From 65c447bfe4ce34f0a2ff6d75746ac3a33273a878 Mon Sep 17 00:00:00 2001 From: Snorre Eskeland Brekke Date: Thu, 3 Oct 2024 10:34:25 +0200 Subject: [PATCH] feat(sanity): studio manifests cont (#7403) * feat(sanity): allow `extractSchema` worker to emit schemas for all workspaces * feat(sanity): include workspace and dataset names when extracting schema * feat(cli): add `manifest` commands * feat(manifest): add `@sanity/manifest` package * refactor(sanity): use manifest schemas from `@sanity/manifest` * chore: format files * feat(schema): include `title`, `description`, and `deprecated` attributes when extracting schema * feat(sanity): add `direct` schema format to schema extractor * Revert "feat(schema): include `title`, `description`, and `deprecated` attributes when extracting schema" This reverts commit 60cb576290e56bde8e688522f2d27ba1cd81e5e7. * feat(sanity): export `ConcreteRuleClass` class * feat(sanity): include validation rules in manifests * refactor(sanity): move manifest extraction code * feat(sanity): extract manifest during build * feat(sanity): adopt `.studioschema.json` filename suffix for manifest schemas * refactor(sanity): rename manifest extraction functions (remove plural) * fix(sanity): remove redundant success message * fix(sanity): stop build spinner before starting manifest extraction * feat(sanity): add `unstable_extractManifestOnBuild` CLI config option * feat(test-studio): enable `unstable_extractManifestOnBuild` * fix(sanity): switch to node crypto for node 18 compatibility * feat(cli): add `unstable_staticAssetsPath` CLI configuration option * chore(cli): refine `unstable_extractManifestOnBuild` CLI configuration option description * feat(sanity): remove extraneous `types` wrapper from manifests * debug(test-studio): remove Mux plugin to unblock typegen * feat(embedded-studio): enable manifest extraction * feat(starter-next-studio): enable manifest extraction * wip * feat(sanity): normalize type constraints in manifest validation * wip * chore: merge fix * feat: serialize userland properties and validation rules in manifest * fix: remove @sanity/manifest package * chore: cleanup * fix: serialize fieldsets * fix: omit default titles on fields and array-members * fix: ensure manifest schema is restoreble and supports cross dataset references * chore: mergefix * fix: serialization of type aliases no longer inlines fields and of props * fix: removes double dot in filename * feat: manifest command * chore: tweaks * chore: revert redundant changes * fix: adds manifest group to CLI * chore: wording change * fix: adds a 2-minute timeout to manifest extract * fix: ensures error code when mainfest extract fails and changes failed spinner message to info * chore: use *ENABLED instead of *DISABLED for constant * chore: defensive optional chaining for option extraction * chore: reworded EXTRACT_FAILURE_MESSAGE --------- Co-authored-by: Ash --- dev/embedded-studio/package.json | 2 +- dev/embedded-studio/sanity.cli.ts | 8 + dev/embedded-studio/sanity.config.ts | 34 + dev/embedded-studio/src/App.tsx | 42 +- dev/starter-next-studio/.gitignore | 3 + dev/starter-next-studio/components/Studio.tsx | 36 +- dev/starter-next-studio/package.json | 2 +- dev/starter-next-studio/sanity.cli.ts | 8 + dev/starter-next-studio/sanity.config.ts | 25 + .../@sanity/cli/src/util/noSuchCommandText.ts | 1 + packages/sanity/package.config.ts | 5 + .../cli/actions/build/buildAction.ts | 1 + .../cli/actions/deploy/deployAction.ts | 22 +- .../actions/manifest/extractManifestAction.ts | 182 ++++ .../src/_internal/cli/commands/index.ts | 4 + .../manifest/extractManifestCommand.ts | 35 + .../cli/commands/manifest/manifestGroup.ts | 6 + .../_internal/cli/threads/extractManifest.ts | 33 + .../manifest/extractWorkspaceManifest.ts | 502 +++++++++ .../_internal/manifest/manifestTypeHelpers.ts | 107 ++ .../src/_internal/manifest/manifestTypes.ts | 85 ++ packages/sanity/src/core/index.ts | 6 +- packages/sanity/src/core/validation/Rule.ts | 34 +- .../test/manifest/extractManifest.test.ts | 990 ++++++++++++++++++ .../manifest/extractManifestRestore.test.ts | 205 ++++ .../extractManifestValidation.test.ts | 515 +++++++++ 26 files changed, 2797 insertions(+), 96 deletions(-) create mode 100644 dev/embedded-studio/sanity.cli.ts create mode 100644 dev/embedded-studio/sanity.config.ts create mode 100644 dev/starter-next-studio/sanity.cli.ts create mode 100644 dev/starter-next-studio/sanity.config.ts create mode 100644 packages/sanity/src/_internal/cli/actions/manifest/extractManifestAction.ts create mode 100644 packages/sanity/src/_internal/cli/commands/manifest/extractManifestCommand.ts create mode 100644 packages/sanity/src/_internal/cli/commands/manifest/manifestGroup.ts create mode 100644 packages/sanity/src/_internal/cli/threads/extractManifest.ts create mode 100644 packages/sanity/src/_internal/manifest/extractWorkspaceManifest.ts create mode 100644 packages/sanity/src/_internal/manifest/manifestTypeHelpers.ts create mode 100644 packages/sanity/src/_internal/manifest/manifestTypes.ts create mode 100644 packages/sanity/test/manifest/extractManifest.test.ts create mode 100644 packages/sanity/test/manifest/extractManifestRestore.test.ts create mode 100644 packages/sanity/test/manifest/extractManifestValidation.test.ts diff --git a/dev/embedded-studio/package.json b/dev/embedded-studio/package.json index 1d245fd10e5..e60a14dff7c 100644 --- a/dev/embedded-studio/package.json +++ b/dev/embedded-studio/package.json @@ -3,7 +3,7 @@ "version": "3.59.0", "private": true, "scripts": { - "build": "tsc && vite build", + "build": "tsc && vite build && sanity manifest extract", "dev": "vite", "preview": "vite preview" }, diff --git a/dev/embedded-studio/sanity.cli.ts b/dev/embedded-studio/sanity.cli.ts new file mode 100644 index 00000000000..fac247bf8cf --- /dev/null +++ b/dev/embedded-studio/sanity.cli.ts @@ -0,0 +1,8 @@ +import {defineCliConfig} from 'sanity/cli' + +export default defineCliConfig({ + api: { + projectId: 'ppsg7ml5', + dataset: 'test', + }, +}) diff --git a/dev/embedded-studio/sanity.config.ts b/dev/embedded-studio/sanity.config.ts new file mode 100644 index 00000000000..c49026536a2 --- /dev/null +++ b/dev/embedded-studio/sanity.config.ts @@ -0,0 +1,34 @@ +import {defineConfig, defineType} from 'sanity' +import {structureTool} from 'sanity/structure' + +const BLOG_POST_SCHEMA = defineType({ + type: 'document', + name: 'blogPost', + title: 'Blog post', + fields: [ + { + type: 'string', + name: 'title', + title: 'Title', + }, + ], +}) + +export const SCHEMA_TYPES = [BLOG_POST_SCHEMA] + +export default defineConfig({ + projectId: 'ppsg7ml5', + dataset: 'test', + + document: { + unstable_comments: { + enabled: true, + }, + }, + + schema: { + types: SCHEMA_TYPES, + }, + + plugins: [structureTool()], +}) diff --git a/dev/embedded-studio/src/App.tsx b/dev/embedded-studio/src/App.tsx index d07913d0206..7ee792a216b 100644 --- a/dev/embedded-studio/src/App.tsx +++ b/dev/embedded-studio/src/App.tsx @@ -1,46 +1,8 @@ import {Button, Card, Flex, studioTheme, ThemeProvider, usePrefersDark} from '@sanity/ui' import {useCallback, useMemo, useState} from 'react' -import { - defineConfig, - defineType, - Studio, - StudioLayout, - StudioProvider, - type StudioThemeColorSchemeKey, -} from 'sanity' -import {structureTool} from 'sanity/structure' +import {Studio, StudioLayout, StudioProvider, type StudioThemeColorSchemeKey} from 'sanity' -const BLOG_POST_SCHEMA = defineType({ - type: 'document', - name: 'blogPost', - title: 'Blog post', - fields: [ - { - type: 'string', - name: 'title', - title: 'Title', - }, - ], -}) - -const SCHEMA_TYPES = [BLOG_POST_SCHEMA] - -const config = defineConfig({ - projectId: 'ppsg7ml5', - dataset: 'test', - - document: { - unstable_comments: { - enabled: true, - }, - }, - - schema: { - types: SCHEMA_TYPES, - }, - - plugins: [structureTool()], -}) +import config from '../sanity.config' export function App() { const prefersDark = usePrefersDark() diff --git a/dev/starter-next-studio/.gitignore b/dev/starter-next-studio/.gitignore index a680367ef56..f0f5197150f 100644 --- a/dev/starter-next-studio/.gitignore +++ b/dev/starter-next-studio/.gitignore @@ -1 +1,4 @@ .next + +public/static/*.create-schema.json +public/static/create-manifest.json diff --git a/dev/starter-next-studio/components/Studio.tsx b/dev/starter-next-studio/components/Studio.tsx index 00557ec43c7..11aa0e3ce65 100644 --- a/dev/starter-next-studio/components/Studio.tsx +++ b/dev/starter-next-studio/components/Studio.tsx @@ -1,41 +1,13 @@ -import {useMemo} from 'react' -import {defineConfig, Studio} from 'sanity' -import {structureTool} from 'sanity/structure' +import {Studio} from 'sanity' + +import config from '../sanity.config' const wrapperStyles = {height: '100vh', width: '100vw'} export default function StudioRoot({basePath}: {basePath: string}) { - const config = useMemo( - () => - defineConfig({ - basePath, - plugins: [structureTool()], - title: 'Next.js Starter', - projectId: 'ppsg7ml5', - dataset: 'test', - schema: { - types: [ - { - type: 'document', - name: 'post', - title: 'Post', - fields: [ - { - type: 'string', - name: 'title', - title: 'Title', - }, - ], - }, - ], - }, - }), - [basePath], - ) - return (
- +
) } diff --git a/dev/starter-next-studio/package.json b/dev/starter-next-studio/package.json index 6db5bcb1008..c92faad7acf 100644 --- a/dev/starter-next-studio/package.json +++ b/dev/starter-next-studio/package.json @@ -5,7 +5,7 @@ "license": "MIT", "author": "Sanity.io ", "scripts": { - "build": "next build", + "build": "sanity manifest extract --path public/static && next build", "dev": "next dev", "start": "next start" }, diff --git a/dev/starter-next-studio/sanity.cli.ts b/dev/starter-next-studio/sanity.cli.ts new file mode 100644 index 00000000000..fac247bf8cf --- /dev/null +++ b/dev/starter-next-studio/sanity.cli.ts @@ -0,0 +1,8 @@ +import {defineCliConfig} from 'sanity/cli' + +export default defineCliConfig({ + api: { + projectId: 'ppsg7ml5', + dataset: 'test', + }, +}) diff --git a/dev/starter-next-studio/sanity.config.ts b/dev/starter-next-studio/sanity.config.ts new file mode 100644 index 00000000000..102cbb15f94 --- /dev/null +++ b/dev/starter-next-studio/sanity.config.ts @@ -0,0 +1,25 @@ +import {defineConfig} from 'sanity' +import {structureTool} from 'sanity/structure' + +export default defineConfig({ + plugins: [structureTool()], + title: 'Next.js Starter', + projectId: 'ppsg7ml5', + dataset: 'test', + schema: { + types: [ + { + type: 'document', + name: 'post', + title: 'Post', + fields: [ + { + type: 'string', + name: 'title', + title: 'Title', + }, + ], + }, + ], + }, +}) diff --git a/packages/@sanity/cli/src/util/noSuchCommandText.ts b/packages/@sanity/cli/src/util/noSuchCommandText.ts index 07b94d6b1ce..3c35f5dd65d 100644 --- a/packages/@sanity/cli/src/util/noSuchCommandText.ts +++ b/packages/@sanity/cli/src/util/noSuchCommandText.ts @@ -18,6 +18,7 @@ const coreCommands = [ 'graphql', 'hook', 'migration', + 'manifest', 'preview', 'schema', 'start', diff --git a/packages/sanity/package.config.ts b/packages/sanity/package.config.ts index 08aa9c96444..a2e7757e57a 100644 --- a/packages/sanity/package.config.ts +++ b/packages/sanity/package.config.ts @@ -41,6 +41,11 @@ export default defineConfig({ require: './lib/_internal/cli/threads/extractSchema.js', runtime: 'node', }, + { + source: './src/_internal/cli/threads/extractManifest.ts', + require: './lib/_internal/cli/threads/extractManifest.js', + runtime: 'node', + }, ], extract: { diff --git a/packages/sanity/src/_internal/cli/actions/build/buildAction.ts b/packages/sanity/src/_internal/cli/actions/build/buildAction.ts index 62b9b8fe8d2..24cb65957bc 100644 --- a/packages/sanity/src/_internal/cli/actions/build/buildAction.ts +++ b/packages/sanity/src/_internal/cli/actions/build/buildAction.ts @@ -187,6 +187,7 @@ export default async function buildSanityStudio( spin.text = `Build Sanity Studio (${buildDuration.toFixed()}ms)` spin.succeed() + trace.complete() if (flags.stats) { output.print('\nLargest module files:') diff --git a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts index 364aec55735..042fb803190 100644 --- a/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts +++ b/packages/sanity/src/_internal/cli/actions/deploy/deployAction.ts @@ -7,6 +7,7 @@ import tar from 'tar-fs' import {shouldAutoUpdate} from '../../util/shouldAutoUpdate' import buildSanityStudio, {type BuildSanityStudioCommandFlags} from '../build/buildAction' +import {extractManifestSafe} from '../manifest/extractManifestAction' import { checkDir, createDeployment, @@ -101,16 +102,25 @@ export default async function deployStudioAction( // Always build the project, unless --no-build is passed const shouldBuild = flags.build if (shouldBuild) { - const buildArgs = [customSourceDir].filter(Boolean) - const {didCompile} = await buildSanityStudio( - {...args, extOptions: flags, argsWithoutOptions: buildArgs}, - context, - {basePath: '/'}, - ) + const buildArgs = { + ...args, + extOptions: flags, + argsWithoutOptions: [customSourceDir].filter(Boolean), + } + const {didCompile} = await buildSanityStudio(buildArgs, context, {basePath: '/'}) if (!didCompile) { return } + + await extractManifestSafe( + { + ...buildArgs, + extOptions: {}, + extraArguments: [], + }, + context, + ) } // Ensure that the directory exists, is a directory and seems to have valid content diff --git a/packages/sanity/src/_internal/cli/actions/manifest/extractManifestAction.ts b/packages/sanity/src/_internal/cli/actions/manifest/extractManifestAction.ts new file mode 100644 index 00000000000..509110cf775 --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/manifest/extractManifestAction.ts @@ -0,0 +1,182 @@ +import {createHash} from 'node:crypto' +import {mkdir, writeFile} from 'node:fs/promises' +import {dirname, join, resolve} from 'node:path' +import {Worker} from 'node:worker_threads' + +import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli' +import {minutesToMilliseconds} from 'date-fns' +import readPkgUp from 'read-pkg-up' + +import { + type CreateManifest, + type CreateWorkspaceManifest, + type ManifestWorkspaceFile, +} from '../../../manifest/manifestTypes' +import {type ExtractManifestWorkerData} from '../../threads/extractManifest' +import {getTimer} from '../../util/timing' + +const MANIFEST_FILENAME = 'create-manifest.json' +const SCHEMA_FILENAME_SUFFIX = '.create-schema.json' + +/** Escape-hatch env flags to change action behavior */ +const FEATURE_ENABLED_ENV_NAME = 'SANITY_CLI_EXTRACT_MANIFEST_ENABLED' +const EXTRACT_MANIFEST_ENABLED = process.env[FEATURE_ENABLED_ENV_NAME] !== 'false' +const EXTRACT_MANIFEST_LOG_ERRORS = process.env.SANITY_CLI_EXTRACT_MANIFEST_LOG_ERRORS === 'true' + +const CREATE_TIMER = 'create-manifest' + +const EXTRACT_TASK_TIMEOUT_MS = minutesToMilliseconds(2) + +const EXTRACT_FAILURE_MESSAGE = + "Couldn't extract manifest file. Sanity Create will not be available for the studio.\n" + + `Disable this message with ${FEATURE_ENABLED_ENV_NAME}=false` + +interface ExtractFlags { + path?: string +} + +/** + * This function will never throw. + * @returns `undefined` if extract succeeded - caught error if it failed + */ +export async function extractManifestSafe( + args: CliCommandArguments, + context: CliCommandContext, +): Promise { + if (!EXTRACT_MANIFEST_ENABLED) { + return undefined + } + + try { + await extractManifest(args, context) + return undefined + } catch (err) { + if (EXTRACT_MANIFEST_LOG_ERRORS) { + context.output.error(err) + } + return err + } +} + +async function extractManifest( + args: CliCommandArguments, + context: CliCommandContext, +): Promise { + const {output, workDir} = context + + const flags = args.extOptions + const defaultOutputDir = resolve(join(workDir, 'dist')) + + const outputDir = resolve(defaultOutputDir) + const defaultStaticPath = join(outputDir, 'static') + + const staticPath = flags.path ?? defaultStaticPath + + const path = join(staticPath, MANIFEST_FILENAME) + + const rootPkgPath = readPkgUp.sync({cwd: __dirname})?.path + if (!rootPkgPath) { + throw new Error('Could not find root directory for `sanity` package') + } + + const timer = getTimer() + timer.start(CREATE_TIMER) + const spinner = output.spinner({}).start('Extracting manifest') + + try { + const workspaceManifests = await getWorkspaceManifests({rootPkgPath, workDir}) + await mkdir(staticPath, {recursive: true}) + + const workspaceFiles = await writeWorkspaceFiles(workspaceManifests, staticPath) + + const manifest: CreateManifest = { + version: 1, + createdAt: new Date().toISOString(), + workspaces: workspaceFiles, + } + + await writeFile(path, JSON.stringify(manifest, null, 2)) + const manifestDuration = timer.end(CREATE_TIMER) + + spinner.succeed(`Extracted manifest (${manifestDuration.toFixed()}ms)`) + } catch (err) { + spinner.info(EXTRACT_FAILURE_MESSAGE) + throw err + } +} + +async function getWorkspaceManifests({ + rootPkgPath, + workDir, +}: { + rootPkgPath: string + workDir: string +}): Promise { + const workerPath = join( + dirname(rootPkgPath), + 'lib', + '_internal', + 'cli', + 'threads', + 'extractManifest.js', + ) + + const worker = new Worker(workerPath, { + workerData: {workDir} satisfies ExtractManifestWorkerData, + // eslint-disable-next-line no-process-env + env: process.env, + }) + + let timeout = false + const timeoutId = setTimeout(() => { + timeout = true + worker.terminate() + }, EXTRACT_TASK_TIMEOUT_MS) + + try { + return await new Promise((resolveWorkspaces, reject) => { + const buffer: CreateWorkspaceManifest[] = [] + worker.addListener('message', (message) => buffer.push(message)) + worker.addListener('exit', (exitCode) => { + if (exitCode === 0) { + resolveWorkspaces(buffer) + } else if (timeout) { + reject(new Error(`Extract manifest was aborted after ${EXTRACT_TASK_TIMEOUT_MS}ms`)) + } + }) + worker.addListener('error', reject) + }) + } finally { + clearTimeout(timeoutId) + } +} + +function writeWorkspaceFiles( + manifestWorkspaces: CreateWorkspaceManifest[], + staticPath: string, +): Promise { + const output = manifestWorkspaces.reduce[]>( + (workspaces, workspace) => { + return [...workspaces, writeWorkspaceSchemaFile(workspace, staticPath)] + }, + [], + ) + return Promise.all(output) +} + +async function writeWorkspaceSchemaFile( + workspace: CreateWorkspaceManifest, + staticPath: string, +): Promise { + const schemaString = JSON.stringify(workspace.schema, null, 2) + const hash = createHash('sha1').update(schemaString).digest('hex') + const filename = `${hash.slice(0, 8)}${SCHEMA_FILENAME_SUFFIX}` + + // workspaces with identical schemas will overwrite each others schema file. This is ok, since they are identical and can be shared + await writeFile(join(staticPath, filename), schemaString) + + return { + ...workspace, + schema: filename, + } +} diff --git a/packages/sanity/src/_internal/cli/commands/index.ts b/packages/sanity/src/_internal/cli/commands/index.ts index e27daee4cce..1a6801e8a82 100644 --- a/packages/sanity/src/_internal/cli/commands/index.ts +++ b/packages/sanity/src/_internal/cli/commands/index.ts @@ -41,6 +41,8 @@ import hookGroup from './hook/hookGroup' import listHookLogsCommand from './hook/listHookLogsCommand' import listHooksCommand from './hook/listHooksCommand' import printHookAttemptCommand from './hook/printHookAttemptCommand' +import extractManifestCommand from './manifest/extractManifestCommand' +import manifestGroup from './manifest/manifestGroup' import createMigrationCommand from './migration/createMigrationCommand' import listMigrationsCommand from './migration/listMigrationsCommand' import migrationGroup from './migration/migrationGroup' @@ -110,6 +112,8 @@ const commands: (CliCommandDefinition | CliCommandGroupDefinition)[] = [ previewCommand, uninstallCommand, execCommand, + manifestGroup, + extractManifestCommand, ] /** diff --git a/packages/sanity/src/_internal/cli/commands/manifest/extractManifestCommand.ts b/packages/sanity/src/_internal/cli/commands/manifest/extractManifestCommand.ts new file mode 100644 index 00000000000..7422524e2a4 --- /dev/null +++ b/packages/sanity/src/_internal/cli/commands/manifest/extractManifestCommand.ts @@ -0,0 +1,35 @@ +import {type CliCommandDefinition} from '@sanity/cli' + +const description = 'Extracts the studio configuration as one or more JSON manifest files.' + +const helpText = ` +**Note**: This command is experimental and subject to change. It is currently intended for use with Create only. + +Options + --path Optional path to specify destination directory of the manifest files. Default: /dist/static + +Examples + # Extracts manifests + sanity manifest extract + + # Extracts manifests into /public/static + sanity manifest extract --path /public/static +` + +const extractManifestCommand: CliCommandDefinition = { + name: 'extract', + group: 'manifest', + signature: '', + description, + helpText, + action: async (args, context) => { + const {extractManifestSafe} = await import('../../actions/manifest/extractManifestAction') + const extractError = await extractManifestSafe(args, context) + if (extractError) { + throw extractError + } + return extractError + }, +} + +export default extractManifestCommand diff --git a/packages/sanity/src/_internal/cli/commands/manifest/manifestGroup.ts b/packages/sanity/src/_internal/cli/commands/manifest/manifestGroup.ts new file mode 100644 index 00000000000..ba086d91672 --- /dev/null +++ b/packages/sanity/src/_internal/cli/commands/manifest/manifestGroup.ts @@ -0,0 +1,6 @@ +export default { + name: 'manifest', + signature: '[COMMAND]', + isGroupRoot: true, + description: 'Interacts with the studio configuration.', +} diff --git a/packages/sanity/src/_internal/cli/threads/extractManifest.ts b/packages/sanity/src/_internal/cli/threads/extractManifest.ts new file mode 100644 index 00000000000..e3080dce09f --- /dev/null +++ b/packages/sanity/src/_internal/cli/threads/extractManifest.ts @@ -0,0 +1,33 @@ +import {isMainThread, parentPort, workerData as _workerData} from 'node:worker_threads' + +import {extractCreateWorkspaceManifest} from '../../manifest/extractWorkspaceManifest' +import {getStudioWorkspaces} from '../util/getStudioWorkspaces' +import {mockBrowserEnvironment} from '../util/mockBrowserEnvironment' + +/** @internal */ +export interface ExtractManifestWorkerData { + workDir: string +} + +if (isMainThread || !parentPort) { + throw new Error('This module must be run as a worker thread') +} + +const opts = _workerData as ExtractManifestWorkerData + +const cleanup = mockBrowserEnvironment(opts.workDir) + +async function main() { + try { + const workspaces = await getStudioWorkspaces({basePath: opts.workDir}) + + for (const workspace of workspaces) { + parentPort?.postMessage(extractCreateWorkspaceManifest(workspace)) + } + } finally { + parentPort?.close() + cleanup() + } +} + +main() diff --git a/packages/sanity/src/_internal/manifest/extractWorkspaceManifest.ts b/packages/sanity/src/_internal/manifest/extractWorkspaceManifest.ts new file mode 100644 index 00000000000..427d4b83a7b --- /dev/null +++ b/packages/sanity/src/_internal/manifest/extractWorkspaceManifest.ts @@ -0,0 +1,502 @@ +import startCase from 'lodash/startCase' +import { + type ArraySchemaType, + type BlockDefinition, + type BooleanSchemaType, + ConcreteRuleClass, + createSchema, + type CrossDatasetReferenceSchemaType, + type FileSchemaType, + type MultiFieldSet, + type NumberSchemaType, + type ObjectField, + type ObjectSchemaType, + type ReferenceSchemaType, + type Rule, + type RuleSpec, + type Schema, + type SchemaType, + type SchemaValidationValue, + type SpanSchemaType, + type StringSchemaType, + type Workspace, +} from 'sanity' + +import { + getCustomFields, + isCrossDatasetReference, + isCustomized, + isDefined, + isPrimitive, + isRecord, + isReference, + isString, + isType, +} from './manifestTypeHelpers' +import { + type CreateWorkspaceManifest, + type ManifestField, + type ManifestFieldset, + type ManifestSchemaType, + type ManifestSerializable, + type ManifestTitledValue, + type ManifestValidationGroup, + type ManifestValidationRule, +} from './manifestTypes' + +interface Context { + schema: Schema +} + +type SchemaTypeKey = + | keyof ArraySchemaType + | keyof BooleanSchemaType + | keyof FileSchemaType + | keyof NumberSchemaType + | keyof ObjectSchemaType + | keyof StringSchemaType + | keyof ReferenceSchemaType + | keyof BlockDefinition + | 'group' // we strip this from fields + +type Validation = {validation: ManifestValidationGroup[]} | Record +type ObjectFields = {fields: ManifestField[]} | Record +type SerializableProp = ManifestSerializable | ManifestSerializable[] | undefined +type ManifestValidationFlag = ManifestValidationRule['flag'] +type ValidationRuleTransformer = (rule: RuleSpec) => ManifestValidationRule | undefined + +const MAX_CUSTOM_PROPERTY_DEPTH = 5 +const INLINE_TYPES = ['document', 'object', 'image', 'file'] + +export function extractCreateWorkspaceManifest(workspace: Workspace): CreateWorkspaceManifest { + const serializedSchema = extractManifestSchemaTypes(workspace.schema) + + return { + name: workspace.name, + title: workspace.title, + subtitle: workspace.subtitle, + basePath: workspace.basePath, + dataset: workspace.dataset, + schema: serializedSchema, + } +} + +/** + * Extracts all serializable properties from userland schema types, + * so they best-effort can be used as definitions for Schema.compile +. */ +export function extractManifestSchemaTypes(schema: Schema): ManifestSchemaType[] { + const typeNames = schema.getTypeNames() + const context = {schema} + + const studioDefaultTypeNames = createSchema({name: 'default', types: []}).getTypeNames() + + return typeNames + .filter((typeName) => !studioDefaultTypeNames.includes(typeName)) + .map((typeName) => schema.get(typeName)) + .filter((type): type is SchemaType => typeof type !== 'undefined') + .map((type) => transformType(type, context)) +} + +function transformCommonTypeFields( + type: SchemaType & {fieldset?: string}, + typeName: string, + context: Context, +): Omit { + const arrayProps = + typeName === 'array' && type.jsonType === 'array' ? transformArrayMember(type, context) : {} + + const referenceProps = isReference(type) ? transformReference(type) : {} + const crossDatasetRefProps = isCrossDatasetReference(type) + ? transformCrossDatasetReference(type) + : {} + + const objectFields: ObjectFields = + type.jsonType === 'object' && type.type && INLINE_TYPES.includes(typeName) && isCustomized(type) + ? { + fields: getCustomFields(type).map((objectField) => transformField(objectField, context)), + } + : {} + + return { + ...retainCustomTypeProps(type), + ...transformValidation(type.validation), + ...ensureString('description', type.description), + ...objectFields, + ...arrayProps, + ...referenceProps, + ...crossDatasetRefProps, + ...ensureConditional('readOnly', type.readOnly), + ...ensureConditional('hidden', type.hidden), + ...transformFieldsets(type), + // fieldset prop gets instrumented via getCustomFields + ...ensureString('fieldset', type.fieldset), + ...transformBlockType(type, context), + } +} + +function transformFieldsets( + type: SchemaType, +): {fieldsets: ManifestFieldset[]} | Record { + if (type.jsonType !== 'object') { + return {} + } + const fieldsets = type.fieldsets + ?.filter((fs): fs is MultiFieldSet => !fs.single) + .map((fs) => { + const options = isRecord(fs.options) ? {options: retainSerializableProps(fs.options)} : {} + return { + name: fs.name, + ...ensureCustomTitle(fs.name, fs.title), + ...ensureString('description', fs.description), + ...ensureConditional('readOnly', fs.readOnly), + ...ensureConditional('hidden', fs.hidden), + ...options, + } + }) + + return fieldsets?.length ? {fieldsets} : {} +} + +function transformType(type: SchemaType, context: Context): ManifestSchemaType { + const typeName = type.type ? type.type.name : type.jsonType + + return { + ...transformCommonTypeFields(type, typeName, context), + name: type.name, + type: typeName, + ...ensureCustomTitle(type.name, type.title), + } +} + +function retainCustomTypeProps(type: SchemaType): Record { + const manuallySerializedFields: SchemaTypeKey[] = [ + //explicitly added + 'name', + 'title', + 'description', + 'readOnly', + 'hidden', + 'validation', + 'fieldsets', + 'fields', + 'to', + 'of', + // not serialized + 'type', + 'jsonType', + '__experimental_actions', + '__experimental_formPreviewTitle', + '__experimental_omnisearch_visibility', + '__experimental_search', + 'components', + 'icon', + 'orderings', + 'preview', + 'groups', + //only exists on fields + 'group', + // we know about these, but let them be generically handled + // deprecated + // rows (from text) + // initialValue + // options + // crossDatasetReference props + ] + const typeWithoutManuallyHandledFields = Object.fromEntries( + Object.entries(type).filter( + ([key]) => !manuallySerializedFields.includes(key as unknown as SchemaTypeKey), + ), + ) + return retainSerializableProps(typeWithoutManuallyHandledFields) as Record< + string, + SerializableProp + > +} + +function retainSerializableProps(maybeSerializable: unknown, depth = 0): SerializableProp { + if (depth > MAX_CUSTOM_PROPERTY_DEPTH) { + return undefined + } + + if (!isDefined(maybeSerializable)) { + return undefined + } + + if (isPrimitive(maybeSerializable)) { + // cull empty strings + if (maybeSerializable === '') { + return undefined + } + return maybeSerializable + } + + // url-schemes ect.. + if (maybeSerializable instanceof RegExp) { + return maybeSerializable.toString() + } + + if (Array.isArray(maybeSerializable)) { + const arrayItems = maybeSerializable + .map((item) => retainSerializableProps(item, depth + 1)) + .filter((item): item is ManifestSerializable => isDefined(item)) + return arrayItems.length ? arrayItems : undefined + } + + if (isRecord(maybeSerializable)) { + const serializableEntries = Object.entries(maybeSerializable) + .map(([key, value]) => { + return [key, retainSerializableProps(value, depth + 1)] + }) + .filter(([, value]) => isDefined(value)) + return serializableEntries.length ? Object.fromEntries(serializableEntries) : undefined + } + + return undefined +} + +function transformField(field: ObjectField & {fieldset?: string}, context: Context): ManifestField { + const fieldType = field.type + const typeNameExists = !!context.schema.get(fieldType.name) + const typeName = typeNameExists ? fieldType.name : (fieldType.type?.name ?? fieldType.name) + return { + ...transformCommonTypeFields(fieldType, typeName, context), + name: field.name, + type: typeName, + ...ensureCustomTitle(field.name, fieldType.title), + // this prop gets added synthetically via getCustomFields + ...ensureString('fieldset', field.fieldset), + } +} + +function transformArrayMember( + arrayMember: ArraySchemaType, + context: Context, +): Pick { + return { + of: arrayMember.of.map((type) => { + const typeNameExists = !!context.schema.get(type.name) + const typeName = typeNameExists ? type.name : (type.type?.name ?? type.name) + return { + ...transformCommonTypeFields(type, typeName, context), + type: typeName, + ...(typeName === type.name ? {} : {name: type.name}), + ...ensureCustomTitle(type.name, type.title), + } + }), + } +} + +function transformReference(reference: ReferenceSchemaType): Pick { + return { + to: (reference.to ?? []).map((type) => { + return { + ...retainCustomTypeProps(type), + type: type.name, + } + }), + } +} + +function transformCrossDatasetReference( + reference: CrossDatasetReferenceSchemaType, +): Pick { + return { + to: (reference.to ?? []).map((crossDataset) => { + const preview = crossDataset.preview?.select + ? {preview: {select: crossDataset.preview.select}} + : {} + return { + type: crossDataset.type, + ...ensureCustomTitle(crossDataset.type, crossDataset.title), + ...preview, + } + }), + } +} + +const transformTypeValidationRule: ValidationRuleTransformer = (rule) => { + return { + ...rule, + constraint: + 'constraint' in rule && + (typeof rule.constraint === 'string' + ? rule.constraint.toLowerCase() + : retainSerializableProps(rule.constraint)), + } +} + +const validationRuleTransformers: Partial< + Record +> = { + type: transformTypeValidationRule, +} + +function transformValidation(validation: SchemaValidationValue): Validation { + const validationArray = (Array.isArray(validation) ? validation : [validation]).filter( + (value): value is Rule => typeof value === 'object' && '_type' in value, + ) + + // we dont want type in the output as that is implicitly given by the typedef itself an will only bloat the payload + const disallowedFlags = ['type'] + + // Validation rules that refer to other fields use symbols, which cannot be serialized. It would + // be possible to transform these to a serializable type, but we haven't implemented that for now. + const disallowedConstraintTypes: (symbol | unknown)[] = [ConcreteRuleClass.FIELD_REF] + + const serializedValidation = validationArray + .map(({_rules, _message, _level}) => { + const message: Partial> = + typeof _message === 'string' ? {message: _message} : {} + + const serializedRules = _rules + .filter((rule) => { + if (!('constraint' in rule)) { + return false + } + + const {flag, constraint} = rule + + if (disallowedFlags.includes(flag)) { + return false + } + + return !( + typeof constraint === 'object' && + 'type' in constraint && + disallowedConstraintTypes.includes(constraint.type) + ) + }) + .reduce((rules, rule) => { + const transformer: ValidationRuleTransformer = + validationRuleTransformers[rule.flag] ?? + ((spec) => retainSerializableProps(spec) as ManifestValidationRule) + + const transformedRule = transformer(rule) + if (!transformedRule) { + return rules + } + return [...rules, transformedRule] + }, []) + + return { + rules: serializedRules, + level: _level, + ...message, + } + }) + .filter((group) => !!group.rules.length) + + return serializedValidation.length ? {validation: serializedValidation} : {} +} + +function ensureCustomTitle(typeName: string, value: unknown) { + const titleObject = ensureString('title', value) + + const defaultTitle = startCase(typeName) + // omit title if its the same as default, to reduce payload + if (titleObject.title === defaultTitle) { + return {} + } + return titleObject +} + +function ensureString(key: Key, value: unknown) { + if (typeof value === 'string') { + return { + [key]: value, + } + } + + return {} +} + +function ensureConditional(key: Key, value: unknown) { + if (typeof value === 'boolean') { + return { + [key]: value, + } + } + + if (typeof value === 'function') { + return { + [key]: 'conditional', + } + } + + return {} +} + +export function transformBlockType( + blockType: SchemaType, + context: Context, +): Pick | Record { + if (blockType.jsonType !== 'object' || !isType(blockType, 'block')) { + return {} + } + + const childrenField = blockType.fields?.find((field) => field.name === 'children') as + | {type: ArraySchemaType} + | undefined + + if (!childrenField) { + return {} + } + const ofType = childrenField.type.of + if (!ofType) { + return {} + } + const spanType = ofType.find((memberType) => memberType.name === 'span') as + | ObjectSchemaType + | undefined + if (!spanType) { + return {} + } + const inlineObjectTypes = (ofType.filter((memberType) => memberType.name !== 'span') || + []) as ObjectSchemaType[] + + return { + marks: { + annotations: (spanType as SpanSchemaType).annotations.map((t) => transformType(t, context)), + decorators: resolveEnabledDecorators(spanType), + }, + lists: resolveEnabledListItems(blockType), + styles: resolveEnabledStyles(blockType), + of: inlineObjectTypes.map((t) => transformType(t, context)), + } +} + +function resolveEnabledStyles(blockType: ObjectSchemaType): ManifestTitledValue[] | undefined { + const styleField = blockType.fields?.find((btField) => btField.name === 'style') + return resolveTitleValueArray(styleField?.type?.options?.list) +} + +function resolveEnabledDecorators(spanType: ObjectSchemaType): ManifestTitledValue[] | undefined { + return 'decorators' in spanType ? resolveTitleValueArray(spanType.decorators) : undefined +} + +function resolveEnabledListItems(blockType: ObjectSchemaType): ManifestTitledValue[] | undefined { + const listField = blockType.fields?.find((btField) => btField.name === 'listItem') + return resolveTitleValueArray(listField?.type?.options?.list) +} + +function resolveTitleValueArray(possibleArray: unknown): ManifestTitledValue[] | undefined { + if (!possibleArray || !Array.isArray(possibleArray)) { + return undefined + } + const titledValues = possibleArray + .filter( + (d): d is {value: string; title?: string} => isRecord(d) && !!d.value && isString(d.value), + ) + .map((item) => { + return { + value: item.value, + ...ensureString('title', item.title), + } satisfies ManifestTitledValue + }) + if (!titledValues?.length) { + return undefined + } + + return titledValues +} diff --git a/packages/sanity/src/_internal/manifest/manifestTypeHelpers.ts b/packages/sanity/src/_internal/manifest/manifestTypeHelpers.ts new file mode 100644 index 00000000000..e9b366e978a --- /dev/null +++ b/packages/sanity/src/_internal/manifest/manifestTypeHelpers.ts @@ -0,0 +1,107 @@ +import { + type CrossDatasetReferenceSchemaType, + type ObjectField, + type ObjectSchemaType, + type ReferenceSchemaType, + type SchemaType, +} from '@sanity/types' + +const DEFAULT_IMAGE_FIELDS = ['asset', 'hotspot', 'crop'] +const DEFAULT_FILE_FIELDS = ['asset'] +const DEFAULT_GEOPOINT_FIELDS = ['lat', 'lng', 'alt'] +const DEFAULT_SLUG_FIELDS = ['current', 'source'] + +export function getCustomFields(type: ObjectSchemaType): (ObjectField & {fieldset?: string})[] { + const fields = type.fieldsets + ? type.fieldsets.flatMap((fs) => { + if (fs.single) { + return fs.field + } + return fs.fields.map((field) => ({ + ...field, + fieldset: fs.name, + })) + }) + : type.fields + + if (isType(type, 'block')) { + return [] + } + if (isType(type, 'slug')) { + return fields.filter((f) => !DEFAULT_SLUG_FIELDS.includes(f.name)) + } + if (isType(type, 'geopoint')) { + return fields.filter((f) => !DEFAULT_GEOPOINT_FIELDS.includes(f.name)) + } + if (isType(type, 'image')) { + return fields.filter((f) => !DEFAULT_IMAGE_FIELDS.includes(f.name)) + } + if (isType(type, 'file')) { + return fields.filter((f) => !DEFAULT_FILE_FIELDS.includes(f.name)) + } + return fields +} + +export function isReference(type: SchemaType): type is ReferenceSchemaType { + return isType(type, 'reference') +} + +export function isCrossDatasetReference(type: SchemaType): type is CrossDatasetReferenceSchemaType { + return isType(type, 'crossDatasetReference') +} + +export function isObjectField(maybeOjectField: unknown): boolean { + return ( + typeof maybeOjectField === 'object' && maybeOjectField !== null && 'name' in maybeOjectField + ) +} + +export function isCustomized(maybeCustomized: SchemaType): boolean { + const hasFieldsArray = + isObjectField(maybeCustomized) && + !isType(maybeCustomized, 'reference') && + !isType(maybeCustomized, 'crossDatasetReference') && + 'fields' in maybeCustomized && + Array.isArray(maybeCustomized.fields) + + if (!hasFieldsArray) { + return false + } + + const fields = getCustomFields(maybeCustomized) + return !!fields.length +} + +export function isType(schemaType: SchemaType, typeName: string): boolean { + if (schemaType.name === typeName) { + return true + } + if (!schemaType.type) { + return false + } + return isType(schemaType.type, typeName) +} + +export function isDefined(value: T | null | undefined): value is T { + return value !== null && value !== undefined +} + +export function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' +} + +export function isPrimitive(value: unknown): value is string | boolean | number { + return isString(value) || isBoolean(value) || isNumber(value) +} + +export function isString(value: unknown): value is string { + return typeof value === 'string' +} + +function isNumber(value: unknown): value is number { + return typeof value === 'boolean' +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === 'number' +} diff --git a/packages/sanity/src/_internal/manifest/manifestTypes.ts b/packages/sanity/src/_internal/manifest/manifestTypes.ts new file mode 100644 index 00000000000..7ce29c9ba7e --- /dev/null +++ b/packages/sanity/src/_internal/manifest/manifestTypes.ts @@ -0,0 +1,85 @@ +export type ManifestSerializable = + | string + | number + | boolean + | {[k: string]: ManifestSerializable} + | ManifestSerializable[] + +export interface CreateManifest { + version: 1 + createdAt: string + workspaces: ManifestWorkspaceFile[] +} + +export interface ManifestWorkspaceFile { + name: string + dataset: string + schema: string // filename +} + +export interface CreateWorkspaceManifest { + name: string + title?: string + subtitle?: string + basePath: string + dataset: string + schema: ManifestSchemaType[] +} + +export interface ManifestSchemaType { + type: string + name: string + title?: string + deprecated?: { + reason: string + } + readOnly?: boolean | 'conditional' + hidden?: boolean | 'conditional' + validation?: ManifestValidationGroup[] + fields?: ManifestField[] + to?: ManifestReferenceMember[] + of?: ManifestArrayMember[] + preview?: { + select: Record + } + fieldsets?: ManifestFieldset[] + options?: Record + //portable text + marks?: { + annotations?: ManifestArrayMember[] + decorators?: ManifestTitledValue[] + } + lists?: ManifestTitledValue[] + styles?: ManifestTitledValue[] + + // userland (assignable to ManifestSerializable | undefined) + // not included to add some typesafty to extractManifest + // [index: string]: unknown +} + +export interface ManifestFieldset { + name: string + title?: string + [index: string]: ManifestSerializable | undefined +} + +export interface ManifestTitledValue { + value: string + title?: string +} + +export type ManifestField = ManifestSchemaType & {fieldset?: string} +export type ManifestArrayMember = Omit & {name?: string} +export type ManifestReferenceMember = Omit & {name?: string} + +export interface ManifestValidationGroup { + rules: ManifestValidationRule[] + message?: string + level?: 'error' | 'warning' | 'info' +} + +export type ManifestValidationRule = { + flag: string + constraint?: ManifestSerializable + [index: string]: ManifestSerializable | undefined +} diff --git a/packages/sanity/src/core/index.ts b/packages/sanity/src/core/index.ts index 56606120212..83140342883 100644 --- a/packages/sanity/src/core/index.ts +++ b/packages/sanity/src/core/index.ts @@ -33,5 +33,9 @@ export * from './templates' export * from './theme' export * from './user-color' export * from './util' -export {validateDocument, type ValidateDocumentOptions} from './validation' +export { + Rule as ConcreteRuleClass, + validateDocument, + type ValidateDocumentOptions, +} from './validation' export * from './version' diff --git a/packages/sanity/src/core/validation/Rule.ts b/packages/sanity/src/core/validation/Rule.ts index 6640cda8a09..15b4ac165a7 100644 --- a/packages/sanity/src/core/validation/Rule.ts +++ b/packages/sanity/src/core/validation/Rule.ts @@ -54,21 +54,25 @@ const ruleConstraintTypes: RuleTypeConstraint[] = [ 'String', ] -// Note: `RuleClass` and `Rule` are split to fit the current `@sanity/types` -// setup. Classes are a bit weird in the `@sanity/types` package because classes -// create an actual javascript class while simultaneously creating a type -// definition. -// -// This implicitly creates two types: -// 1. the instance type — `Rule` and -// 2. the static/class type - `RuleClass` -// -// The `RuleClass` type contains the static methods and the `Rule` instance -// contains the instance methods. -// -// This package exports the RuleClass as a value without implicitly exporting -// an instance definition. This should help reminder downstream users to import -// from the `@sanity/types` package. +/** + * Note: `RuleClass` and `Rule` are split to fit the current `@sanity/types` + * setup. Classes are a bit weird in the `@sanity/types` package because classes + * create an actual javascript class while simultaneously creating a type + * definition. + * + * This implicitly creates two types: + * 1. the instance type — `Rule` and + * 2. the static/class type - `RuleClass` + * + * The `RuleClass` type contains the static methods and the `Rule` instance + * contains the instance methods. + * + * This package exports the RuleClass as a value without implicitly exporting + * an instance definition. This should help reminder downstream users to import + * from the `@sanity/types` package. + * + * @internal + */ export const Rule: RuleClass = class Rule implements IRule { static readonly FIELD_REF = FIELD_REF static array = (def?: SchemaType): Rule => new Rule(def).type('Array') diff --git a/packages/sanity/test/manifest/extractManifest.test.ts b/packages/sanity/test/manifest/extractManifest.test.ts new file mode 100644 index 00000000000..36cabadb2e9 --- /dev/null +++ b/packages/sanity/test/manifest/extractManifest.test.ts @@ -0,0 +1,990 @@ +/* eslint-disable camelcase */ +import {describe, expect, test} from '@jest/globals' +import {defineArrayMember, defineField, defineType} from '@sanity/types' + +import {extractManifestSchemaTypes} from '../../src/_internal/manifest/extractWorkspaceManifest' +import {createSchema} from '../../src/core' + +describe('Extract studio manifest', () => { + describe('serialize schema for manifest', () => { + test('extracted schema should only include user defined types (and no built-in types)', () => { + const documentType = 'basic' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: documentType, + type: 'document', + fields: [defineField({name: 'title', type: 'string'})], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + expect(extracted.map((v) => v.name)).toStrictEqual([documentType]) + }) + + test('indicate conditional for function values on hidden and readOnly fields', () => { + const documentType = 'basic' + + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: documentType, + type: 'document', + readOnly: true, + hidden: false, + fields: [ + defineField({ + name: 'string', + type: 'string', + hidden: () => true, + readOnly: () => false, + }), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + type: 'document', + name: 'basic', + readOnly: true, + hidden: false, + fields: [ + { + name: 'string', + type: 'string', + hidden: 'conditional', + readOnly: 'conditional', + }, + ], + }) + }) + + test('should omit known non-serializable schema props ', () => { + const documentType = 'remove-props' + + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + //include + name: documentType, + type: 'document', + title: 'My document', + description: 'Stuff', + deprecated: { + reason: 'old', + }, + options: { + custom: 'value', + }, + initialValue: {title: 'Default'}, + liveEdit: true, + + //omit + icon: () => 'remove-icon', + groups: [{name: 'groups-are-removed'}], + __experimental_omnisearch_visibility: true, + __experimental_search: [ + { + path: 'title', + weight: 100, + }, + ], + __experimental_formPreviewTitle: true, + components: { + field: () => 'remove-components', + }, + orderings: [ + {name: 'remove-orderings', title: '', by: [{field: 'title', direction: 'desc'}]}, + ], + fields: [ + defineField({ + name: 'string', + type: 'string', + group: 'groups-are-removed', + }), + ], + preview: { + select: {title: 'remove-preview'}, + }, + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + type: 'document', + name: documentType, + title: 'My document', + description: 'Stuff', + deprecated: { + reason: 'old', + }, + options: { + custom: 'value', + }, + initialValue: {title: 'Default'}, + liveEdit: true, + fields: [ + { + name: 'string', + type: 'string', + }, + ], + }) + }) + + test('schema should include most userland properties', () => { + const documentType = 'basic' + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const recursiveObject: any = { + repeat: 'string', + } + recursiveObject.recurse = recursiveObject + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const customization: any = { + recursiveObject, // this one will be cut off at max-depth + serializableProp: 'dummy', + nonSerializableProp: () => {}, + options: { + serializableOption: true, + nonSerializableOption: () => {}, + nested: { + serializableOption: 1, + nonSerializableOption: () => {}, + }, + }, + } + + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: documentType, + type: 'document', + fields: [ + defineField({ + title: 'Nested', + name: 'nested', + type: 'object', + fields: [ + defineField({ + title: 'Nested inline string', + name: 'nestedString', + type: 'string', + ...customization, + }), + ], + ...customization, + }), + ], + ...customization, + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + + const expectedCustomProps = { + serializableProp: 'dummy', + options: { + serializableOption: true, + nested: { + serializableOption: 1, + }, + }, + recursiveObject: { + recurse: { + recurse: { + recurse: { + repeat: 'string', + }, + repeat: 'string', + }, + repeat: 'string', + }, + repeat: 'string', + }, + } + + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + type: 'document', + name: 'basic', + fields: [ + { + name: 'nested', + type: 'object', + fields: [ + { + name: 'nestedString', + title: 'Nested inline string', + type: 'string', + ...expectedCustomProps, + }, + ], + ...expectedCustomProps, + }, + ], + ...expectedCustomProps, + }) + }) + + test('should serialize fieldset config', () => { + const documentType = 'fieldsets' + + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: documentType, + type: 'document', + fields: [ + defineField({ + name: 'string', + type: 'string', + }), + ], + preview: { + select: {title: 'title'}, + prepare: () => ({ + title: 'remove-prepare', + }), + }, + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + type: 'document', + name: documentType, + fields: [ + { + name: 'string', + type: 'string', + }, + ], + }) + }) + + test('serialize fieldless types', () => { + const documentType = 'fieldless-types' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + title: 'Some document', + name: documentType, + type: 'document', + fields: [ + defineField({title: 'String field', name: 'string', type: 'string'}), + defineField({title: 'Text field', name: 'text', type: 'text'}), + defineField({title: 'Number field', name: 'number', type: 'number'}), + defineField({title: 'Boolean field', name: 'boolean', type: 'boolean'}), + defineField({title: 'Date field', name: 'date', type: 'date'}), + defineField({title: 'Datetime field', name: 'datetime', type: 'datetime'}), + defineField({title: 'Geopoint field', name: 'geopoint', type: 'geopoint'}), + defineField({title: 'Basic image field', name: 'image', type: 'image'}), + defineField({title: 'Basic file field', name: 'file', type: 'file'}), + defineField({title: 'Slug field', name: 'slug', type: 'slug'}), + defineField({title: 'URL field', name: 'url', type: 'url'}), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + fields: [ + {name: 'string', title: 'String field', type: 'string'}, + {name: 'text', title: 'Text field', type: 'text'}, + {name: 'number', title: 'Number field', type: 'number'}, + {name: 'boolean', title: 'Boolean field', type: 'boolean'}, + {name: 'date', title: 'Date field', type: 'date'}, + {name: 'datetime', title: 'Datetime field', type: 'datetime'}, + {name: 'geopoint', title: 'Geopoint field', type: 'geopoint'}, + {name: 'image', title: 'Basic image field', type: 'image'}, + {name: 'file', title: 'Basic file field', type: 'file'}, + { + name: 'slug', + title: 'Slug field', + type: 'slug', + validation: [{level: 'error', rules: [{flag: 'custom'}]}], + }, + { + name: 'url', + title: 'URL field', + type: 'url', + validation: [ + { + level: 'error', + rules: [ + { + constraint: { + options: { + allowCredentials: false, + allowRelative: false, + relativeOnly: false, + scheme: ['/^http$/', '/^https$/'], + }, + }, + flag: 'uri', + }, + ], + }, + ], + }, + ], + name: documentType, + title: 'Some document', + type: 'document', + }) + }) + + test('serialize types with fields', () => { + const documentType = 'field-types' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + fields: [ + { + name: 'existingType', + type: documentType, + }, + { + fields: [ + { + name: 'nestedString', + title: 'Nested inline string', + type: 'string', + }, + { + fields: [ + { + name: 'inner', + title: 'Inner', + type: 'number', + }, + ], + name: 'nestedTwice', + title: 'Child object', + type: 'object', + }, + ], + name: 'nested', + title: 'Nested', + type: 'object', + }, + { + fields: [ + { + name: 'title', + title: 'Image title', + type: 'string', + }, + ], + name: 'image', + type: 'image', + }, + { + fields: [ + { + name: 'title', + title: 'File title', + type: 'string', + }, + ], + name: 'file', + type: 'file', + }, + ], + name: documentType, + type: 'document', + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + fields: [ + { + name: 'existingType', + type: 'field-types', + }, + + { + fields: [ + { + name: 'nestedString', + title: 'Nested inline string', + type: 'string', + }, + { + fields: [ + { + name: 'inner', + type: 'number', + }, + ], + name: 'nestedTwice', + title: 'Child object', + type: 'object', + }, + ], + name: 'nested', + type: 'object', + }, + { + fields: [ + { + name: 'title', + title: 'Image title', + type: 'string', + }, + ], + name: 'image', + type: 'image', + }, + { + fields: [ + { + name: 'title', + title: 'File title', + type: 'string', + }, + ], + name: 'file', + type: 'file', + }, + ], + name: documentType, + type: 'document', + }) + }) + + test('serialize array-like fields (portable text tested separately)', () => { + const documentType = 'all-types' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + title: 'Basic doc', + name: documentType, + type: 'document', + fields: [ + defineField({ + title: 'String array', + name: 'stringArray', + type: 'array', + of: [{type: 'string'}], + }), + defineField({ + title: 'Number array', + name: 'numberArray', + type: 'array', + of: [{type: 'number'}], + }), + defineField({ + title: 'Boolean array', + name: 'booleanArray', + type: 'array', + of: [{type: 'boolean'}], + }), + defineField({ + name: 'objectArray', + type: 'array', + of: [ + defineArrayMember({ + title: 'Anonymous object item', + type: 'object', + fields: [ + defineField({ + name: 'itemTitle', + type: 'string', + }), + ], + }), + defineArrayMember({ + type: 'object', + title: 'Inline named object item', + name: 'item', + fields: [ + defineField({ + name: 'otherTitle', + type: 'string', + }), + ], + }), + defineArrayMember({ + title: 'Existing type object item', + type: documentType, + }), + ], + }), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + fields: [ + { + name: 'stringArray', + of: [{type: 'string'}], + title: 'String array', + type: 'array', + }, + { + name: 'numberArray', + of: [{type: 'number'}], + title: 'Number array', + type: 'array', + }, + { + name: 'booleanArray', + of: [{type: 'boolean'}], + title: 'Boolean array', + type: 'array', + }, + { + name: 'objectArray', + of: [ + { + title: 'Anonymous object item', + type: 'object', + fields: [{name: 'itemTitle', type: 'string'}], + }, + { + fields: [{name: 'otherTitle', type: 'string'}], + title: 'Inline named object item', + type: 'object', + name: 'item', + }, + { + title: 'Existing type object item', + type: 'all-types', + }, + ], + type: 'array', + }, + ], + name: 'all-types', + title: 'Basic doc', + type: 'document', + }) + }) + + test('serialize array with type reference and overridden typename', () => { + const arrayType = 'someArray' + const objectBaseType = 'someObject' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: objectBaseType, + type: 'object', + fields: [ + defineField({ + name: 'title', + type: 'string', + }), + ], + }), + defineType({ + name: arrayType, + type: 'array', + of: [{type: objectBaseType, name: 'override'}], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + + const serializedDoc = extracted.find((serialized) => serialized.name === arrayType) + expect(serializedDoc).toEqual({ + name: arrayType, + of: [{title: 'Some Object', type: objectBaseType, name: 'override'}], + type: 'array', + }) + }) + + test('serialize schema with indirectly recursive structure', () => { + const arrayType = 'someArray' + const objectBaseType = 'someObject' + const otherObjectType = 'other' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: objectBaseType, + type: 'object', + fields: [ + defineField({ + name: 'recurse', + type: otherObjectType, + }), + ], + }), + defineType({ + name: otherObjectType, + type: 'object', + fields: [ + defineField({ + name: 'recurse2', + type: arrayType, + }), + ], + }), + defineType({ + name: arrayType, + type: 'array', + of: [{type: objectBaseType}], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + + expect(extracted).toEqual([ + { + fields: [{name: 'recurse', type: 'other'}], + name: 'someObject', + type: 'object', + }, + { + fields: [{name: 'recurse2', type: 'someArray'}], + name: 'other', + type: 'object', + }, + { + name: 'someArray', + of: [{type: 'someObject'}], + type: 'array', + }, + ]) + }) + + test('serialize portable text field', () => { + const documentType = 'pt' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: documentType, + type: 'document', + fields: [ + defineField({ + title: 'Portable text', + name: 'pt', + type: 'array', + of: [ + defineArrayMember({ + title: 'Block', + name: 'block', + type: 'block', + of: [ + defineField({ + title: 'Inline block', + name: 'inlineBlock', + type: 'object', + fields: [ + defineField({ + title: 'Inline value', + name: 'value', + type: 'string', + }), + ], + }), + ], + marks: { + annotations: [ + defineField({ + title: 'Annotation', + name: 'annotation', + type: 'object', + fields: [ + defineField({ + title: 'Annotation value', + name: 'value', + type: 'string', + }), + ], + }), + ], + decorators: [{title: 'Custom mark', value: 'custom'}], + }, + lists: [{value: 'bullet', title: 'Bullet list'}], + styles: [{value: 'customStyle', title: 'Custom style'}], + }), + ], + }), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + fields: [ + { + name: 'pt', + of: [ + { + lists: [{title: 'Bullet list', value: 'bullet'}], + marks: { + annotations: [ + { + fields: [{name: 'value', title: 'Annotation value', type: 'string'}], + name: 'annotation', + type: 'object', + }, + ], + decorators: [{title: 'Custom mark', value: 'custom'}], + }, + of: [ + { + fields: [{name: 'value', title: 'Inline value', type: 'string'}], + name: 'inlineBlock', + title: 'Inline block', + type: 'object', + }, + ], + styles: [ + {title: 'Normal', value: 'normal'}, + {title: 'Custom style', value: 'customStyle'}, + ], + type: 'block', + }, + ], + title: 'Portable text', + type: 'array', + }, + ], + name: 'pt', + type: 'document', + }) + }) + + test('serialize fields with references', () => { + const documentType = 'ref-types' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: documentType, + type: 'document', + fields: [ + defineField({ + title: 'Reference to', + name: 'reference', + type: 'reference', + to: [{type: documentType}], + }), + defineField({ + title: 'Cross dataset ref', + name: 'crossDatasetReference', + type: 'crossDatasetReference', + dataset: 'production', + studioUrl: () => 'cannot serialize studioUrl function', + to: [ + { + type: documentType, + preview: { + select: {title: 'title'}, + prepare: () => ({ + title: 'cannot serialize prepare function', + }), + }, + }, + ], + }), + defineField({ + title: 'Reference array', + name: 'refArray', + type: 'array', + of: [ + defineArrayMember({ + title: 'Reference to', + name: 'reference', + type: 'reference', + to: [{type: documentType}], + }), + ], + }), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + fields: [ + { + name: 'reference', + title: 'Reference to', + to: [{type: documentType}], + type: 'reference', + }, + { + dataset: 'production', + name: 'crossDatasetReference', + title: 'Cross dataset ref', + type: 'crossDatasetReference', + to: [ + { + type: documentType, + preview: { + select: {title: 'title'}, + }, + }, + ], + }, + { + name: 'refArray', + of: [ + { + title: 'Reference to', + to: [{type: documentType}], + type: 'reference', + }, + ], + title: 'Reference array', + type: 'array', + }, + ], + name: documentType, + type: 'document', + }) + }) + + test('fieldsets and fieldset on fields is serialized', () => { + const documentType = 'basic' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: documentType, + type: 'document', + fieldsets: [ + { + name: 'test', + title: 'Test fieldset', + hidden: false, + readOnly: true, + options: { + collapsed: true, + }, + description: 'my fieldset', + }, + { + name: 'conditional', + hidden: () => true, + readOnly: () => true, + }, + ], + fields: [ + defineField({name: 'title', type: 'string', fieldset: 'test'}), + defineField({name: 'other', type: 'string', fieldset: 'conditional'}), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + fields: [ + { + fieldset: 'test', + name: 'title', + type: 'string', + }, + { + fieldset: 'conditional', + name: 'other', + type: 'string', + }, + ], + fieldsets: [ + { + description: 'my fieldset', + hidden: false, + name: 'test', + options: { + collapsed: true, + }, + readOnly: true, + title: 'Test fieldset', + }, + { + hidden: 'conditional', + name: 'conditional', + readOnly: 'conditional', + }, + ], + name: 'basic', + type: 'document', + }) + }) + + test('do not serialize default titles (default titles added by Schema.compile based on type/field name)', () => { + const documentType = 'basic-document' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: documentType, + type: 'document', + fieldsets: [ + {name: 'someFieldset'}, + { + name: 'conditional', + hidden: () => true, + readOnly: () => true, + }, + ], + fields: [ + defineField({name: 'title', type: 'string'}), + defineField({name: 'someField', type: 'array', of: [{type: 'string'}]}), + defineField({name: 'customTitleField', type: 'string', title: 'Custom'}), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const serializedDoc = extracted.find((serialized) => serialized.name === documentType) + expect(serializedDoc).toEqual({ + fields: [ + {name: 'title', type: 'string'}, + {name: 'someField', of: [{type: 'string'}], type: 'array'}, + {name: 'customTitleField', type: 'string', title: 'Custom'}, + ], + name: 'basic-document', + type: 'document', + }) + }) + }) +}) diff --git a/packages/sanity/test/manifest/extractManifestRestore.test.ts b/packages/sanity/test/manifest/extractManifestRestore.test.ts new file mode 100644 index 00000000000..a64ab1e8699 --- /dev/null +++ b/packages/sanity/test/manifest/extractManifestRestore.test.ts @@ -0,0 +1,205 @@ +import {describe, expect, test} from '@jest/globals' +import { + defineArrayMember, + defineField, + defineType, + type ObjectSchemaType, + type SchemaType, +} from '@sanity/types' +import pick from 'lodash/pick' + +import {extractManifestSchemaTypes} from '../../src/_internal/manifest/extractWorkspaceManifest' +import {createSchema} from '../../src/core' + +describe('Extract studio manifest', () => { + test('extracted schema types should be mappable to a createSchema compatible version', () => { + const documentType = 'basic' + const sourceSchema = createSchema({ + name: 'test', + types: [ + defineType({ + name: documentType, + type: 'document', + fields: [ + defineField({name: 'string', type: 'string'}), + defineField({name: 'text', type: 'text'}), + defineField({name: 'number', type: 'number'}), + defineField({name: 'boolean', type: 'boolean'}), + defineField({name: 'date', type: 'date'}), + defineField({name: 'datetime', type: 'datetime'}), + defineField({name: 'geopoint', type: 'geopoint'}), + defineField({name: 'image', type: 'image'}), + defineField({name: 'file', type: 'file'}), + defineField({name: 'slug', type: 'slug'}), + defineField({name: 'url', type: 'url'}), + defineField({name: 'object', type: documentType}), + defineField({ + type: 'object', + name: 'nestedObject', + fields: [{name: 'nestedString', type: 'string'}], + }), + defineField({ + type: 'image', + name: 'customImage', + fields: [{name: 'title', type: 'string'}], + }), + defineField({ + type: 'file', + name: 'customFile', + fields: [{name: 'title', type: 'string'}], + options: {storeOriginalFilename: true}, + }), + defineField({ + name: 'typeAliasArray', + type: 'array', + of: [{type: documentType}], + }), + defineField({ + name: 'stringArray', + type: 'array', + of: [{type: 'string'}], + }), + defineField({ + name: 'numberArray', + type: 'array', + of: [{type: 'number'}], + }), + defineField({ + name: 'booleanArray', + type: 'array', + of: [{type: 'boolean'}], + }), + defineField({ + name: 'objectArray', + type: 'array', + of: [ + defineArrayMember({ + type: 'object', + fields: [defineField({name: 'itemTitle', type: 'string'})], + }), + ], + }), + defineField({ + name: 'reference', + type: 'reference', + to: [{type: documentType}], + }), + defineField({ + name: 'crossDatasetReference', + type: 'crossDatasetReference', + dataset: 'production', + to: [ + { + type: documentType, + preview: {select: {title: 'title'}}, + }, + ], + }), + defineField({ + name: 'refArray', + type: 'array', + of: [ + defineArrayMember({ + name: 'reference', + type: 'reference', + to: [{type: documentType}], + }), + ], + }), + defineField({ + name: 'pt', + type: 'array', + of: [ + defineArrayMember({ + name: 'block', + type: 'block', + of: [ + defineField({ + name: 'inlineBlock', + type: 'object', + fields: [ + defineField({ + name: 'value', + type: 'string', + }), + ], + }), + ], + marks: { + annotations: [ + defineField({ + name: 'annotation', + type: 'object', + fields: [ + defineField({ + name: 'value', + type: 'string', + }), + ], + }), + ], + decorators: [{title: 'Custom mark', value: 'custom'}], + }, + lists: [{value: 'bullet', title: 'Bullet list'}], + styles: [{value: 'customStyle', title: 'Custom style'}], + }), + ], + }), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(sourceSchema) + + const restoredSchema = createSchema({ + name: 'test', + types: extracted, + }) + + expect(restoredSchema._validation).toEqual([]) + expect(restoredSchema.getTypeNames().sort()).toEqual(sourceSchema.getTypeNames().sort()) + + const restoredDocument = restoredSchema.get(documentType) as ObjectSchemaType + const sourceDocument = sourceSchema.get(documentType) as ObjectSchemaType + + // this is not an exhaustive test (requires additional mapping to make validation, readOnly ect schema def compliant); + // it just asserts that a basic schema can be restored without crashing + expect(typeForComparison(restoredDocument)).toEqual(typeForComparison(sourceDocument)) + }) +}) + +function typeForComparison(_type: SchemaType, depth = 0): unknown { + const type = pick(_type, 'jsonType', 'name', 'title', 'fields', 'of', 'to') + + if (depth > 10) { + return undefined + } + + if ('to' in type) { + return { + ...type, + to: (type.to as SchemaType[]).map((item) => ({ + type: item.name, + })), + } + } + + if (type.jsonType === 'object' && type.fields) { + return { + ...type, + fields: type.fields.map((field) => ({ + ...field, + type: typeForComparison(field.type, depth + 1), + })), + } + } + if (type.jsonType === 'array' && 'of' in type) { + return { + ...type, + of: (type.of as SchemaType[]).map((item) => typeForComparison(item, depth + 1)), + } + } + + return type +} diff --git a/packages/sanity/test/manifest/extractManifestValidation.test.ts b/packages/sanity/test/manifest/extractManifestValidation.test.ts new file mode 100644 index 00000000000..9709c8ea554 --- /dev/null +++ b/packages/sanity/test/manifest/extractManifestValidation.test.ts @@ -0,0 +1,515 @@ +/* eslint-disable camelcase */ +import {describe, expect, test} from '@jest/globals' +import {defineField, defineType} from '@sanity/types' + +import {extractManifestSchemaTypes} from '../../src/_internal/manifest/extractWorkspaceManifest' +import {createSchema} from '../../src/core' + +describe('Extract studio manifest', () => { + describe('serialize validation rules', () => { + test('object validation rules', () => { + const docType = 'some-doc' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: docType, + type: 'document', + fields: [defineField({name: 'title', type: 'string'})], + validation: (rule) => [ + rule + .required() + .custom(() => 'doesnt-matter') + .warning('custom-warning'), + rule.custom(() => 'doesnt-matter').error('custom-error'), + rule.custom(() => 'doesnt-matter').info('custom-info'), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === docType)?.validation + expect(validation).toEqual([ + { + level: 'warning', + message: 'custom-warning', + rules: [{constraint: 'required', flag: 'presence'}, {flag: 'custom'}], + }, + { + level: 'error', + message: 'custom-error', + rules: [{flag: 'custom'}], + }, + { + level: 'info', + message: 'custom-info', + rules: [{flag: 'custom'}], + }, + ]) + }) + + test('array validation rules', () => { + const type = 'someArray' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: type, + type: 'array', + of: [{type: 'string'}], + validation: (rule) => [ + rule + .required() + .unique() + .min(1) + .max(10) + .length(10) + .custom(() => 'doesnt-matter'), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === type)?.validation + expect(validation).toEqual([ + { + level: 'error', + rules: [ + {constraint: 'required', flag: 'presence'}, + {constraint: 1, flag: 'min'}, + {constraint: 10, flag: 'max'}, + {constraint: 10, flag: 'length'}, + {flag: 'custom'}, + ], + }, + ]) + }) + + test('boolean validation rules', () => { + const type = 'someArray' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: type, + type: 'boolean', + validation: (rule) => [rule.required().custom(() => 'doesnt-matter')], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === type)?.validation + expect(validation).toEqual([ + { + level: 'error', + rules: [{constraint: 'required', flag: 'presence'}, {flag: 'custom'}], + }, + ]) + }) + + test('date validation rules', () => { + const type = 'someDate' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: type, + type: 'date', + validation: (rule) => [ + rule + .required() + .min('2022-01-01') + .max('2022-01-02') + .custom(() => 'doesnt-matter'), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === type)?.validation + expect(validation).toEqual([ + { + level: 'error', + rules: [ + {constraint: 'required', flag: 'presence'}, + {constraint: '2022-01-01', flag: 'min'}, + {constraint: '2022-01-02', flag: 'max'}, + {flag: 'custom'}, + ], + }, + ]) + }) + + test('image validation rules', () => { + const type = 'someImage' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: type, + type: 'image', + validation: (rule) => [ + rule + .required() + .assetRequired() + .custom(() => 'doesnt-matter'), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === type)?.validation + expect(validation).toEqual([ + { + level: 'error', + rules: [ + {constraint: 'required', flag: 'presence'}, + {constraint: {assetType: 'image'}, flag: 'assetRequired'}, + {flag: 'custom'}, + ], + }, + ]) + }) + + test('file validation rules', () => { + const type = 'someFile' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: type, + type: 'file', + validation: (rule) => [ + rule + .required() + .assetRequired() + .custom(() => 'doesnt-matter'), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === type)?.validation + expect(validation).toEqual([ + { + level: 'error', + rules: [ + {constraint: 'required', flag: 'presence'}, + {constraint: {assetType: 'file'}, flag: 'assetRequired'}, + {flag: 'custom'}, + ], + }, + ]) + }) + + test('number validation rules', () => { + const type = 'someNumber' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: type, + type: 'number', + validation: (rule) => [ + rule + .custom(() => 'doesnt-matter') + .required() + .min(1) + .max(2), + rule.integer().positive(), + rule.greaterThan(-4).negative(), + rule.precision(2).lessThan(5), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === type)?.validation + expect(validation).toEqual([ + { + level: 'error', + rules: [ + {flag: 'custom'}, + {constraint: 'required', flag: 'presence'}, + {constraint: 1, flag: 'min'}, + {constraint: 2, flag: 'max'}, + ], + }, + { + level: 'error', + rules: [{constraint: 0, flag: 'min'}], + }, + { + level: 'error', + rules: [ + {constraint: -4, flag: 'greaterThan'}, + {constraint: 0, flag: 'lessThan'}, + ], + }, + { + level: 'error', + rules: [ + {constraint: 2, flag: 'precision'}, + {constraint: 5, flag: 'lessThan'}, + ], + }, + ]) + }) + + test('reference validation rules', () => { + const type = 'someRef' + const docType = 'doc' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + type: 'document', + name: docType, + fields: [ + defineField({ + type: 'string', + name: 'title', + }), + ], + }), + defineType({ + name: type, + type: 'reference', + to: [{type: docType}], + validation: (rule) => rule.required().custom(() => 'doesnt-matter'), + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === type)?.validation + expect(validation).toEqual([ + { + level: 'error', + rules: [{constraint: 'required', flag: 'presence'}, {flag: 'custom'}], + }, + ]) + }) + + test('slug validation rules', () => { + const type = 'someSlug' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: type, + type: 'slug', + validation: (rule) => rule.required().custom(() => 'doesnt-matter'), + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === type)?.validation + expect(validation).toEqual([ + { + level: 'error', + rules: [ + { + flag: 'custom', // this is the default unique checking rule + }, + { + constraint: 'required', + flag: 'presence', + }, + { + flag: 'custom', + }, + ], + }, + ]) + }) + + test('string validation rules', () => { + const type = 'someString' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: type, + type: 'string', + validation: (rule) => [ + rule + .required() + .max(50) + .min(5) + .length(10) + .uppercase() + .lowercase() + .regex(/a+/, 'test', {name: 'yeah', invert: true}) + .regex(/a+/, {name: 'yeah', invert: true}) + .regex(/a+/, 'test') + .regex(/a+/) + .email() + .custom(() => 'doesnt-matter'), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === type)?.validation + expect(validation).toEqual([ + { + level: 'error', + rules: [ + {constraint: 'required', flag: 'presence'}, + {constraint: 50, flag: 'max'}, + {constraint: 5, flag: 'min'}, + {constraint: 10, flag: 'length'}, + {constraint: 'uppercase', flag: 'stringCasing'}, + {constraint: 'lowercase', flag: 'stringCasing'}, + { + constraint: { + invert: false, + name: 'test', + pattern: '/a+/', + }, + flag: 'regex', + }, + { + constraint: { + invert: true, + name: 'yeah', + pattern: '/a+/', + }, + flag: 'regex', + }, + { + constraint: { + invert: false, + name: 'test', + pattern: '/a+/', + }, + flag: 'regex', + }, + { + constraint: { + invert: false, + pattern: '/a+/', + }, + flag: 'regex', + }, + { + flag: 'custom', + }, + ], + }, + ]) + }) + + test('url validation rules', () => { + const type = 'someUrl' + const schema = createSchema({ + name: 'test', + types: [ + defineType({ + name: type, + type: 'url', + validation: (rule) => [ + rule.required().custom(() => 'doesnt-matter'), + rule.uri({scheme: 'ftp'}), + rule.uri({ + scheme: ['https'], + allowCredentials: true, + allowRelative: true, + relativeOnly: false, + }), + rule.uri({ + scheme: /^custom-protocol.*$/g, + }), + ], + }), + ], + }) + + const extracted = extractManifestSchemaTypes(schema) + const validation = extracted.find((e) => e.name === type)?.validation + expect(validation).toEqual([ + { + level: 'error', + rules: [ + { + constraint: { + options: { + allowCredentials: false, + allowRelative: false, + relativeOnly: false, + scheme: ['/^http$/', '/^https$/'], + }, + }, + flag: 'uri', + }, + { + constraint: 'required', + flag: 'presence', + }, + { + flag: 'custom', + }, + ], + }, + { + level: 'error', + rules: [ + { + constraint: { + options: { + allowCredentials: false, + allowRelative: false, + relativeOnly: false, + scheme: ['/^ftp$/'], + }, + }, + flag: 'uri', + }, + ], + }, + { + level: 'error', + rules: [ + { + constraint: { + options: { + allowCredentials: true, + allowRelative: true, + relativeOnly: false, + scheme: ['/^https$/'], + }, + }, + flag: 'uri', + }, + ], + }, + { + level: 'error', + rules: [ + { + constraint: { + options: { + allowCredentials: false, + allowRelative: false, + relativeOnly: false, + scheme: ['/^custom-protocol.*$/g'], + }, + }, + flag: 'uri', + }, + ], + }, + ]) + }) + }) +})