@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
545 lines (543 loc) • 20.5 kB
JavaScript
/*
* 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}`);
}