import { JSONSchema7, JSONSchema7Definition } from 'json-schema'; import _ from 'lodash'; import { CardBlock, CardBlocks, CardDetailTemplate, CardFormTemplate, CardFragment, FieldType, FragmentField, InputBlock, TagsBlock, } from './schema'; export type CoastJSONSchema7Definition = JSONSchema7Definition & { fieldType?: 'cards' | 'tags' }; export type CardSingleTemplate = CardFormTemplate | CardDetailTemplate; export type FragmentUUID = string; export const TupleFieldTypes: FieldType[] = ['questionCheckbox', 'questionSingleSelect', 'questionTextArea']; export const FieldDelimiter = '_'; export const makeFieldName = (baseName: string, uuid: string) => `${baseName}${FieldDelimiter}${uuid}`; export const makeTupleFieldName = (name: string, tupleName: string): string => { const [base, uuid] = name.split(FieldDelimiter); return makeFieldName(`${base}${FieldDelimiter}${tupleName}`, uuid); }; function getDefinitionFromType(fieldType: FieldType): CoastJSONSchema7Definition { switch (fieldType) { case 'cards': return { type: ['array', 'null'], items: { anyOf: [ { type: 'string', }, { type: 'object', required: ['id'], additionalProperties: false, properties: { id: { type: 'string', }, }, }, ], }, fieldType: 'cards', }; case 'questionSingleSelect': case 'tags': return { type: ['array', 'null'], items: { type: 'string', }, fieldType: 'tags', }; case 'float': case 'percent': return { type: 'number', }; case 'toggle': case 'questionCheckbox': return { type: 'boolean', }; case 'image': case 'file': return { type: 'array', items: { type: 'object', required: ['url', 'name', 'contentType'], additionalProperties: false, properties: { url: { type: 'string', format: 'uri', }, name: { type: 'string', }, width: { type: 'number', }, height: { type: 'number', }, contentType: { type: ['string', 'null'], }, }, }, }; default: return { type: 'string', }; } } export function generateCardDefinition({ fragment, uuid, rootCardDefinition, }: { fragment: CardFragment; uuid: FragmentUUID; rootCardDefinition: JSONSchema7; }): JSONSchema7 { const cardDef = { ...rootCardDefinition, }; if (_.isNil(cardDef.required)) { cardDef.required = []; } for (const f of fragment.items) { if (!_.isNil(cardDef.properties)) { const name = makeFieldName(f.name, uuid); cardDef.properties[name] = getDefinitionFromType(f.fieldType); if (f.required) { cardDef.required.push(name); } if (TupleFieldTypes.includes(f.fieldType)) { cardDef.properties[makeTupleFieldName(name, 'file')] = getDefinitionFromType('file'); cardDef.properties[makeTupleFieldName(name, 'notes')] = getDefinitionFromType('textarea'); } } } return cardDef; } function getValidationProps(settings: FragmentField['settings']): InputBlock['validation'] { if (!_.isNil(settings?.validation)) { return { max: settings.validation.max, }; } return undefined; } function getCardBlockFromFragmentField( uuid: FragmentUUID, field: FragmentField, context: CardSingleTemplate['context'] ): CardBlock | undefined { switch (field.fieldType) { case 'text': case 'textarea': case 'toggle': case 'questionTextArea': case 'questionCheckbox': case 'questionSingleSelect': const passThroughBlockFields = _.omit(field, ['name', 'required', 'type', 'settings']); const validation = getValidationProps(field.settings); return context === 'detail' ? { type: 'field', name: makeFieldName(field.name, uuid), ...passThroughBlockFields, } : { type: 'input', name: makeFieldName(field.name, uuid), ...(!_.isNil(validation) ? { validation } : {}), ...passThroughBlockFields, required: field.required, }; default: return undefined; } } /** * * Iterates over the blocks array to find the target block specified by `compose` configuration. * * The precedence followed is: * - by fieldName * - by blockType * * @param blocks collection of blocks from the root template * @param compose configuration to reference a block * @returns index number of found block in blocks array */ function findTargetBlockIndex(blocks: CardBlocks, compose: CardFragment['compose']): number | undefined { const indexByName = !_.isNil(compose?.fieldName) && blocks.findIndex((block: CardBlock) => { const tagsBlock = block as TagsBlock; const namedBlock = block as InputBlock; if (tagsBlock.names?.length > 0) { return tagsBlock.names.includes(compose?.fieldName as string); } return namedBlock.name === compose.fieldName; }) >= 0; if (typeof indexByName === 'number' && indexByName > -1) { return indexByName; } const indexByType = !_.isNil(compose?.blockType) && blocks.findIndex((block: CardBlock) => { const typedBlock = block; return typedBlock.type === compose.blockType; }) >= 0; if (typeof indexByType === 'number' && indexByType > -1) { return indexByType; } } /** * * Analyses whether the referenced block was found and the position param * from compose to return where the partition should be made in the array. * * Basically, this guards agains the case where the compose sets position as 'prepend' * but the target block wasn't found, when we should in fact append at the end. * * @param targetBlockIndex index of the reference block (or undefined if not found) * @param compose configuration from fragment template * @param originalBlocks original array of blocks */ function getPartitionIndex( targetBlockIndex: number | undefined, compose: CardFragment['compose'], originalBlocks: CardBlocks ): number { if (typeof targetBlockIndex === 'number') { return compose?.position === 'prepend' ? targetBlockIndex : targetBlockIndex + 1; } else { return originalBlocks.length; } } export function generateUITemplate({ slug, title, fragment, uuid, rootUITemplate, }: { slug: string; title: string; fragment: CardFragment; uuid: FragmentUUID; rootUITemplate: T; }): T { const originalBlocks: CardBlocks = [...rootUITemplate.cardBlocks]; const newBlocks = fragment.items .map((item: FragmentField) => getCardBlockFromFragmentField(uuid, item, rootUITemplate.context)) .filter(Boolean); const targetBlockIndex = findTargetBlockIndex(originalBlocks, fragment.compose); const partitionIndex = getPartitionIndex(targetBlockIndex, fragment?.compose, originalBlocks); const preBlocks = originalBlocks.slice(0, partitionIndex); const postBlocks = originalBlocks.slice(partitionIndex); const cardBlocks = [...preBlocks, ...newBlocks, ...postBlocks]; return { ...rootUITemplate, slug, title, cardBlocks, } as T; }