UNPKG

@craftercms/studio-ui

Version:

Services, components, models & utils to build CrafterCMS authoring extensions.

552 lines (550 loc) 20.2 kB
/* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ /* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ import { camelize, capitalize, isBlank } from '../utils/string'; import { forkJoin, of } from 'rxjs'; import { errorSelectorApi1, get, getBinary, post, postJSON } from '../utils/ajax'; import { catchError, map, switchMap } from 'rxjs/operators'; import { createLookupTable, nou, toQueryString } from '../utils/object'; import { fetchItemsByPath } from './content'; import { fetchConfigurationDOM, fetchConfigurationJSON, writeConfiguration } from './configuration'; import { beautify, deserialize, entityEncodingTagValueProcessor, serialize } from '../utils/xml'; import { stripDuplicateSlashes } from '../utils/path'; import { asArray } from '../utils/array'; import { fromPromise } from 'rxjs/internal/observable/innerFrom'; const typeMap = { input: 'text', rte: 'html', checkbox: 'boolean', 'image-picker': 'image' }; const systemValidationsNames = [ 'itemManager', 'minSize', 'maxSize', 'maxlength', 'readonly', 'width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight', 'minValue', 'maxValue', 'imgRepositoryUpload', 'imgDesktopUpload', 'videoDesktopUpload', 'videoBrowseRepo', 'audioDesktopUpload', 'audioBrowseRepo' ]; const systemValidationsKeysMap = { minSize: 'minCount', maxSize: 'maxCount', maxlength: 'maxLength', contentTypes: 'allowedContentTypes', tags: 'allowedContentTypeTags', readonly: 'readOnly', width: 'width', height: 'height', minWidth: 'minWidth', minHeight: 'minHeight', maxWidth: 'maxWidth', maxHeight: 'maxHeight', minValue: 'minValue', maxValue: 'maxValue', imgRepositoryUpload: 'allowImagesFromRepo', imgDesktopUpload: 'allowImageUpload', videoDesktopUpload: 'allowVideoUpload', videoBrowseRepo: 'allowVideosFromRepo', audioDesktopUpload: 'allowAudioUpload', audioBrowseRepo: 'allowAudioFromRepo' }; function bestGuessParse(value) { if (nou(value)) { return null; } else if (value === 'true') { return true; } else if (value === 'false') { return false; } else if (!isNaN(parseFloat(value))) { return parseFloat(value); } else { return value; } } function getFieldValidations(fieldProperty, dropTargetsLookup) { const map = asArray(fieldProperty).reduce((table, prop) => { if (prop.name === 'width' || prop.name === 'height') { const parsedValidation = JSON.parse(prop.value); if (parsedValidation.exact) { table[prop.name] = { name: prop.name, type: prop.type, value: parsedValidation.exact }; } else { table[`min${capitalize(prop.name)}`] = { name: prop.name, type: prop.type, value: parsedValidation.min }; table[`max${capitalize(prop.name)}`] = { name: prop.name, type: prop.type, value: parsedValidation.max }; } } else { table[prop.name] = prop; } return table; }, {}); let validations = {}; Object.keys(map).forEach((key) => { if (systemValidationsNames.includes(key)) { if (key === 'itemManager' && dropTargetsLookup) { map.itemManager?.value && map.itemManager.value.split(',').forEach((itemManagerId) => { asArray(dropTargetsLookup[itemManagerId]?.properties?.property).forEach((prop) => { let mappedPropName = systemValidationsKeysMap[prop.name]; if (mappedPropName) { let value = prop.value ? prop.value.split(',') : []; if (mappedPropName === 'allowedContentTypes') { const datasource = dropTargetsLookup[itemManagerId]; validations.allowedContentTypes = validations.allowedContentTypes ?? { id: 'allowedContentTypes', level: 'required', value: {} }; validations.allowedEmbeddedContentTypes = validations.allowedEmbeddedContentTypes ?? { id: 'allowedEmbeddedContentTypes', level: 'required', value: [] }; validations.allowedSharedContentTypes = validations.allowedSharedContentTypes ?? { id: 'allowedSharedContentTypes', level: 'required', value: [] }; validations.allowedSharedExistingContentTypes = validations.allowedSharedExistingContentTypes ?? { id: 'allowedSharedExistingContentTypes', level: 'required', value: [] }; const allowedContentTypesMeta = validations.allowedContentTypes.value; value.forEach((typeId) => { allowedContentTypesMeta[typeId] = allowedContentTypesMeta[typeId] ?? {}; if (datasource.allowEmbedded) { allowedContentTypesMeta[typeId].embedded = true; validations.allowedEmbeddedContentTypes.value.push(typeId); } if (datasource.allowShared) { allowedContentTypesMeta[typeId].shared = true; validations.allowedSharedContentTypes.value.push(typeId); } if (datasource.enableBrowse || datasource.enableSearch) { allowedContentTypesMeta[typeId].sharedExisting = true; validations.allowedSharedExistingContentTypes.value.push(typeId); } }); } else { validations[mappedPropName] = { id: mappedPropName, value, level: 'required' }; } } }); }); } else if (systemValidationsNames.includes(key) && !isBlank(map[key]?.value)) { validations[systemValidationsKeysMap[key]] = { id: systemValidationsKeysMap[key], // TODO: Parse values robustly value: bestGuessParse(map[key].value), level: 'required' }; } } }); return validations; } function getFieldDataSourceValidations(fieldProperty, dataSources) { let validations = {}; if ( dataSources && dataSources.length > 0 && asArray(fieldProperty).find((prop) => ['imageManager', 'videoManager', 'audioManager'].includes(prop.name)) ) { validations = asArray(fieldProperty).reduce((table, prop) => { if (prop.name === 'imageManager' || prop.name === 'videoManager' || prop.name === 'audioManager') { const dataSourcesIds = prop.value.trim() !== '' ? prop.value.split(',') : null; dataSourcesIds?.forEach((id) => { const dataSource = dataSources.find((datasource) => datasource.id === id); if (dataSource && systemValidationsNames.includes(camelize(dataSource.type))) { table[systemValidationsKeysMap[camelize(dataSource.type)]] = { id: systemValidationsKeysMap[camelize(dataSource.type)], value: asArray(dataSource.properties.property).find((prop) => prop.name === 'repoPath').value, level: 'required' }; } }); } return table; }, {}); } return validations; } function parseLegacyFormDefinitionFields( legacyFieldsToBeParsed, currentFieldLookup, dropTargetsLookup, sectionFieldIds, dataSources ) { asArray(legacyFieldsToBeParsed).forEach((legacyField) => { const fieldId = ['file-name', 'internal-name'].includes(legacyField.id) ? camelize(legacyField.id) : legacyField.id; sectionFieldIds?.push(fieldId); const field = { id: fieldId, name: legacyField.title, type: typeMap[legacyField.type] || legacyField.type, sortable: legacyField.type === 'node-selector' || legacyField.type === 'repeat', validations: {}, properties: {}, defaultValue: legacyField.defaultValue }; asArray(legacyField.properties?.property).forEach((legacyProp) => { let value; switch (legacyProp.type) { case 'boolean': value = legacyProp.value === 'true'; break; case 'int': value = parseInt(legacyProp.value); break; default: value = legacyProp.value; } field.properties[legacyProp.name] = { ...legacyProp, value }; }); asArray(legacyField.constraints?.constraint).forEach((legacyProp) => { const value = legacyProp.value.trim(); switch (legacyProp.name) { case 'required': if (value === 'true') { field.validations.required = { id: 'required', value: value === 'true', level: 'required' }; } break; case 'allowDuplicates': break; case 'pattern': break; case 'minSize': break; default: console.log(`[parseLegacyFormDef] Unhandled constraint "${legacyProp.name}"`, legacyProp); } }); switch (legacyField.type) { case 'repeat': field.fields = {}; let min = parseInt(legacyField?.minOccurs); let max = parseInt(legacyField?.maxOccurs); isNaN(min) && (min = 0); field.validations.required = { id: 'required', value: min > 0, level: 'required' }; min > 0 && (field.validations.minCount = { id: 'minCount', value: min, level: 'required' }); !isNaN(max) && (field.validations.maxCount = { id: 'maxCount', value: max, level: 'required' }); parseLegacyFormDefinitionFields(legacyField.fields.field, field.fields, dropTargetsLookup, null, dataSources); break; case 'node-selector': field.validations = { ...field.validations, ...getFieldValidations(legacyField.properties.property, dropTargetsLookup) }; field.validations.required = { id: 'required', value: Boolean(field.validations.minCount), level: 'required' }; break; case 'input': case 'textarea': case 'numeric-input': case 'image-picker': field.validations = { ...field.validations, ...getFieldValidations(legacyField.properties.property), ...getFieldDataSourceValidations(legacyField.properties.property, dataSources) }; break; case 'video-picker': case 'rte': field.validations = { ...field.validations, ...getFieldDataSourceValidations(legacyField.properties.property, dataSources) }; } currentFieldLookup[fieldId] = field; }); } function parseLegacyFormDefinition(definition) { if (nou(definition)) { return {}; } const fields = {}; const sections = []; const dataSources = {}; const dropTargetsLookup = {}; // get receptacles dataSources asArray(definition.datasources?.datasource).forEach((datasource) => { // TODO: Delete datasource.properties after props have been added to the root object? Must update code usages of datasource.properties. const properties = asArray(datasource.properties?.property); properties.forEach((property) => { let value = property.value; switch (property.type) { case 'boolean': value = property.value.trim().toLowerCase() === 'true'; break; case 'int': value = parseInt(property.value); if (isNaN(value)) value = 0; // TODO: There's more `types`. Review getSupportedProperties across different datasources. // case 'minMax': // value = // break; } datasource[property.name] = value; }); if (datasource.type === 'components') dropTargetsLookup[datasource.id] = datasource; dataSources[datasource.id] = datasource; }); // Parse Sections & Fields asArray(definition.sections?.section).forEach((legacySection) => { const fieldIds = []; parseLegacyFormDefinitionFields( legacySection.fields?.field, fields, dropTargetsLookup, fieldIds, asArray(definition.datasources?.datasource) ); sections.push({ description: legacySection.description, expandByDefault: legacySection.defaultOpen === 'true', title: legacySection.title, fields: fieldIds }); }); const topLevelProps = asArray(definition.properties?.property); return { id: definition['content-type'], name: definition.title, quickCreate: (definition.quickCreate ?? '').trim() === 'true', quickCreatePath: definition.quickCreatePath, type: definition.objectType, displayTemplate: topLevelProps.find((prop) => prop.name === 'display-template')?.value, mergeStrategy: topLevelProps.find((prop) => prop.name === 'merge-strategy')?.value, dataSources: Object.values(dataSources), sections, fields }; } function parseLegacyContentType(legacy) { return { id: legacy.form, name: legacy.label.replace('Component - ', ''), quickCreate: legacy.quickCreate, quickCreatePath: legacy.quickCreatePath, type: legacy.type, fields: null, sections: null, displayTemplate: null, dataSources: null, mergeStrategy: null }; } function fetchFormDefinition(site, contentTypeId) { const path = `/content-types${contentTypeId}/form-definition.xml`; return fetchConfigurationJSON(site, path, 'studio').pipe(map((def) => parseLegacyFormDefinition(def.form))); } export function fetchContentType(site, contentTypeId) { return forkJoin({ type: fetchLegacyContentType(site, contentTypeId).pipe(map(parseLegacyContentType)), definition: fetchFormDefinition(site, contentTypeId) }).pipe( map(({ type, definition }) => ({ ...type, ...definition })) ); } export function fetchContentTypes(site) { return post(`/studio/api/2/model/${site}/definitions`).pipe( map(({ response }) => response.types.map((xmlStr) => parseLegacyFormDefinition( deserialize(xmlStr, { parseTagValue: false, tagValueProcessor: entityEncodingTagValueProcessor }).form ) ) ) ); } export function fetchLegacyContentType(site, contentTypeId) { return get(`/studio/api/1/services/api/1/content/get-content-type.json?site_id=${site}&type=${contentTypeId}`).pipe( map((response) => response?.response) ); } export function fetchLegacyContentTypes(site, path) { const qs = toQueryString({ site, path }); return get(`/studio/api/1/services/api/1/content/get-content-types.json${qs}`).pipe( map((response) => response?.response), catchError(errorSelectorApi1) ); } export function fetchContentTypeUsage(site, contentTypeId) { const qs = toQueryString({ siteId: site, contentType: contentTypeId }); return get(`/studio/api/2/configuration/content-type/usage${qs}`).pipe( map((response) => response?.response.usage), switchMap((usage) => usage.templates.length + usage.scripts.length + usage.content.length === 0 ? // @ts-ignore - avoiding creating new object with the exact same structure just for typescript's sake of(usage) : fetchItemsByPath(site, [...usage.templates, ...usage.scripts, ...usage.content]).pipe( map((items) => { const itemLookup = createLookupTable(items, 'path'); const mapper = (path) => itemLookup[path]; return { templates: usage.templates.map(mapper).filter(Boolean), scripts: usage.scripts.map(mapper).filter(Boolean), content: usage.content.map(mapper).filter(Boolean) }; }) ) ) ); } export function deleteContentType(site, contentTypeId) { return postJSON(`/studio/api/2/configuration/content-type/delete`, { siteId: site, contentType: contentTypeId, deleteDependencies: true }).pipe(map(() => true)); } export function associateTemplate(site, contentTypeId, displayTemplate) { const path = stripDuplicateSlashes(`/content-types/${contentTypeId}/form-definition.xml`); const module = 'studio'; return fetchConfigurationDOM(site, path, 'studio').pipe( switchMap((doc) => { const properties = doc.querySelectorAll('properties > property'); const property = Array.from(properties).find( (node) => node.querySelector('name').innerHTML.trim() === 'display-template' ); if (property) { property.querySelector('value').innerHTML = displayTemplate; } else { const property = document.createElement('property'); const name = document.createElement('name'); const label = document.createElement('label'); const value = document.createElement('value'); const type = document.createElement('type'); name.innerHTML = 'display-template'; label.innerHTML = 'Display Template'; value.innerHTML = displayTemplate; type.innerHTML = 'template'; property.appendChild(name); property.appendChild(label); property.appendChild(value); property.appendChild(type); doc.querySelector('properties').appendChild(property); } return fromPromise(beautify(serialize(doc))).pipe( switchMap((xml) => writeConfiguration(site, path, module, xml)) ); }) ); } export function dissociateTemplate(site, contentTypeId) { const path = stripDuplicateSlashes(`/content-types/${contentTypeId}/form-definition.xml`); const module = 'studio'; return fetchConfigurationDOM(site, path, 'studio').pipe( switchMap((doc) => { const properties = doc.querySelectorAll('properties > property'); const property = Array.from(properties).find( (node) => node.querySelector('name').innerHTML.trim() === 'display-template' ); if (property) { property.querySelector('value').innerHTML = ''; return fromPromise(beautify(serialize(doc))).pipe( switchMap((xml) => writeConfiguration(site, path, module, xml)) ); } else { return of(false); } }) ); } export function fetchPreviewImage(site, contentTypeId) { const qs = toQueryString({ siteId: site, contentTypeId }); return getBinary(`/studio/api/2/configuration/content-type/preview_image${qs}`); } /** * @deprecated Only for Forms Engine v1 (FE1) usage. FE1 gets replaced by FE2 in CrafterCMS v5. **/ export function getFetchLegacyFormControllerUrl(site, contentTypeId) { const qs = toQueryString({ siteId: site, contentTypeId }); return `/studio/api/2/configuration/content-type/form_controller${qs}`; }