diff --git a/packages/payload/src/admin/components/forms/field-types/RichText/types.ts b/packages/payload/src/admin/components/forms/field-types/RichText/types.ts index 13d50950d9..a459240499 100644 --- a/packages/payload/src/admin/components/forms/field-types/RichText/types.ts +++ b/packages/payload/src/admin/components/forms/field-types/RichText/types.ts @@ -29,9 +29,14 @@ type RichTextAdapterBase< }) => Promise | null outputSchema?: ({ field, + interfaceNameDefinitions, isRequired, }: { field: RichTextField + /** + * Allows you to define new top-level interfaces that can be re-used in the output schema. + */ + interfaceNameDefinitions: Map isRequired: boolean }) => JSONSchema4 populationPromise?: (data: { diff --git a/packages/payload/src/bin/generateTypes.ts b/packages/payload/src/bin/generateTypes.ts index 0b1b49be79..4f08a14ea9 100644 --- a/packages/payload/src/bin/generateTypes.ts +++ b/packages/payload/src/bin/generateTypes.ts @@ -31,6 +31,10 @@ export async function generateTypes(): Promise { style: { singleQuote: true, }, + // Generates code for $defs that aren't referenced by the schema. Reason: + // If a field defines an interfaceName, it should be included in the generated types + // even if it's not used by another type. Reason: the user might want to use it in their own code. + unreachableDefinitions: true, }).then((compiled) => { if (config.typescript.declare !== false) { compiled += `\n\n${declare}` diff --git a/packages/payload/src/exports/utilities.ts b/packages/payload/src/exports/utilities.ts index ba700a7765..809b9e9f9e 100644 --- a/packages/payload/src/exports/utilities.ts +++ b/packages/payload/src/exports/utilities.ts @@ -9,6 +9,7 @@ export { combineMerge } from '../utilities/combineMerge' export { configToJSONSchema, entityToJSONSchema, + fieldsToJSONSchema, withNullableJSONSchemaType, } from '../utilities/configToJSONSchema' export { createArrayFromCommaDelineated } from '../utilities/createArrayFromCommaDelineated' diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index 5680724ee2..82ef0bd8ad 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -78,9 +78,12 @@ export function withNullableJSONSchemaType( return fieldTypes } -function fieldsToJSONSchema( +export function fieldsToJSONSchema( collectionIDFieldTypes: { [key: string]: 'number' | 'string' }, fields: Field[], + /** + * Allows you to define new top-level interfaces that can be re-used in the output schema. + */ interfaceNameDefinitions: Map, ): { properties: { @@ -144,6 +147,7 @@ function fieldsToJSONSchema( if (field.editor.outputSchema) { fieldSchema = field.editor.outputSchema({ field, + interfaceNameDefinitions, isRequired, }) } else { @@ -524,8 +528,11 @@ export function configToJSONSchema( config: SanitizedConfig, defaultIDType?: 'number' | 'text', ): JSONSchema4 { - // a mutable Map to store custom top-level `interfaceName` types + // a mutable Map to store custom top-level `interfaceName` types. Fields with an `interfaceName` property will be moved to the top-level definitions here const interfaceNameDefinitions: Map = new Map() + + // Collections and Globals have to be moved to the top-level definitions as well. Reason: The top-level type will be the `Config` type - we don't want all collection and global + // types to be inlined inside the `Config` type const entityDefinitions: { [k: string]: JSONSchema4 } = [ ...config.globals, ...config.collections, @@ -537,6 +544,7 @@ export function configToJSONSchema( return { additionalProperties: false, definitions: { ...entityDefinitions, ...Object.fromEntries(interfaceNameDefinitions) }, + // These properties here will be very simple, as all the complexity is in the definitions. These are just the properties for the top-level `Config` type properties: { collections: generateEntitySchemas(config.collections || []), globals: generateEntitySchemas(config.globals || []), diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json index 0f9551c96b..861c14194c 100644 --- a/packages/richtext-lexical/package.json +++ b/packages/richtext-lexical/package.json @@ -32,6 +32,7 @@ "classnames": "^2.3.2", "deep-equal": "2.2.3", "i18next": "22.5.1", + "json-schema": "^0.4.0", "lexical": "0.12.6", "lodash": "4.17.21", "react": "18.2.0", @@ -42,6 +43,7 @@ }, "devDependencies": { "@payloadcms/eslint-config": "workspace:*", + "@types/json-schema": "7.0.12", "@types/node": "20.6.2", "@types/react": "18.2.15", "payload": "workspace:*" diff --git a/packages/richtext-lexical/src/field/features/Blocks/index.ts b/packages/richtext-lexical/src/field/features/Blocks/index.ts index 333b15fe7e..9e1892bfdb 100644 --- a/packages/richtext-lexical/src/field/features/Blocks/index.ts +++ b/packages/richtext-lexical/src/field/features/Blocks/index.ts @@ -1,7 +1,7 @@ -import type { Block } from 'payload/types' +import type { Block, BlockField } from 'payload/types' import { baseBlockFields } from 'payload/config' -import { formatLabels, getTranslation } from 'payload/utilities' +import { fieldsToJSONSchema, formatLabels, getTranslation } from 'payload/utilities' import type { FeatureProvider } from '../types' @@ -31,6 +31,20 @@ export const BlocksFeature = (props?: BlocksFeatureProps): FeatureProvider => { return { feature: () => { return { + generatedTypes: { + modifyOutputSchema: ({ currentSchema, field, interfaceNameDefinitions }) => { + const blocksField: BlockField = { + name: field?.name + '_lexical_blocks', + blocks: props.blocks, + type: 'blocks', + } + // This is only done so that interfaceNameDefinitions sets those block's interfaceNames. + // we don't actually use the JSON Schema itself in the generated types yet. + fieldsToJSONSchema({}, [blocksField], interfaceNameDefinitions) + + return currentSchema + }, + }, nodes: [ { node: BlockNode, diff --git a/packages/richtext-lexical/src/field/features/types.ts b/packages/richtext-lexical/src/field/features/types.ts index f34e5b6350..072c6225a1 100644 --- a/packages/richtext-lexical/src/field/features/types.ts +++ b/packages/richtext-lexical/src/field/features/types.ts @@ -1,4 +1,5 @@ import type { Transformer } from '@lexical/markdown' +import type { JSONSchema4 } from 'json-schema' import type { Klass, LexicalEditor, LexicalNode, SerializedEditorState } from 'lexical' import type { SerializedLexicalNode } from 'lexical' import type { LexicalNodeReplacement } from 'lexical' @@ -65,6 +66,25 @@ export type Feature = { floatingSelectToolbar?: { sections: FloatingToolbarSection[] } + generatedTypes?: { + modifyOutputSchema: ({ + currentSchema, + field, + interfaceNameDefinitions, + isRequired, + }: { + /** + * Current schema which will be modified by this function. + */ + currentSchema: JSONSchema4 + field: RichTextField + /** + * Allows you to define new top-level interfaces that can be re-used in the output schema. + */ + interfaceNameDefinitions: Map + isRequired: boolean + }) => JSONSchema4 + } hooks?: { afterReadPromise?: ({ field, @@ -200,6 +220,27 @@ export type SanitizedFeatures = Required< floatingSelectToolbar: { sections: FloatingToolbarSection[] } + generatedTypes: { + modifyOutputSchemas: Array< + ({ + currentSchema, + field, + interfaceNameDefinitions, + isRequired, + }: { + /** + * Current schema which will be modified by this function. + */ + currentSchema: JSONSchema4 + field: RichTextField + /** + * Allows you to define new top-level interfaces that can be re-used in the output schema. + */ + interfaceNameDefinitions: Map + isRequired: boolean + }) => JSONSchema4 + > + } hooks: { afterReadPromises: Array< ({ diff --git a/packages/richtext-lexical/src/field/lexical/config/sanitize.ts b/packages/richtext-lexical/src/field/lexical/config/sanitize.ts index 0034566b38..bbb8e66ada 100644 --- a/packages/richtext-lexical/src/field/lexical/config/sanitize.ts +++ b/packages/richtext-lexical/src/field/lexical/config/sanitize.ts @@ -12,6 +12,9 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature floatingSelectToolbar: { sections: [], }, + generatedTypes: { + modifyOutputSchemas: [], + }, hooks: { afterReadPromises: [], load: [], @@ -29,6 +32,9 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature } features.forEach((feature) => { + if (feature?.generatedTypes?.modifyOutputSchema) { + sanitized.generatedTypes.modifyOutputSchemas.push(feature.generatedTypes.modifyOutputSchema) + } if (feature.hooks) { if (feature.hooks.afterReadPromise) { sanitized.hooks.afterReadPromises = sanitized.hooks.afterReadPromises.concat( diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index 05d1bde9eb..d73a6aa3b1 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -1,3 +1,4 @@ +import type { JSONSchema4 } from 'json-schema' import type { SerializedEditorState } from 'lexical' import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor' import type { RichTextAdapter } from 'payload/types' @@ -98,8 +99,8 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte }) }, editorConfig: finalSanitizedEditorConfig, - outputSchema: ({ isRequired }) => { - return { + outputSchema: ({ field, interfaceNameDefinitions, isRequired }) => { + let outputSchema: JSONSchema4 = { // This schema matches the SerializedEditorState type so far, that it's possible to cast SerializedEditorState to this schema without any errors. // In the future, we should // 1) allow recursive children @@ -155,6 +156,17 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte required: ['root'], type: withNullableJSONSchemaType('object', isRequired), } + for (const modifyOutputSchema of finalSanitizedEditorConfig.features.generatedTypes + .modifyOutputSchemas) { + outputSchema = modifyOutputSchema({ + currentSchema: outputSchema, + field, + interfaceNameDefinitions, + isRequired, + }) + } + + return outputSchema }, populationPromise({ context, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50087bf14d..532349c96f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1387,6 +1387,9 @@ importers: i18next: specifier: 22.5.1 version: 22.5.1 + json-schema: + specifier: ^0.4.0 + version: 0.4.0 lexical: specifier: 0.12.6 version: 0.12.6 @@ -1412,6 +1415,9 @@ importers: '@payloadcms/eslint-config': specifier: workspace:* version: link:../eslint-config-payload + '@types/json-schema': + specifier: 7.0.12 + version: 7.0.12 '@types/node': specifier: 20.6.2 version: 20.6.2 @@ -12971,6 +12977,10 @@ packages: resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} dev: false + /json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + dev: false + /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: false diff --git a/test/fields/collections/Lexical/blocks.ts b/test/fields/collections/Lexical/blocks.ts index c4851d9c5b..663989dea0 100644 --- a/test/fields/collections/Lexical/blocks.ts +++ b/test/fields/collections/Lexical/blocks.ts @@ -73,6 +73,7 @@ export const TextBlock: Block = { } export const RadioButtonsBlock: Block = { + interfaceName: 'LexicalBlocksRadioButtonsBlock', fields: [ { name: 'radioButtons',