diff --git a/assets/cms/components/groups/Chooser.vue b/assets/cms/components/groups/Chooser.vue index 0fc8dd7d..4d04036b 100644 --- a/assets/cms/components/groups/Chooser.vue +++ b/assets/cms/components/groups/Chooser.vue @@ -144,7 +144,7 @@ export default { gID = parseInt(gID) || 0 // Fallback to legacy event if no Vue listener is defined if (!this.$listeners.select) { - ConcreteEvent.publish('SelectGroup', {gID, gName, gDisplayName}) + ConcreteEvent.publish('SelectGroup', { gID, gName, gDisplayName }) } this.$emit('select', { gID, gName, gDisplayName }) }, diff --git a/assets/cms/js/fetch.js b/assets/cms/js/fetch.js new file mode 100644 index 00000000..d7d5f112 --- /dev/null +++ b/assets/cms/js/fetch.js @@ -0,0 +1,220 @@ +;(function(global) { + 'use strict' + + /** + * Recursively add fields to URLSearchParams. + * Used by buildRequestBody(). + * + * @param {string} prefix + * @param {Record|Array|any} value + * @param {URLSearchParams} urlSearchParams + * + * @returns {void} + */ + function addToUrlSearchParams(prefix, value, urlSearchParams) { + if (value === null || value === undefined || typeof value !== 'object') { + return + } + if (!prefix && Array.isArray(value)) { + return + } + for (const [key, val] of Object.entries(value)) { + if (val === null || val === undefined) { + continue + } + const fieldName = prefix ? `${prefix}[${key}]` : key + if (typeof val === 'object') { + addToUrlSearchParams(fieldName, val, urlSearchParams) + } else { + urlSearchParams.append(fieldName, String(val)) + } + } + } + + /** + * Build the request body for an AJAX request. + * + * @param {Record|any} data The object to build the body from + * + * @returns {URLSearchParams} The request body + * + * @example + * buildRequestBody({key: 'value', arr: [1, 2, 3], nested: {a: 'b'}}) + */ + function buildRequestBody(data) { + const urlSearchParams = new URLSearchParams() + addToUrlSearchParams('', data, urlSearchParams) + return urlSearchParams + } + + /** + * Prepare the request options for window.fetch(). + * + * @param {RequestInit|Record|null|undefined} request + * @param {Record|null|undefined} headers Additional headers to add (will not override existing ones) + * + * @returns {RequestInit} + */ + function prepareRequest(request, headers) { + if (request) { + try { + request = global.structuredClone(request) + } catch {} + } else { + request = {} + } + // Default to GET if no method is specified + request.method = String(request.method || 'GET').toUpperCase() + if (request.body?.constructor === Object) { + // Convert body object to URLSearchParams + request.body = buildRequestBody(request.body) + } + if (!request.headers) { + request.headers = {} + } + if (!request.cache && request.method !== 'GET') { + // Disable caching for non-GET requests by default + request.cache = 'no-cache' + } + const existingHeaderKeys = Object.keys(request.headers).map((key) => key.toLowerCase()) + if (headers) { + for (const [name, value] of Object.entries(headers)) { + const lowerCaseName = name.toLowerCase() + if (!existingHeaderKeys.includes(lowerCaseName)) { + request.headers[name] = value + existingHeaderKeys.push(lowerCaseName) + } + } + } + if (!existingHeaderKeys.includes('x-requested-with')) { + // Just to let the server know this is an AJAX request + request.headers['X-Requested-With'] = 'XMLHttpRequest' + } + if (request.method !== 'GET' && request.body && !existingHeaderKeys.includes('content-type')) { + // We want to use $_POST on the server side + request.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8' + } + return request + } + + /** + * Check a JSON response for errors. + * + * @param {any} responseData + * + * @throws {Error} If the response data contains errors (the thrown error will have a responseData property) + */ + function checkJsonResponse(responseData) { + if (responseData?.errors?.length) { + const error = new Error(responseData.errors[0]) + error.responseData = responseData + throw error + } + if (responseData?.error) { + const error = new Error(responseData.error) + error.responseData = responseData + throw error + } + } + + /** + * Fetch JSON data from a URL. + * + * @param {string} url The URL to fetch data from + * @param {RequestInit|Record|null|undefined} request The request options and body + * + * @throws {Error} If the response contains an error or is not ok + * + * @returns {Promise} The JSON response + * + * @example + * try { + * const data = await fetchJson('/api/data', { + * method: 'POST', + * body: { + * key: 'value' + * } + * }); + * } catch (error) { + * window.alert(error.message); + * } + */ + async function fetchJson(url, request) { + request = prepareRequest(request, { Accept: 'application/json' }) + const response = await fetch(url, request) + const responseText = await response.text() + let responseData + try { + responseData = JSON.parse(responseText) + } catch { + throw new Error(responseText) + } + checkJsonResponse(responseData) + if (!response.ok) { + throw new Error(responseText) + } + return responseData + } + + /** + * Fetch an HTML chunk from a URL. + * + * @param {string} url The URL to fetch data from + * @param {RequestInit|Record|undefined} request The request options and body + * + * @throws {Error} If the response contains an error or is not ok + * + * @returns {Promise} The HTML response + * + * @example + * try { + * const data = await fetchHtml('/api/render', { + * method: 'POST', + * body: { + * key: 'value' + * } + * }); + * } catch (error) { + * window.alert(error.message); + * } + */ + async function fetchHtml(url, request) { + request = prepareRequest( + request, + { + Accept: [ + // Prefer HTML + 'text/html', + // ... but accept JSON in case of errors + 'application/json;q=0.9', + // ... or plain text as a last resort fallback + 'text/plain;q=0.8' + ].join(', ') + } + ) + const response = await fetch(url, request) + const responseText = await response.text() + let responseData + try { + // Try to see if it's JSON with errors + responseData = JSON.parse(responseText) + } catch { + // Not JSON, that's fine + responseData = null + } + if (responseData) { + checkJsonResponse(responseData) + } + + if (!response.ok) { + throw new Error(responseText) + } + return responseText + } + + global.ConcreteFetch = { + buildRequestBody, + json: fetchJson, + html: fetchHtml + } +})(global) diff --git a/assets/cms/js/page-notification.js b/assets/cms/js/page-notification.js index ec3a3f84..10ff827b 100644 --- a/assets/cms/js/page-notification.js +++ b/assets/cms/js/page-notification.js @@ -14,7 +14,7 @@ const modules = new Map([ class PageNotification { constructor() { const notificationsBoxHTML = ` -
+
${ccmi18n.notifications}
diff --git a/assets/cms/js/vue/Manager.js b/assets/cms/js/vue/Manager.js index 038e9ee9..155fd8d8 100644 --- a/assets/cms/js/vue/Manager.js +++ b/assets/cms/js/vue/Manager.js @@ -59,6 +59,24 @@ export default class Manager { }, 10) } + /** + * Activates a particular context (and its components) for a particular selector, returning a promise. + * + * @param {String} context + * + * @returns {Promise<{Vue: typeof Vue, options: Record}> + * + * @example + * const {Vue, options} = await Concrete.Vue.activateContextAsync('cms') + */ + activateContextAsync(context) { + return new Promise((resolve) => { + this.activateContext(context, (Vue, options) => { + resolve({ Vue, options }) + }) + }) + } + /** * For a given string `context`, adds the passed components to make them available within that context. * diff --git a/assets/conversations/js/frontend/conversations.js b/assets/conversations/js/frontend/conversations.js index 32b037b9..08f853a4 100644 --- a/assets/conversations/js/frontend/conversations.js +++ b/assets/conversations/js/frontend/conversations.js @@ -176,7 +176,7 @@ window._ = _ updateStatus: function(data) { if (data.status == 'ready') { var $form = $('form[data-conversation-form=subscribe]') - $('button').on('click', $form, function(e) { + $('button.btn-primary').on('click', $form, function(e) { e.preventDefault() e.stopPropagation() $.ajax({ diff --git a/assets/conversations/scss/frontend.scss b/assets/conversations/scss/frontend.scss index 3820d6f7..e55cc133 100644 --- a/assets/conversations/scss/frontend.scss +++ b/assets/conversations/scss/frontend.scss @@ -9,4 +9,6 @@ @import 'bootstrap/scss/mixins'; @import '../../cms/scss/variables'; +@import 'dropzone/dist/dropzone.css'; + @import 'frontend/frontend'; diff --git a/assets/stacks/scss/backend/_stack-editor.scss b/assets/stacks/scss/backend/_stack-editor.scss index ec525341..d08c30a0 100644 --- a/assets/stacks/scss/backend/_stack-editor.scss +++ b/assets/stacks/scss/backend/_stack-editor.scss @@ -2,12 +2,13 @@ #ccm-stack-editor { margin-top: 100px; + .ccm-dashboard-breadcrumb { + font-size: 1rem; margin-bottom: 0; margin-top: 0; padding-bottom: 0; padding-top: 0; - font-size: 1rem; a.dropdown-toggle { color: $gray-600; @@ -17,8 +18,4 @@ .ccm-stack-editor-column { transition: width 0.1s cubic-bezier(0.19, 1, 0.22, 1); } - - #ccm-stack-editor { - margin-top: $ccm-toolbar-height * 2; - } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index bdc0ceb5..1f2e17ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@concretecms/bedrock", - "version": "1.7.0-alpha.1", + "version": "1.6.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@concretecms/bedrock", - "version": "1.7.0-alpha.1", + "version": "1.6.7", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^5.15.1", diff --git a/package.json b/package.json index e22b9067..34db04ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@concretecms/bedrock", - "version": "1.7.0-alpha.1", + "version": "1.7.0", + "version": "1.6.7", "description": "The asset framework and dependencies for Concrete CMS.", "scripts": { "lint": "standardx \"**/*.{js,vue}\" && stylelint assets/**/*.{scss,vue}",