import { singular } from 'pluralize'; import type { JSONSchema4 } from 'json-schema'; import { fieldAffectsData, Field, Option, FieldAffectingData, tabHasName } from '../fields/config/types'; import { SanitizedCollectionConfig } from '../collections/config/types'; import { SanitizedGlobalConfig } from '../globals/config/types'; import deepCopyObject from './deepCopyObject'; import { groupOrTabHasRequiredSubfield } from './groupOrTabHasRequiredSubfield'; import { toWords } from './formatLabels'; import { SanitizedConfig } from '../config/types'; const nonOptionalFieldTypes = ['group', 'array', 'blocks']; const propertyIsOptional = (field: Field) => { return fieldAffectsData(field) && (field.required === true || nonOptionalFieldTypes.includes(field.type)); }; function getCollectionIDType(collections: SanitizedCollectionConfig[], slug: string): 'string' | 'number' { const matchedCollection = collections.find((collection) => collection.slug === slug); const customIdField = matchedCollection.fields.find((field) => 'name' in field && field.name === 'id'); if (customIdField && customIdField.type === 'number') { return 'number'; } return 'string'; } function returnOptionEnums(options: Option[]): string[] { return options.map((option) => { if (typeof option === 'object' && 'value' in option) { return option.value; } return option; }); } function generateFieldTypes(config: SanitizedConfig, fields: Field[]): { properties: { [k: string]: JSONSchema4; } required: string[] } { let topLevelProps = []; let requiredTopLevelProps = []; return { properties: Object.fromEntries( fields.reduce((properties, field) => { let fieldSchema: JSONSchema4; switch (field.type) { case 'text': case 'textarea': case 'code': case 'email': case 'date': { fieldSchema = { type: 'string' }; break; } case 'number': { fieldSchema = { type: 'number' }; break; } case 'checkbox': { fieldSchema = { type: 'boolean' }; break; } case 'json': { fieldSchema = { type: 'object' }; break; } case 'richText': { fieldSchema = { type: 'array', items: { type: 'object', }, }; break; } case 'radio': { fieldSchema = { type: 'string', enum: returnOptionEnums(field.options), }; break; } case 'select': { const selectType: JSONSchema4 = { type: 'string', enum: returnOptionEnums(field.options), }; if (field.hasMany) { fieldSchema = { type: 'array', items: selectType, }; } else { fieldSchema = selectType; } break; } case 'point': { fieldSchema = { type: 'array', minItems: 2, maxItems: 2, items: [ { type: 'number', }, { type: 'number', }, ], }; break; } case 'relationship': { if (Array.isArray(field.relationTo)) { if (field.hasMany) { fieldSchema = { oneOf: [ { type: 'array', items: { oneOf: field.relationTo.map((relation) => { const idFieldType = getCollectionIDType(config.collections, relation); return { type: 'object', additionalProperties: false, properties: { value: { type: idFieldType, }, relationTo: { const: relation, }, }, required: ['value', 'relationTo'], }; }), }, }, { type: 'array', items: { oneOf: field.relationTo.map((relation) => { return { type: 'object', additionalProperties: false, properties: { value: { $ref: `#/definitions/${relation}`, }, relationTo: { const: relation, }, }, required: ['value', 'relationTo'], }; }), }, }, ], }; } else { fieldSchema = { oneOf: field.relationTo.map((relation) => { const idFieldType = getCollectionIDType(config.collections, relation); return { type: 'object', additionalProperties: false, properties: { value: { oneOf: [ { type: idFieldType, }, { $ref: `#/definitions/${relation}`, }, ], }, relationTo: { const: relation, }, }, required: ['value', 'relationTo'], }; }), }; } } else { const idFieldType = getCollectionIDType(config.collections, field.relationTo); if (field.hasMany) { fieldSchema = { oneOf: [ { type: 'array', items: { type: idFieldType, }, }, { type: 'array', items: { $ref: `#/definitions/${field.relationTo}`, }, }, ], }; } else { fieldSchema = { oneOf: [ { type: idFieldType, }, { $ref: `#/definitions/${field.relationTo}`, }, ], }; } } break; } case 'upload': { const idFieldType = getCollectionIDType(config.collections, field.relationTo); fieldSchema = { oneOf: [ { type: idFieldType, }, { $ref: `#/definitions/${field.relationTo}`, }, ], }; break; } case 'blocks': { fieldSchema = { type: 'array', items: { oneOf: field.blocks.map((block) => { const blockSchema = generateFieldTypes(config, block.fields); return { type: 'object', additionalProperties: false, properties: { ...blockSchema.properties, blockType: { const: block.slug, }, }, required: [ 'blockType', ...blockSchema.required, ], }; }), }, }; break; } case 'array': { fieldSchema = { type: 'array', items: { type: 'object', additionalProperties: false, ...generateFieldTypes(config, field.fields), }, }; break; } case 'row': case 'collapsible': { const topLevelFields = generateFieldTypes(config, field.fields); requiredTopLevelProps = requiredTopLevelProps.concat(topLevelFields.required); topLevelProps = topLevelProps.concat(Object.entries(topLevelFields.properties).map((prop) => prop)); break; } case 'tabs': { field.tabs.forEach((tab) => { if (tabHasName(tab)) { const hasRequiredSubfields = groupOrTabHasRequiredSubfield(tab); if (hasRequiredSubfields) requiredTopLevelProps.push(tab.name); topLevelProps.push([ tab.name, { type: 'object', additionalProperties: false, ...generateFieldTypes(config, tab.fields), }, ]); } else { const topLevelFields = generateFieldTypes(config, tab.fields); requiredTopLevelProps = requiredTopLevelProps.concat(topLevelFields.required); topLevelProps = topLevelProps.concat(Object.entries(topLevelFields.properties).map((prop) => prop)); } }); break; } case 'group': { fieldSchema = { type: 'object', additionalProperties: false, ...generateFieldTypes(config, field.fields), }; break; } default: { break; } } if (fieldSchema && fieldAffectsData(field)) { return [ ...properties, [ field.name, { ...fieldSchema, }, ], ]; } return [ ...properties, ...topLevelProps, ]; }, []), ), required: [ ...fields .filter(propertyIsOptional) .map((field) => (fieldAffectsData(field) ? field.name : '')), ...requiredTopLevelProps, ], }; } export function entityToJsonSchema(config: SanitizedConfig, incomingEntity: SanitizedCollectionConfig | SanitizedGlobalConfig): JSONSchema4 { const entity = deepCopyObject(incomingEntity); const title = entity.typescript?.interface ? entity.typescript.interface : singular(toWords(entity.slug, true)); const idField: FieldAffectingData = { type: 'text', name: 'id', required: true }; const customIdField = entity.fields.find((field) => fieldAffectsData(field) && field.name === 'id') as FieldAffectingData; if (customIdField) { customIdField.required = true; } else { entity.fields.unshift(idField); } if ('timestamps' in entity && entity.timestamps !== false) { entity.fields.push( { type: 'text', name: 'createdAt', required: true, }, { type: 'text', name: 'updatedAt', required: true, }, ); } return { title, type: 'object', additionalProperties: false, ...generateFieldTypes(config, entity.fields), }; } export function generateEntityObject(config: SanitizedConfig, type: 'collections' | 'globals'): JSONSchema4 { return { type: 'object', properties: Object.fromEntries(config[type].map(({ slug }) => [ slug, { $ref: `#/definitions/${slug}`, }, ])), required: config[type].map(({ slug }) => slug), additionalProperties: false, }; }