UNPKG

@craftercms/studio-ui

Version:

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

545 lines (543 loc) 20.5 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, zip } from 'rxjs'; import { errorSelectorApi1, get, getBinary, postJSON } from '../utils/ajax'; import { catchError, map, pluck, switchMap } from 'rxjs/operators'; import { createLookupTable, nou, toQueryString } from '../utils/object'; import { fetchItemsByPath } from './content'; import { fetchConfigurationDOM, fetchConfigurationJSON, writeConfiguration } from './configuration'; import { beautify, serialize } from '../utils/xml'; import { stripDuplicateSlashes } from '../utils/path'; import { asArray } from '../utils/array'; 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' ]; 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' }; 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) => { var _a, _b; if (systemValidationsNames.includes(key)) { if (key === 'itemManager' && dropTargetsLookup) { ((_a = map.itemManager) === null || _a === void 0 ? void 0 : _a.value) && map.itemManager.value.split(',').forEach((itemManagerId) => { var _a, _b; asArray( (_b = (_a = dropTargetsLookup[itemManagerId]) === null || _a === void 0 ? void 0 : _a.properties) === null || _b === void 0 ? void 0 : _b.property ).forEach((prop) => { var _a, _b; let mappedPropName = systemValidationsKeysMap[prop.name]; if (mappedPropName) { let value = prop.value ? prop.value.split(',') : []; if (mappedPropName === 'allowedContentTypes') { const datasource = dropTargetsLookup[itemManagerId]; validations.allowedEmbeddedContentTypes = (_a = validations.allowedEmbeddedContentTypes) !== null && _a !== void 0 ? _a : { id: 'allowedEmbeddedContentTypes', level: 'required', value: [] }; validations.allowedSharedContentTypes = (_b = validations.allowedSharedContentTypes) !== null && _b !== void 0 ? _b : { id: 'allowedSharedContentTypes', level: 'required', value: [] }; datasource.allowEmbedded && (validations.allowedEmbeddedContentTypes.value = validations.allowedEmbeddedContentTypes.value.concat(value)); datasource.allowShared && (validations.allowedSharedContentTypes.value = validations.allowedSharedContentTypes.value.concat(value)); // If there is more than one Components DS on this type, make sure they don't override each other as they get parsed if (validations[mappedPropName]) { value = validations[mappedPropName].value.concat(value); } } validations[mappedPropName] = { id: mappedPropName, value, level: 'required' }; } }); }); } else if ( systemValidationsNames.includes(key) && !isBlank((_b = map[key]) === null || _b === void 0 ? void 0 : _b.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'].includes(prop.name)) ) { validations = asArray(fieldProperty).reduce((table, prop) => { if (prop.name === 'imageManager' || prop.name === 'videoManager') { const dataSourcesIds = prop.value.trim() !== '' ? prop.value.split(',') : null; dataSourcesIds === null || dataSourcesIds === void 0 ? void 0 : 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) => { var _a, _b; const fieldId = ['file-name', 'internal-name'].includes(legacyField.id) ? camelize(legacyField.id) : legacyField.id; sectionFieldIds === null || sectionFieldIds === void 0 ? void 0 : 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((_a = legacyField.properties) === null || _a === void 0 ? void 0 : _a.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] = Object.assign(Object.assign({}, legacyProp), { value }); }); asArray((_b = legacyField.constraints) === null || _b === void 0 ? void 0 : _b.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 === null || legacyField === void 0 ? void 0 : legacyField.minOccurs); let max = parseInt(legacyField === null || legacyField === void 0 ? void 0 : 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 = Object.assign( Object.assign({}, 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 = Object.assign( Object.assign(Object.assign({}, field.validations), getFieldValidations(legacyField.properties.property)), getFieldDataSourceValidations(legacyField.properties.property, dataSources) ); break; case 'video-picker': case 'rte': field.validations = Object.assign( Object.assign({}, field.validations), getFieldDataSourceValidations(legacyField.properties.property, dataSources) ); } currentFieldLookup[fieldId] = field; }); } function parseLegacyFormDefinition(definition) { var _a, _b, _c, _d, _e; if (nou(definition)) { return {}; } const fields = {}; const sections = []; const dataSources = {}; const dropTargetsLookup = {}; // get receptacles dataSources asArray((_a = definition.datasources) === null || _a === void 0 ? void 0 : _a.datasource).forEach((datasource) => { var _a; // TODO: Delete datasource.properties after props have been added to the root object? Must update code usages of datasource.properties. const properties = asArray((_a = datasource.properties) === null || _a === void 0 ? void 0 : _a.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((_b = definition.sections) === null || _b === void 0 ? void 0 : _b.section).forEach((legacySection) => { var _a, _b; const fieldIds = []; parseLegacyFormDefinitionFields( (_a = legacySection.fields) === null || _a === void 0 ? void 0 : _a.field, fields, dropTargetsLookup, fieldIds, asArray((_b = definition.datasources) === null || _b === void 0 ? void 0 : _b.datasource) ); sections.push({ description: legacySection.description, expandByDefault: legacySection.defaultOpen === 'true', title: legacySection.title, fields: fieldIds }); }); const topLevelProps = asArray((_c = definition.properties) === null || _c === void 0 ? void 0 : _c.property); return { // Find display template displayTemplate: (_d = topLevelProps.find((prop) => prop.name === 'display-template')) === null || _d === void 0 ? void 0 : _d.value, mergeStrategy: (_e = topLevelProps.find((prop) => prop.name === 'merge-strategy')) === null || _e === void 0 ? void 0 : _e.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 }) => Object.assign(Object.assign({}, type), definition))); } export function fetchContentTypes(site, query) { return fetchLegacyContentTypes(site).pipe( map((response) => ((query === null || query === void 0 ? void 0 : query.type) ? response.filter( (contentType) => contentType.type === query.type && contentType.name !== '/component/level-descriptor' ) : response ).map(parseLegacyContentType) ), switchMap((contentTypes) => zip( of(contentTypes), forkJoin( contentTypes.reduce((hash, contentType) => { hash[contentType.id] = fetchFormDefinition(site, contentType.id); return hash; }, {}) ) ) ), map(([contentTypes, formDefinitions]) => contentTypes.map((contentType) => Object.assign(Object.assign({}, contentType), formDefinitions[contentType.id])) ) ); } export function fetchLegacyContentType(site, contentTypeId) { return get(`/studio/api/1/services/api/1/content/get-content-type.json?site_id=${site}&type=${contentTypeId}`).pipe( pluck('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( pluck('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( pluck('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 writeConfiguration(site, path, module, beautify(serialize(doc))); }) ); } 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 writeConfiguration(site, path, module, beautify(serialize(doc))); } 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}`); }