Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/cms/components/groups/Chooser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
},
Expand Down
220 changes: 220 additions & 0 deletions assets/cms/js/fetch.js
Original file line number Diff line number Diff line change
@@ -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<string, any>|null|undefined} request
* @param {Record<string, string>|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<string, any>|null|undefined} request The request options and body
*
* @throws {Error} If the response contains an error or is not ok
*
* @returns {Promise<any>} 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<string, any>|undefined} request The request options and body
*
* @throws {Error} If the response contains an error or is not ok
*
* @returns {Promise<string>} 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)
2 changes: 1 addition & 1 deletion assets/cms/js/page-notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const modules = new Map([
class PageNotification {
constructor() {
const notificationsBoxHTML = `
<div class="ccm-notifications-box">
<div class="ccm-notifications-box ccm-ui">
<div class="ccm-notifications-box-header">
<div data-bs-toggle="collapse" data-bs-target=".ccm-notifications-box-body" aria-expanded="true" role="button">${ccmi18n.notifications}</div>
<a href="#" class="btn-close ccm-notifications-box-close"></a></div>
Expand Down
18 changes: 18 additions & 0 deletions assets/cms/js/vue/Manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>}>
*
* @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.
*
Expand Down
2 changes: 1 addition & 1 deletion assets/conversations/js/frontend/conversations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions assets/conversations/scss/frontend.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@
@import 'bootstrap/scss/mixins';
@import '../../cms/scss/variables';

@import 'dropzone/dist/dropzone.css';

@import 'frontend/frontend';
9 changes: 3 additions & 6 deletions assets/stacks/scss/backend/_stack-editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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}",
Expand Down