diff --git a/.github/renovate.json b/.github/renovate.json index 386dcd52abb..47aed6e6a7c 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -84,6 +84,11 @@ "description": "ESBuild 0.23 drops support for Windows 7 and 8, for now we don't want renovate to open PRs to bump them", "allowedVersions": "<=0.21", "matchPackageNames": ["esbuild"] + }, + { + "description": "Group TypeScript related deps in a single PR, as they often have to update together", + "groupName": "typescript-tooling", + "matchPackageNames": ["@sanity/pkg-utils", "@sanity/tsdoc", "typescript"] } ], "ignorePaths": [ diff --git a/.github/workflows/lint-fix-if-needed.yml b/.github/workflows/lint-fix-if-needed.yml index 105503c0b7f..cb35554afe4 100644 --- a/.github/workflows/lint-fix-if-needed.yml +++ b/.github/workflows/lint-fix-if-needed.yml @@ -66,7 +66,7 @@ jobs: with: app-id: ${{ secrets.ECOSPARK_APP_ID }} private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} - - uses: peter-evans/create-pull-request@9153d834b60caba6d51c9b9510b087acf9f33f83 # v6 + - uses: peter-evans/create-pull-request@8867c4aba1b742c39f8d0ba35429c2dfa4b6cb20 # v7 # Run even if `lint:fix` fails if: always() with: @@ -74,5 +74,6 @@ jobs: branch: actions/lint-fix-if-needed commit-message: "chore(lint): fix linter issues 🤖 ✨" labels: 🤖 bot + sign-commits: true title: "chore(lint): fix linter issues 🤖 ✨" token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/pnpm-if-needed.yml b/.github/workflows/pnpm-if-needed.yml index 43b28d89f90..e9064b9e2cb 100644 --- a/.github/workflows/pnpm-if-needed.yml +++ b/.github/workflows/pnpm-if-needed.yml @@ -62,11 +62,12 @@ jobs: with: app-id: ${{ secrets.ECOSPARK_APP_ID }} private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} - - uses: peter-evans/create-pull-request@9153d834b60caba6d51c9b9510b087acf9f33f83 # v6 + - uses: peter-evans/create-pull-request@8867c4aba1b742c39f8d0ba35429c2dfa4b6cb20 # v7 with: body: I ran `pnpm dedupe` 🧑‍💻 branch: actions/dedupe-if-needed commit-message: "chore(deps): dedupe pnpm-lock.yaml" labels: 🤖 bot + sign-commits: true title: "chore(deps): dedupe pnpm-lock.yaml" token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/prettier-if-needed.yml b/.github/workflows/prettier-if-needed.yml index c4a271ade5a..769734fcfc3 100644 --- a/.github/workflows/prettier-if-needed.yml +++ b/.github/workflows/prettier-if-needed.yml @@ -66,11 +66,12 @@ jobs: with: app-id: ${{ secrets.ECOSPARK_APP_ID }} private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} - - uses: peter-evans/create-pull-request@9153d834b60caba6d51c9b9510b087acf9f33f83 # v6 + - uses: peter-evans/create-pull-request@8867c4aba1b742c39f8d0ba35429c2dfa4b6cb20 # v7 with: body: I ran `pnpm prettier` 🧑‍💻 branch: actions/prettier-if-needed commit-message: "chore(prettier): fix unformatted files 🤖 ✨" labels: 🤖 bot + sign-commits: true title: "chore(prettier): fix unformatted files 🤖 ✨" token: ${{ steps.app-token.outputs.token }} diff --git a/dev/depcheck-test/package.json b/dev/depcheck-test/package.json index e3cb98af5da..7ba8dc2aabc 100644 --- a/dev/depcheck-test/package.json +++ b/dev/depcheck-test/package.json @@ -1,6 +1,6 @@ { "name": "depcheck-test", - "version": "3.57.0", + "version": "3.57.4", "private": true, "license": "MIT", "author": "Sanity.io " diff --git a/dev/design-studio/package.json b/dev/design-studio/package.json index 58ba490707b..d88a91deb6e 100644 --- a/dev/design-studio/package.json +++ b/dev/design-studio/package.json @@ -1,6 +1,6 @@ { "name": "design-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "description": "Sanity Design Studio", "keywords": [ @@ -32,7 +32,7 @@ }, "dependencies": { "@sanity/icons": "^3.4.0", - "@sanity/ui": "^2.8.8", + "@sanity/ui": "^2.8.9", "react": "^18.3.1", "react-dom": "^18.3.1", "sanity": "workspace:*", diff --git a/dev/embedded-studio/package.json b/dev/embedded-studio/package.json index 277ccc6d7d5..700b4bd0662 100644 --- a/dev/embedded-studio/package.json +++ b/dev/embedded-studio/package.json @@ -1,6 +1,6 @@ { "name": "embedded-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "scripts": { "build": "tsc && vite build", @@ -8,17 +8,17 @@ "preview": "vite preview" }, "dependencies": { - "@sanity/ui": "^2.8.8", + "@sanity/ui": "^2.8.9", "react": "^18.3.1", "react-dom": "^18.3.1", "sanity": "workspace:*", "styled-components": "^6.1.0" }, "devDependencies": { - "@types/react": "^18.3.3", + "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", - "typescript": "5.5.4", + "typescript": "5.6.2", "vite": "^4.5.3" } } diff --git a/dev/page-building-studio/package.json b/dev/page-building-studio/package.json index 46d02fc080f..1ae554f9298 100644 --- a/dev/page-building-studio/package.json +++ b/dev/page-building-studio/package.json @@ -1,6 +1,6 @@ { "name": "sanity-page-building-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "license": "MIT", "author": "Sanity.io ", diff --git a/dev/starter-next-studio/package.json b/dev/starter-next-studio/package.json index 5d666f4fceb..9637e13ee4b 100644 --- a/dev/starter-next-studio/package.json +++ b/dev/starter-next-studio/package.json @@ -1,6 +1,6 @@ { "name": "sanity-starter-next-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "license": "MIT", "author": "Sanity.io ", diff --git a/dev/starter-studio/package.json b/dev/starter-studio/package.json index a76d7658b36..22e6a388fe3 100644 --- a/dev/starter-studio/package.json +++ b/dev/starter-studio/package.json @@ -1,6 +1,6 @@ { "name": "sanity-starter-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "license": "MIT", "author": "Sanity.io ", diff --git a/dev/strict-studio/package.json b/dev/strict-studio/package.json index 5a62c8fc591..01b5296659a 100644 --- a/dev/strict-studio/package.json +++ b/dev/strict-studio/package.json @@ -1,6 +1,6 @@ { "name": "sanity-strict-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "license": "MIT", "author": "Sanity.io ", diff --git a/dev/studio-e2e-testing/package.json b/dev/studio-e2e-testing/package.json index ea4ab41368f..7a26f074ccb 100644 --- a/dev/studio-e2e-testing/package.json +++ b/dev/studio-e2e-testing/package.json @@ -1,6 +1,6 @@ { "name": "studio-e2e-testing", - "version": "3.57.0", + "version": "3.57.4", "private": true, "keywords": [ "sanity" @@ -17,8 +17,8 @@ "dependencies": { "@sanity/google-maps-input": "^4.0.0", "@sanity/icons": "^3.4.0", - "@sanity/ui": "^2.8.8", - "@sanity/vision": "3.57.0", + "@sanity/ui": "^2.8.9", + "@sanity/vision": "3.57.4", "react": "^18.3.1", "react-dom": "^18.3.1", "sanity": "workspace:*", diff --git a/dev/test-next-studio/package.json b/dev/test-next-studio/package.json index c517906d170..15a760a06a1 100644 --- a/dev/test-next-studio/package.json +++ b/dev/test-next-studio/package.json @@ -1,6 +1,6 @@ { "name": "sanity-test-next-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "license": "MIT", "author": "Sanity.io ", @@ -12,7 +12,7 @@ }, "dependencies": { "@sanity/vision": "workspace:*", - "babel-plugin-react-compiler": "0.0.0-experimental-334f00b-20240725", + "babel-plugin-react-compiler": "0.0.0-experimental-de2cfda-20240912", "next": "15.0.0-rc.0", "react": "19.0.0-rc-a7d1240c-20240731", "react-dom": "19.0.0-rc-a7d1240c-20240731", @@ -20,6 +20,6 @@ "sanity": "workspace:*", "sanity-test-studio": "workspace:*", "styled-components": "^6.1.12", - "typescript": "5.5.4" + "typescript": "5.6.2" } } diff --git a/dev/test-studio/package.json b/dev/test-studio/package.json index 81394140453..fc208efa26b 100644 --- a/dev/test-studio/package.json +++ b/dev/test-studio/package.json @@ -1,6 +1,6 @@ { "name": "sanity-test-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "license": "MIT", "author": "Sanity.io ", @@ -16,11 +16,11 @@ "workshop:dev": "node -r esbuild-register scripts/workshop/dev.ts" }, "dependencies": { - "@portabletext/editor": "^1.0.12", + "@portabletext/editor": "^1.1.0", "@portabletext/react": "^3.0.0", "@sanity/assist": "^3.0.2", - "@sanity/block-tools": "3.57.0", - "@sanity/client": "^6.21.2", + "@sanity/block-tools": "3.57.4", + "@sanity/client": "^6.21.3", "@sanity/color": "^3.0.0", "@sanity/google-maps-input": "^4.0.0", "@sanity/icons": "^3.4.0", @@ -34,14 +34,14 @@ "@sanity/migrate": "workspace:*", "@sanity/preview-url-secret": "^1.6.1", "@sanity/react-loader": "^1.8.3", - "@sanity/tsdoc": "1.0.90", + "@sanity/tsdoc": "1.0.105", "@sanity/types": "workspace:*", - "@sanity/ui": "^2.8.8", + "@sanity/ui": "^2.8.9", "@sanity/ui-workshop": "^1.0.0", "@sanity/util": "workspace:*", "@sanity/uuid": "^3.0.1", "@sanity/vision": "workspace:*", - "@sanity/visual-editing": "2.1.8", + "@sanity/visual-editing": "2.1.10", "@turf/helpers": "^6.0.1", "@turf/points-within-polygon": "^5.1.5", "@vercel/stega": "0.1.2", diff --git a/examples/blog-studio/package.json b/examples/blog-studio/package.json index deb0bda3803..08cba373652 100644 --- a/examples/blog-studio/package.json +++ b/examples/blog-studio/package.json @@ -1,6 +1,6 @@ { "name": "blog-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "description": "Content studio running with schema from the blog init template", "keywords": [ diff --git a/examples/clean-studio/package.json b/examples/clean-studio/package.json index 7e606e10caa..acfd8084219 100644 --- a/examples/clean-studio/package.json +++ b/examples/clean-studio/package.json @@ -1,6 +1,6 @@ { "name": "clean-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "description": "Content studio running with schema from the clean template", "keywords": [ diff --git a/examples/ecommerce-studio/package.json b/examples/ecommerce-studio/package.json index 4e5b45ced71..01a5b630412 100644 --- a/examples/ecommerce-studio/package.json +++ b/examples/ecommerce-studio/package.json @@ -1,6 +1,6 @@ { "name": "ecommerce-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "description": "", "keywords": [ @@ -29,8 +29,8 @@ "start": "sanity dev --port 3337" }, "dependencies": { - "@sanity/cli": "3.57.0", - "@sanity/ui": "^2.8.8", + "@sanity/cli": "3.57.4", + "@sanity/ui": "^2.8.9", "react": "^18.3.1", "react-barcode": "^1.4.1", "react-dom": "^18.3.1", diff --git a/examples/movies-studio/package.json b/examples/movies-studio/package.json index 910e7c0c883..935a10fa084 100644 --- a/examples/movies-studio/package.json +++ b/examples/movies-studio/package.json @@ -1,6 +1,6 @@ { "name": "movies-studio", - "version": "3.57.0", + "version": "3.57.4", "private": true, "description": "Content studio running with schema from the moviedb init template", "keywords": [ diff --git a/lerna.json b/lerna.json index c1935bcab9d..ff354a1d261 100644 --- a/lerna.json +++ b/lerna.json @@ -12,5 +12,5 @@ "packages/groq", "packages/sanity" ], - "version": "3.57.0" + "version": "3.57.4" } diff --git a/package.json b/package.json index 8c2779f2635..923d3610000 100644 --- a/package.json +++ b/package.json @@ -106,20 +106,20 @@ "@playwright/test": "1.44.1", "@repo/package.config": "workspace:*", "@repo/tsconfig": "workspace:*", - "@sanity/client": "^6.21.2", + "@sanity/client": "^6.21.3", "@sanity/eslint-config-i18n": "1.0.0", "@sanity/eslint-config-studio": "^4.0.0", - "@sanity/mutate": "^0.8.0", - "@sanity/pkg-utils": "6.11.0", + "@sanity/mutate": "^0.10.0", + "@sanity/pkg-utils": "6.11.2", "@sanity/prettier-config": "^1.0.2", "@sanity/test": "0.0.1-alpha.1", - "@sanity/tsdoc": "1.0.90", - "@sanity/ui": "^2.8.8", + "@sanity/tsdoc": "1.0.105", + "@sanity/ui": "^2.8.9", "@sanity/uuid": "^3.0.2", "@types/glob": "^7.2.0", "@types/lodash": "^4.17.7", "@types/node": "^18.19.8", - "@types/react": "^18.3.3", + "@types/react": "^18.3.5", "@types/semver": "^7.5.6", "@types/yargs": "^17.0.7", "@typescript-eslint/eslint-plugin": "^7.18.0", @@ -135,12 +135,12 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-config-sanity": "^7.1.2", - "eslint-config-turbo": "^2.0.11", - "eslint-import-resolver-typescript": "^3.6.1", + "eslint-config-turbo": "^2.1.2", + "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-boundaries": "^4.2.2", - "eslint-plugin-import": "^2.29.1", + "eslint-plugin-import": "^2.30.0", "eslint-plugin-prettier": "^5.2.1", - "eslint-plugin-react": "^7.35.0", + "eslint-plugin-react": "^7.36.1", "eslint-plugin-react-compiler": "0.0.0-experimental-9ed098e-20240725", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-simple-import-sort": "^12.1.1", @@ -165,8 +165,8 @@ "rxjs": "^7.8.1", "sanity": "workspace:*", "semver": "^7.3.5", - "turbo": "^2.0.11", - "typescript": "5.5.4", + "turbo": "^2.1.2", + "typescript": "5.6.2", "vite": "^4.5.3", "vite-tsconfig-paths": "^4.3.2", "yargs": "^17.3.0" @@ -174,7 +174,7 @@ "optionalDependencies": { "node-notifier": "^10.0.0" }, - "packageManager": "pnpm@9.7.0", + "packageManager": "pnpm@9.10.0", "pnpm": { "peerDependencyRules": { "allowAny": [ diff --git a/packages/@repo/package.bundle/package.json b/packages/@repo/package.bundle/package.json index eea5831d188..344bc9aa684 100644 --- a/packages/@repo/package.bundle/package.json +++ b/packages/@repo/package.bundle/package.json @@ -1,6 +1,6 @@ { "name": "@repo/package.bundle", - "version": "3.57.0", + "version": "3.57.4", "private": true, "description": "Shared package bundle configuration", "main": "./src/package.bundle.ts", diff --git a/packages/@repo/package.config/package.json b/packages/@repo/package.config/package.json index 0ae49404808..058ed8878d4 100644 --- a/packages/@repo/package.config/package.json +++ b/packages/@repo/package.config/package.json @@ -1,6 +1,6 @@ { "name": "@repo/package.config", - "version": "3.57.0", + "version": "3.57.4", "private": true, "description": "Shared @sanity/pkg-utils configuration", "main": "./src/package.config.ts", diff --git a/packages/@repo/test-exports/package.json b/packages/@repo/test-exports/package.json index d74c0f45b8a..65662eb0a0e 100644 --- a/packages/@repo/test-exports/package.json +++ b/packages/@repo/test-exports/package.json @@ -1,6 +1,6 @@ { "name": "@repo/test-exports", - "version": "3.57.0", + "version": "3.57.4", "private": true, "description": "Ensures that all the monorepo packages that are published works in native node ESM and CJS runtimes", "exports": { diff --git a/packages/@repo/tsconfig/package.json b/packages/@repo/tsconfig/package.json index eeafcbcb253..efee35f18a3 100644 --- a/packages/@repo/tsconfig/package.json +++ b/packages/@repo/tsconfig/package.json @@ -1,5 +1,5 @@ { "name": "@repo/tsconfig", - "version": "3.57.0", + "version": "3.57.4", "private": true } diff --git a/packages/@sanity/block-tools/package.json b/packages/@sanity/block-tools/package.json index 339459a51fe..383e05e232e 100644 --- a/packages/@sanity/block-tools/package.json +++ b/packages/@sanity/block-tools/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/block-tools", - "version": "3.57.0", + "version": "3.57.4", "description": "Can format HTML, Slate JSON or Sanity block array into any other format.", "keywords": [ "sanity", @@ -49,15 +49,15 @@ "watch": "pkg-utils watch" }, "dependencies": { - "@sanity/types": "3.57.0", - "@types/react": "^18.3.3", + "@sanity/types": "3.57.4", + "@types/react": "^18.3.5", "get-random-values-esm": "1.0.2", "lodash": "^4.17.21" }, "devDependencies": { "@jest/globals": "^29.7.0", "@repo/package.config": "workspace:*", - "@sanity/schema": "3.57.0", + "@sanity/schema": "3.57.4", "@types/jsdom": "^20.0.0", "@types/lodash": "^4.17.7", "@vercel/stega": "0.1.2", diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index cd3bdf2cf02..25575ccdb60 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/cli", - "version": "3.57.0", + "version": "3.57.4", "description": "Sanity CLI tool for managing Sanity installations, managing plugins, schemas and datasets", "keywords": [ "sanity", @@ -57,10 +57,10 @@ }, "dependencies": { "@babel/traverse": "^7.23.5", - "@sanity/client": "^6.21.2", - "@sanity/codegen": "3.57.0", + "@sanity/client": "^6.21.3", + "@sanity/codegen": "3.57.4", "@sanity/telemetry": "^0.7.7", - "@sanity/util": "3.57.0", + "@sanity/util": "3.57.4", "chalk": "^4.1.2", "debug": "^4.3.4", "decompress": "^4.2.0", diff --git a/packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts b/packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts index 186d5a19ef0..1c14b8f0614 100644 --- a/packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts +++ b/packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts @@ -127,6 +127,7 @@ export async function bootstrapTemplate( const cliConfig = await createCliConfig({ projectId: variables.projectId, dataset: variables.dataset, + autoUpdates: variables.autoUpdates, }) // Write non-template files to disc diff --git a/packages/@sanity/cli/src/actions/init-project/createCliConfig.ts b/packages/@sanity/cli/src/actions/init-project/createCliConfig.ts index 4b83847ce4b..abd1e5c8c92 100644 --- a/packages/@sanity/cli/src/actions/init-project/createCliConfig.ts +++ b/packages/@sanity/cli/src/actions/init-project/createCliConfig.ts @@ -9,19 +9,26 @@ export default defineCliConfig({ api: { projectId: '%projectId%', dataset: '%dataset%' - } + }, + /** + * Enable auto-updates for studios. + * Learn more at https://www.sanity.io/docs/cli#auto-updates + */ + autoUpdates: __BOOL__autoUpdates__, }) ` export interface GenerateCliConfigOptions { projectId: string dataset: string + autoUpdates: boolean } export function createCliConfig(options: GenerateCliConfigOptions): string { const variables = options const template = defaultTemplate.trimStart() const ast = parse(template, {parser}) + traverse(ast, { StringLiteral: { enter({node}) { @@ -29,13 +36,35 @@ export function createCliConfig(options: GenerateCliConfigOptions): string { if (!value.startsWith('%') || !value.endsWith('%')) { return } - const variableName = value.slice(1, -1) as keyof GenerateCliConfigOptions if (!(variableName in variables)) { throw new Error(`Template variable '${value}' not defined`) } - - node.value = variables[variableName] || '' + const newValue = variables[variableName] + /* + * although there are valid non-strings in our config, + * they're not in StringLiteral nodes, so assume undefined + */ + node.value = typeof newValue === 'string' ? newValue : '' + }, + }, + Identifier: { + enter(path) { + if (!path.node.name.startsWith('__BOOL__')) { + return + } + const variableName = path.node.name.replace( + /^__BOOL__(.+?)__$/, + '$1', + ) as keyof GenerateCliConfigOptions + if (!(variableName in variables)) { + throw new Error(`Template variable '${variableName}' not defined`) + } + const value = variables[variableName] + if (typeof value !== 'boolean') { + throw new Error(`Expected boolean value for '${variableName}'`) + } + path.replaceWith({type: 'BooleanLiteral', value}) }, }, }) diff --git a/packages/@sanity/cli/src/actions/init-project/createStudioConfig.ts b/packages/@sanity/cli/src/actions/init-project/createStudioConfig.ts index 6aa2b8f8ecb..c5d29795ec6 100644 --- a/packages/@sanity/cli/src/actions/init-project/createStudioConfig.ts +++ b/packages/@sanity/cli/src/actions/init-project/createStudioConfig.ts @@ -34,6 +34,7 @@ export interface GenerateConfigOptions { variables: { projectId: string dataset: string + autoUpdates: boolean projectName?: string sourceName?: string sourceTitle?: string @@ -60,8 +61,12 @@ export function createStudioConfig(options: GenerateConfigOptions): string { if (!(variableName in variables)) { throw new Error(`Template variable '${value}' not defined`) } - - node.value = variables[variableName] || '' + const newValue = variables[variableName] + /* + * although there are valid non-strings in our config, + * they're not in this template, so assume undefined + */ + node.value = typeof newValue === 'string' ? newValue : '' }, }, }) diff --git a/packages/@sanity/cli/src/actions/init-project/initProject.ts b/packages/@sanity/cli/src/actions/init-project/initProject.ts index a8f929e28c0..fe87d5eb5dd 100644 --- a/packages/@sanity/cli/src/actions/init-project/initProject.ts +++ b/packages/@sanity/cli/src/actions/init-project/initProject.ts @@ -240,7 +240,11 @@ export default async function initSanity( throw new Error('`--reconfigure` is deprecated - manual configuration is now required') } - const envFilename = typeof env === 'string' ? env : '.env' + let envFilenameDefault = '.env' + if (detectedFramework && detectedFramework.slug === 'nextjs') { + envFilenameDefault = '.env.local' + } + const envFilename = typeof env === 'string' ? env : envFilenameDefault if (!envFilename.startsWith('.env')) { throw new Error(`Env filename must start with .env`) } @@ -433,10 +437,39 @@ export default async function initSanity( const appendEnv = unattended ? true : await promptForAppendEnv(prompt, envFilename) if (appendEnv) { - await createOrAppendEnvVars(envFilename, detectedFramework, { - log: true, + await createOrAppendEnvVars(envFilename, detectedFramework, {log: true}) + } + + if (embeddedStudio) { + const nextjsLocalDevOrigin = 'http://localhost:3000' + const existingCorsOrigins = await apiClient({api: {projectId}}).request({ + method: 'GET', + uri: '/cors', }) + const hasExistingCorsOrigin = existingCorsOrigins.some( + (item: {origin: string}) => item.origin === nextjsLocalDevOrigin, + ) + if (!hasExistingCorsOrigin) { + await apiClient({api: {projectId}}) + .request({ + method: 'POST', + url: '/cors', + body: {origin: nextjsLocalDevOrigin, allowCredentials: true}, + maxRedirects: 0, + }) + .then((res) => { + print( + res.id + ? `Added ${nextjsLocalDevOrigin} to CORS origins` + : `Failed to add ${nextjsLocalDevOrigin} to CORS origins`, + ) + }) + .catch((error) => { + print(`Failed to add ${nextjsLocalDevOrigin} to CORS origins`, error) + }) + } } + const {chosen} = await getPackageManagerChoice(workDir, {interactive: false}) trace.log({step: 'selectPackageManager', selectedOption: chosen}) const packages = ['@sanity/vision@3', 'sanity@3', '@sanity/image-url@1', 'styled-components@6'] @@ -534,6 +567,12 @@ export default async function initSanity( trace.log({step: 'useTypeScript', selectedOption: useTypeScript ? 'yes' : 'no'}) } + // we enable auto-updates by default, but allow users to specify otherwise + let autoUpdates = true + if (typeof cliFlags['auto-updates'] === 'boolean') { + autoUpdates = cliFlags['auto-updates'] + } + // Build a full set of resolved options const templateOptions: BootstrapOptions = { outputPath, @@ -542,6 +581,7 @@ export default async function initSanity( schemaUrl, useTypeScript, variables: { + autoUpdates, dataset: datasetName, projectId, projectName: displayName || answers.projectName, diff --git a/packages/@sanity/cli/src/commands/init/initCommand.ts b/packages/@sanity/cli/src/commands/init/initCommand.ts index 2863f626b38..394dc71e8e2 100644 --- a/packages/@sanity/cli/src/commands/init/initCommand.ts +++ b/packages/@sanity/cli/src/commands/init/initCommand.ts @@ -27,6 +27,7 @@ Options --coupon Optionally select a coupon for a new project (cannot be used with --project-plan) --no-typescript Do not use TypeScript for template files --package-manager Specify which package manager to use [allowed: ${allowedPackageManagersString}] + --no-auto-updates Disable auto updates of studio versions Examples # Initialize a new project, prompt for required information along the way @@ -60,6 +61,7 @@ export interface InitFlags { 'visibility'?: string 'typescript'?: boolean + 'auto-updates'?: boolean /** * Used for initializing a project from a server schema that is saved in the Journey API * Overrides `project` option. diff --git a/packages/@sanity/cli/test/init.test.ts b/packages/@sanity/cli/test/init.test.ts new file mode 100644 index 00000000000..98b47f57383 --- /dev/null +++ b/packages/@sanity/cli/test/init.test.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import {describe, expect} from '@jest/globals' + +import templates from '../src/actions/init-project/templates' +import {describeCliTest, testConcurrent} from './shared/describe' +import {baseTestPath, cliProjectId, getTestRunArgs, runSanityCmdCommand} from './shared/environment' + +describeCliTest('CLI: `sanity init v3`', () => { + describe.each(Object.keys(templates))('for template %s', (template) => { + testConcurrent('adds autoUpdates: true to cli config', async () => { + const version = 'v3' + const testRunArgs = getTestRunArgs(version) + const outpath = `test-template-${template}-${version}` + + await runSanityCmdCommand(version, [ + 'init', + '--y', + '--project', + cliProjectId, + '--dataset', + testRunArgs.dataset, + '--template', + template, + '--output-path', + `${baseTestPath}/${outpath}`, + '--package-manager', + 'manual', + ]) + + const cliConfig = await fs.readFile( + path.join(baseTestPath, outpath, 'sanity.cli.ts'), + 'utf-8', + ) + + expect(cliConfig).toContain(`projectId: '${cliProjectId}'`) + expect(cliConfig).toContain(`dataset: '${testRunArgs.dataset}'`) + expect(cliConfig).toContain(`autoUpdates: true`) + }) + }) + + testConcurrent('adds autoUpdates: true to cli config for javascript projects', async () => { + const version = 'v3' + const testRunArgs = getTestRunArgs(version) + const outpath = `test-template-${version}` + + await runSanityCmdCommand(version, [ + 'init', + '--y', + '--project', + cliProjectId, + '--dataset', + testRunArgs.dataset, + '--output-path', + `${baseTestPath}/${outpath}`, + '--package-manager', + 'manual', + '--no-typescript', + ]) + + const cliConfig = await fs.readFile(path.join(baseTestPath, outpath, 'sanity.cli.js'), 'utf-8') + + expect(cliConfig).toContain(`projectId: '${cliProjectId}'`) + expect(cliConfig).toContain(`dataset: '${testRunArgs.dataset}'`) + expect(cliConfig).toContain(`autoUpdates: true`) + }) + + testConcurrent('adds autoUpdates: false to cli config if flag provided', async () => { + const version = 'v3' + const testRunArgs = getTestRunArgs(version) + const outpath = `test-template-${version}` + + await runSanityCmdCommand(version, [ + 'init', + '--y', + '--project', + cliProjectId, + '--dataset', + testRunArgs.dataset, + '--output-path', + `${baseTestPath}/${outpath}`, + '--package-manager', + 'manual', + '--no-auto-updates', + ]) + + const cliConfig = await fs.readFile(path.join(baseTestPath, outpath, 'sanity.cli.ts'), 'utf-8') + + expect(cliConfig).toContain(`projectId: '${cliProjectId}'`) + expect(cliConfig).toContain(`dataset: '${testRunArgs.dataset}'`) + expect(cliConfig).toContain(`autoUpdates: false`) + }) +}) diff --git a/packages/@sanity/codegen/package.json b/packages/@sanity/codegen/package.json index 41ff79f838b..f184a269ddf 100644 --- a/packages/@sanity/codegen/package.json +++ b/packages/@sanity/codegen/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/codegen", - "version": "3.57.0", + "version": "3.57.4", "description": "Codegen toolkit for Sanity.io", "keywords": [ "sanity", diff --git a/packages/@sanity/diff/package.json b/packages/@sanity/diff/package.json index 9782f97eb95..57d33680ab8 100644 --- a/packages/@sanity/diff/package.json +++ b/packages/@sanity/diff/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/diff", - "version": "3.57.0", + "version": "3.57.4", "description": "Generates diffs between documents and primitive types", "keywords": [ "sanity", diff --git a/packages/@sanity/migrate/package.json b/packages/@sanity/migrate/package.json index 97d5fa56522..33c4f74d323 100644 --- a/packages/@sanity/migrate/package.json +++ b/packages/@sanity/migrate/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/migrate", - "version": "3.57.0", + "version": "3.57.4", "description": "Tooling for running data migrations on Sanity.io projects", "keywords": [ "sanity", @@ -50,10 +50,10 @@ "watch": "pkg-utils watch" }, "dependencies": { - "@sanity/client": "^6.21.2", - "@sanity/mutate": "^0.8.0", - "@sanity/types": "3.57.0", - "@sanity/util": "3.57.0", + "@sanity/client": "^6.21.3", + "@sanity/mutate": "^0.10.0", + "@sanity/types": "3.57.4", + "@sanity/util": "3.57.4", "arrify": "^2.0.1", "debug": "^4.3.4", "fast-fifo": "^1.3.2", diff --git a/packages/@sanity/migrate/src/mutations/creators.ts b/packages/@sanity/migrate/src/mutations/creators.ts index 56d9552a167..e02d6495d8a 100644 --- a/packages/@sanity/migrate/src/mutations/creators.ts +++ b/packages/@sanity/migrate/src/mutations/creators.ts @@ -20,7 +20,6 @@ import {type NormalizeReadOnlyArray, type Optional, type Tuplify} from './typeUt * Creates a new document. * @param document - The document to be created. * @returns The mutation to create the document. - * @beta */ export function create>( document: Doc, @@ -34,7 +33,6 @@ export function create>( * @param patches - The patches to be applied. * @param options - Optional patch options. * @returns The mutation to patch the document. - * @beta */ export function patch

( id: string, @@ -54,7 +52,6 @@ export function patch

( * @param path - The path where the operation should be applied. * @param operation - The operation to be applied. * @returns The node patch. - * @beta */ export function at(path: Path | string, operation: O): NodePatch { return { @@ -67,7 +64,6 @@ export function at(path: Path | string, operation: O): Node * Creates a document if it does not exist. * @param document - The document to be created. * @returns The mutation operation to create the document if it does not exist. - * @beta */ export function createIfNotExists( document: Doc, @@ -79,7 +75,6 @@ export function createIfNotExists( * Creates or replaces a document. * @param document - The document to be created or replaced. * @returns The mutation operation to create or replace the document. - * @beta */ export function createOrReplace( document: Doc, @@ -91,7 +86,6 @@ export function createOrReplace( * Deletes a document. * @param id - The id of the document to be deleted. * @returns The mutation operation to delete the document. - * @beta */ export function delete_(id: string): DeleteMutation { return {type: 'delete', id} diff --git a/packages/@sanity/migrate/src/mutations/operations/creators.ts b/packages/@sanity/migrate/src/mutations/operations/creators.ts index c56a6ac9433..729112d7b1a 100644 --- a/packages/@sanity/migrate/src/mutations/operations/creators.ts +++ b/packages/@sanity/migrate/src/mutations/operations/creators.ts @@ -21,7 +21,6 @@ import { * @param value - The value to set. * @returns A `set` operation. * {@link https://www.sanity.io/docs/http-patches#6TPENSW3} - * @beta * * @example * ```ts @@ -36,7 +35,6 @@ export const set = (value: T): SetOp => ({type: 'set', value}) * @param value - The value to set if missing. * @returns A `setIfMissing` operation. * {@link https://www.sanity.io/docs/http-patches#A80781bT} - * @beta * @example * ```ts * const setFooIfMissing = setIfMissing('foo') @@ -52,7 +50,6 @@ export const setIfMissing = (value: T): SetIfMissingOp => ({ * Creates an `unset` operation. * @returns An `unset` operation. * {@link https://www.sanity.io/docs/http-patches#xRtBjp8o} - * @beta * * @example * ```ts @@ -66,7 +63,6 @@ export const unset = (): UnsetOp => ({type: 'unset'}) * @param amount - The amount to increment by. * @returns An incrementation operation for numeric values * {@link https://www.sanity.io/docs/http-patches#vIT8WWQo} - * @beta * * @example * ```ts @@ -84,7 +80,6 @@ export const inc = (amount: N = 1 as N): IncOp => * @param amount - The amount to decrement by. * @returns A `dec` operation. * {@link https://www.sanity.io/docs/http-patches#vIT8WWQo} - * @beta * * @example * ```ts @@ -116,7 +111,6 @@ export const diffMatchPatch = (value: string): DiffMatchPatchOp => ({ * @param indexOrReferenceItem - The index or reference item to insert before or after. * @returns An `insert` operation for adding values to arrays * {@link https://www.sanity.io/docs/http-patches#febxf6Fk} - * @beta * * @example * ```ts @@ -147,7 +141,6 @@ export function insert< * @param items - The items to append. * @returns An `insert` operation for adding a value to the end of an array. * {@link https://www.sanity.io/docs/http-patches#Cw4vhD88} - * @beta * * @example * ```ts @@ -165,7 +158,6 @@ export function append>(items: Items | Arr * @param items - The items to prepend. * @returns An `insert` operation for adding a value to the start of an array. * {@link https://www.sanity.io/docs/http-patches#refAUsf0} - * @beta * * @example * ```ts @@ -205,7 +197,6 @@ export function insertBefore< * @param indexOrReferenceItem - The index or reference item to insert after. * @returns An `insert` operation after the provided index or reference item. * {@link https://www.sanity.io/docs/http-patches#0SQmPlb6} - * @beta * * @example * ```ts @@ -230,7 +221,6 @@ export const insertAfter = < * @returns A `truncate` operation. * @remarks - This will be converted to an `unset` patch when submitted to the API * {@link https://www.sanity.io/docs/http-patches#xRtBjp8o} - * @beta * * @example * ```ts @@ -254,7 +244,6 @@ export function truncate(startIndex: number, endIndex?: number): TruncateOp { * @returns A ReplaceOp operation. * @remarks This will be converted to an `insert`/`replace` patch when submitted to the API * {@link https://www.sanity.io/docs/http-patches#GnVSwcPa} - * @beta * * @example * ```ts diff --git a/packages/@sanity/mutator/package.json b/packages/@sanity/mutator/package.json index 330eb23dc83..42866e0c531 100644 --- a/packages/@sanity/mutator/package.json +++ b/packages/@sanity/mutator/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/mutator", - "version": "3.57.0", + "version": "3.57.4", "description": "A set of models to make it easier to utilize the powerful real time collaborative features of Sanity", "keywords": [ "sanity", @@ -50,7 +50,7 @@ }, "dependencies": { "@sanity/diff-match-patch": "^3.1.1", - "@sanity/types": "3.57.0", + "@sanity/types": "3.57.4", "@sanity/uuid": "^3.0.1", "debug": "^4.3.4", "lodash": "^4.17.21" diff --git a/packages/@sanity/mutator/src/document/Mutation.ts b/packages/@sanity/mutator/src/document/Mutation.ts index 0d04d187750..ad145532652 100644 --- a/packages/@sanity/mutator/src/document/Mutation.ts +++ b/packages/@sanity/mutator/src/document/Mutation.ts @@ -106,17 +106,21 @@ export class Mutation { compile(): void { const operations: ((doc: Doc | null) => Doc | null)[] = [] + // creation requires a _createdAt + const getGuaranteedCreatedAt = (doc: Doc): string => + doc?._createdAt || this.params.timestamp || new Date().toISOString() + this.mutations.forEach((mutation) => { if (mutation.create) { // TODO: Fail entire patch if document did exist - const create = mutation.create || {} + const create = (mutation.create || {}) as Doc operations.push((doc): Doc => { if (doc) { return doc } - return Object.assign(create as Doc, { - _createdAt: create._createdAt || this.params.timestamp, + return Object.assign(create, { + _createdAt: getGuaranteedCreatedAt(create), }) }) return @@ -127,7 +131,7 @@ export class Mutation { operations.push((doc) => doc === null ? Object.assign(createIfNotExists, { - _createdAt: createIfNotExists._createdAt || this.params.timestamp, + _createdAt: getGuaranteedCreatedAt(createIfNotExists), }) : doc, ) @@ -138,7 +142,7 @@ export class Mutation { const createOrReplace = mutation.createOrReplace || {} operations.push(() => Object.assign(createOrReplace, { - _createdAt: createOrReplace._createdAt || this.params.timestamp, + _createdAt: getGuaranteedCreatedAt(createOrReplace), }), ) return diff --git a/packages/@sanity/mutator/src/document/types.ts b/packages/@sanity/mutator/src/document/types.ts index acd147e4b7d..215cf2c67d2 100644 --- a/packages/@sanity/mutator/src/document/types.ts +++ b/packages/@sanity/mutator/src/document/types.ts @@ -16,6 +16,7 @@ export interface Doc { _type: string _rev?: string _updatedAt?: string + _createdAt?: string [attribute: string]: unknown } diff --git a/packages/@sanity/mutator/test/SquashingBuffer.test.ts b/packages/@sanity/mutator/test/SquashingBuffer.test.ts index ab5ee308a59..6544bddf769 100644 --- a/packages/@sanity/mutator/test/SquashingBuffer.test.ts +++ b/packages/@sanity/mutator/test/SquashingBuffer.test.ts @@ -1,4 +1,4 @@ -import {expect, test} from '@jest/globals' +import {expect, jest, test} from '@jest/globals' import {type PatchMutationOperation} from '@sanity/types' import {Mutation} from '../src/document/Mutation' @@ -109,7 +109,39 @@ test('de-duplicate createIfNotExists', () => { expect(tx2 && tx2.mutations.length).toBe(1) }) +test.each(['create', 'createIfNotExists', 'createOrReplace'])( + '%s defaults to current created at time', + (createFnc) => { + const globalMockDate = new Date('2020-01-01T12:34:55.000Z') + const globalDateSpy = jest.spyOn(global, 'Date').mockReturnValue(globalMockDate) + + const sb = new SquashingBuffer(null) + + add(sb, {[createFnc]: {_id: '1', _type: 'test', a: 'A string value'}}) + + const tx = sb.purge('txn_id') + if (!tx) { + throw new Error('buffer purge did not result in a mutation') + } + + const final = tx.apply(null) + + expect(final).toEqual({ + _id: '1', + _rev: 'txn_id', + _createdAt: '2020-01-01T12:34:55.000Z', + _type: 'test', + a: 'A string value', + }) + + globalDateSpy.mockRestore() + }, +) + test('de-duplicate create respects deletes', () => { + const globalMockDate = new Date('2020-01-01T12:34:55.000Z') + const globalDateSpy = jest.spyOn(global, 'Date').mockReturnValue(globalMockDate) + const initial = {_id: '1', _type: 'test', a: 'A string value', c: 'Some value'} const sb = new SquashingBuffer(initial) add(sb, {createIfNotExists: {_id: '1', _type: 'test', a: 'A string value', c: 'Some value'}}) @@ -124,7 +156,7 @@ test('de-duplicate create respects deletes', () => { if (!tx) { throw new Error('buffer purge did not result in a mutation') } - tx.params.timestamp = '2021-01-01T12:34:55Z' + tx.params.timestamp = '2021-01-01T12:34:55.000Z' const creates = tx.mutations.filter((mut) => !!mut.createIfNotExists) expect(creates.length).toBe(2) // Only a single create mutation expected (note: bn - is this correct?) @@ -134,14 +166,16 @@ test('de-duplicate create respects deletes', () => { expect(final).toEqual({ _id: '1', _type: 'test', - _createdAt: '2021-01-01T12:34:55Z', - _updatedAt: '2021-01-01T12:34:55Z', + _createdAt: '2020-01-01T12:34:55.000Z', + _updatedAt: '2021-01-01T12:34:55.000Z', _rev: 'txn_id', a: { b: 'A wrapped value', }, c: 'Changed', }) + + globalDateSpy.mockRestore() }) test('de-duplicate create respects rebasing', () => { diff --git a/packages/@sanity/schema/package.json b/packages/@sanity/schema/package.json index 7818ddb4aa0..8745f5f794f 100644 --- a/packages/@sanity/schema/package.json +++ b/packages/@sanity/schema/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/schema", - "version": "3.57.0", + "version": "3.57.4", "description": "", "keywords": [ "sanity", @@ -64,7 +64,7 @@ }, "dependencies": { "@sanity/generate-help-url": "^3.0.0", - "@sanity/types": "3.57.0", + "@sanity/types": "3.57.4", "arrify": "^1.0.1", "groq-js": "^1.13.0", "humanize-list": "^1.0.1", @@ -78,7 +78,7 @@ "@sanity/icons": "^3.4.0", "@types/arrify": "^1.0.4", "@types/object-inspect": "^1.13.0", - "@types/react": "^18.3.3", + "@types/react": "^18.3.5", "rimraf": "^3.0.2" } } diff --git a/packages/@sanity/types/package.json b/packages/@sanity/types/package.json index c7be70e50d9..2450cd67916 100644 --- a/packages/@sanity/types/package.json +++ b/packages/@sanity/types/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/types", - "version": "3.57.0", + "version": "3.57.4", "description": "Type definitions for common Sanity data structures", "keywords": [ "sanity", @@ -49,13 +49,13 @@ "watch": "pkg-utils watch" }, "dependencies": { - "@sanity/client": "^6.21.2", - "@types/react": "^18.3.3" + "@sanity/client": "^6.21.3", + "@types/react": "^18.3.5" }, "devDependencies": { "@jest/globals": "^29.7.0", "@repo/package.config": "workspace:*", - "@sanity/insert-menu": "1.0.8", + "@sanity/insert-menu": "1.0.9", "rimraf": "^3.0.2" } } diff --git a/packages/@sanity/util/package.json b/packages/@sanity/util/package.json index 57e75b74968..5a3c2672654 100644 --- a/packages/@sanity/util/package.json +++ b/packages/@sanity/util/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/util", - "version": "3.57.0", + "version": "3.57.4", "description": "Utilities shared across projects of Sanity", "keywords": [ "sanity", @@ -121,8 +121,8 @@ "watch": "pkg-utils watch" }, "dependencies": { - "@sanity/client": "^6.21.2", - "@sanity/types": "3.57.0", + "@sanity/client": "^6.21.3", + "@sanity/types": "3.57.4", "get-random-values-esm": "1.0.2", "moment": "^2.29.4", "rxjs": "^7.8.1" diff --git a/packages/@sanity/vision/package.json b/packages/@sanity/vision/package.json index dac096a9ded..43dd29c3a39 100644 --- a/packages/@sanity/vision/package.json +++ b/packages/@sanity/vision/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/vision", - "version": "3.57.0", + "version": "3.57.4", "description": "Sanity plugin for running/debugging GROQ-queries against Sanity datasets", "keywords": [ "sanity", @@ -63,7 +63,7 @@ "@rexxars/react-split-pane": "^0.1.93", "@sanity/color": "^3.0.0", "@sanity/icons": "^3.4.0", - "@sanity/ui": "^2.8.8", + "@sanity/ui": "^2.8.9", "@uiw/react-codemirror": "^4.11.4", "is-hotkey-esm": "^1.0.0", "json-2-csv": "^5.5.1", @@ -75,7 +75,7 @@ "@repo/package.config": "workspace:*", "@sanity/block-tools": "workspace:*", "@sanity/cli": "workspace:*", - "@sanity/client": "^6.21.2", + "@sanity/client": "^6.21.3", "@sanity/codegen": "workspace:*", "@sanity/diff": "workspace:*", "@sanity/migrate": "workspace:*", diff --git a/packages/create-sanity/package.json b/packages/create-sanity/package.json index c8aa9ce8c34..f7392caeb35 100644 --- a/packages/create-sanity/package.json +++ b/packages/create-sanity/package.json @@ -1,6 +1,6 @@ { "name": "create-sanity", - "version": "3.57.0", + "version": "3.57.4", "description": "Initialize a new Sanity project", "keywords": [ "sanity", @@ -26,7 +26,7 @@ "index.js" ], "dependencies": { - "@sanity/cli": "3.57.0", + "@sanity/cli": "3.57.4", "resolve-pkg": "^2.0.0" }, "engines": { diff --git a/packages/groq/package.json b/packages/groq/package.json index 54953281d5e..27a900ad7fa 100644 --- a/packages/groq/package.json +++ b/packages/groq/package.json @@ -1,6 +1,6 @@ { "name": "groq", - "version": "3.57.0", + "version": "3.57.4", "description": "Tagged template literal for Sanity.io GROQ-queries", "keywords": [ "sanity", diff --git a/packages/sanity/package.json b/packages/sanity/package.json index c2060c006d2..e8145a8812a 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -1,6 +1,6 @@ { "name": "sanity", - "version": "3.57.0", + "version": "3.57.4", "description": "Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches", "keywords": [ "sanity", @@ -153,38 +153,38 @@ "@dnd-kit/sortable": "^7.0.1", "@dnd-kit/utilities": "^3.2.0", "@juggle/resize-observer": "^3.3.1", - "@portabletext/editor": "^1.0.19", + "@portabletext/editor": "^1.1.1", "@portabletext/react": "^3.0.0", "@rexxars/react-json-inspector": "^8.0.1", "@sanity/asset-utils": "^1.2.5", "@sanity/bifur-client": "^0.4.1", - "@sanity/block-tools": "3.57.0", - "@sanity/cli": "3.57.0", - "@sanity/client": "^6.21.2", + "@sanity/block-tools": "3.57.4", + "@sanity/cli": "3.57.4", + "@sanity/client": "^6.21.3", "@sanity/color": "^3.0.0", - "@sanity/diff": "3.57.0", + "@sanity/diff": "3.57.4", "@sanity/diff-match-patch": "^3.1.1", "@sanity/eventsource": "^5.0.0", "@sanity/export": "^3.41.0", "@sanity/icons": "^3.4.0", "@sanity/image-url": "^1.0.2", "@sanity/import": "^3.37.3", - "@sanity/insert-menu": "1.0.8", + "@sanity/insert-menu": "1.0.9", "@sanity/logos": "^2.1.4", - "@sanity/migrate": "3.57.0", - "@sanity/mutator": "3.57.0", - "@sanity/presentation": "1.16.4", - "@sanity/schema": "3.57.0", + "@sanity/migrate": "3.57.4", + "@sanity/mutator": "3.57.4", + "@sanity/presentation": "1.16.5", + "@sanity/schema": "3.57.4", "@sanity/telemetry": "^0.7.7", - "@sanity/types": "3.57.0", - "@sanity/ui": "^2.8.8", - "@sanity/util": "3.57.0", + "@sanity/types": "3.57.4", + "@sanity/ui": "^2.8.9", + "@sanity/util": "3.57.4", "@sanity/uuid": "^3.0.1", "@sentry/react": "^8.7.0", "@tanstack/react-table": "^8.16.0", "@tanstack/react-virtual": "3.0.0-beta.54", "@types/react-copy-to-clipboard": "^5.0.2", - "@types/react-is": "^18.2.0", + "@types/react-is": "^18.3.0", "@types/shallow-equals": "^1.0.0", "@types/speakingurl": "^13.0.3", "@types/tar-stream": "^3.1.3", @@ -271,10 +271,10 @@ "@playwright/experimental-ct-react": "1.44.1", "@playwright/test": "1.44.1", "@repo/package.config": "workspace:*", - "@sanity/codegen": "3.57.0", + "@sanity/codegen": "3.57.4", "@sanity/generate-help-url": "^3.0.0", - "@sanity/pkg-utils": "6.11.0", - "@sanity/tsdoc": "1.0.90", + "@sanity/pkg-utils": "6.11.2", + "@sanity/tsdoc": "1.0.105", "@sanity/ui-workshop": "^1.2.11", "@sentry/types": "^8.12.0", "@testing-library/jest-dom": "^6.4.8", @@ -290,7 +290,7 @@ "@types/log-symbols": "^2.0.0", "@types/node": "^18.19.8", "@types/raf": "^3.4.0", - "@types/react": "^18.3.3", + "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@types/refractor": "^3.0.0", "@types/resolve-from": "^4.0.0", diff --git a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx index 60d134cfc03..742c063ac92 100644 --- a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx +++ b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx @@ -157,7 +157,8 @@ export function TestForm(props: TestFormProps) { validateStaticDocument(document, workspace, (result) => setValidation(result)) }, [document, workspace]) - const formState = useFormState(schemaType, { + const formState = useFormState({ + schemaType, focusPath, collapsedPaths, collapsedFieldSets, @@ -166,7 +167,7 @@ export function TestForm(props: TestFormProps) { openPath, presence: presenceFromProps, validation, - value: document, + documentValue: document, }) const formStateRef = useRef(formState) diff --git a/packages/sanity/src/_internal/cli/commands/build/buildCommand.ts b/packages/sanity/src/_internal/cli/commands/build/buildCommand.ts index 8c20ab0a84b..e45cf70dc96 100644 --- a/packages/sanity/src/_internal/cli/commands/build/buildCommand.ts +++ b/packages/sanity/src/_internal/cli/commands/build/buildCommand.ts @@ -4,6 +4,7 @@ import {BuildSanityStudioCommandFlags} from '../../actions/build/buildAction' const helpText = ` Options --source-maps Enable source maps for built bundles (increases size of bundle) + --auto-updates / --no-auto-updates Enable/disable auto updates of studio versions --no-minify Skip minifying built JavaScript (speeds up build, increases size of bundle) -y, --yes Unattended mode, answers "yes" to any "yes/no" prompt and otherwise uses defaults diff --git a/packages/sanity/src/_internal/cli/commands/deploy/deployCommand.ts b/packages/sanity/src/_internal/cli/commands/deploy/deployCommand.ts index 2b126289788..a4b8bc18c84 100644 --- a/packages/sanity/src/_internal/cli/commands/deploy/deployCommand.ts +++ b/packages/sanity/src/_internal/cli/commands/deploy/deployCommand.ts @@ -9,6 +9,7 @@ import {type DeployStudioActionFlags} from '../../actions/deploy/deployAction' const helpText = ` Options --source-maps Enable source maps for built bundles (increases size of bundle) + --auto-updates / --no-auto-updates Enable/disable auto updates of studio versions --no-minify Skip minifying built JavaScript (speeds up build, increases size of bundle) --no-build Don't build the studio prior to deploy, instead deploying the version currently in \`dist/\` diff --git a/packages/sanity/src/core/form/inputs/PortableText/Editor.styles.tsx b/packages/sanity/src/core/form/inputs/PortableText/Editor.styles.tsx index ccf1d036b2c..458399b72ed 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/Editor.styles.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/Editor.styles.tsx @@ -95,7 +95,6 @@ export const EditableWrapper = styled(Card)<{$isFullscreen: boolean; $readOnly?: & > .pt-list-item-bullet + .pt-list-item-number, & > .pt-list-item-number + .pt-list-item-bullet { margin-top: ${({theme}) => theme.sanity.space[3]}px; - counter-reset: ${TEXT_LEVELS.map((l) => createListName(l)).join(' ')}; } & > :not(.pt-list-item) + .pt-list-item { @@ -107,13 +106,12 @@ export const EditableWrapper = styled(Card)<{$isFullscreen: boolean; $readOnly?: counter-set: ${TEXT_LEVELS.map((l) => createListName(l)).join(' ')}; } - ${TEXT_LEVELS.slice(1).map((l) => { - return css` - & > .pt-list-item-level-${l} + .pt-list-item-level-${l - 1} { - counter-reset: ${createListName(l)}; - } - ` - })} + /* Reset the list count all the sub-list items */ + & > .pt-list-item-number.pt-list-item-level-${TEXT_LEVELS[0]} { + counter-set: ${TEXT_LEVELS.slice(1) + .map((l) => createListName(l)) + .join(' ')}; + } & > .pt-list-item + :not(.pt-list-item) { margin-top: ${({theme}) => theme.sanity.space[3]}px; diff --git a/packages/sanity/src/core/form/inputs/arrays/ArrayOfObjectsInput/Grid/GridArrayInput.tsx b/packages/sanity/src/core/form/inputs/arrays/ArrayOfObjectsInput/Grid/GridArrayInput.tsx index eed8da9f254..c1c68ece4ee 100644 --- a/packages/sanity/src/core/form/inputs/arrays/ArrayOfObjectsInput/Grid/GridArrayInput.tsx +++ b/packages/sanity/src/core/form/inputs/arrays/ArrayOfObjectsInput/Grid/GridArrayInput.tsx @@ -24,7 +24,8 @@ export function GridArrayInput(props: ArrayOfObjectsInp elementProps, members, onChange, - onInsert, + onItemPrepend, + onItemAppend, onItemMove, onUpload, readOnly, @@ -40,20 +41,6 @@ export function GridArrayInput(props: ArrayOfObjectsInp } = props const {t} = useTranslation() - const handlePrepend = useCallback( - (item: Item) => { - onInsert({items: [item], position: 'before', referenceItem: 0}) - }, - [onInsert], - ) - - const handleAppend = useCallback( - (item: Item) => { - onInsert({items: [item], position: 'after', referenceItem: -1}) - }, - [onInsert], - ) - const sortable = schemaType.options?.sortable !== false const renderItem = useCallback(({key, ...itemProps}: Omit) => { @@ -118,8 +105,8 @@ export function GridArrayInput(props: ArrayOfObjectsInp (props: ArrayOfObjectsInp onUpload, focusPath, readOnly, + onItemAppend, + onItemPrepend, renderAnnotation, renderBlock, renderField, @@ -52,20 +54,6 @@ export function ListArrayInput(props: ArrayOfObjectsInp const [activeDragItemIndex, setActiveDragItemIndex] = useState(null) const {space} = useTheme().sanity - const handlePrepend = useCallback( - (item: Item) => { - onInsert({items: [item], position: 'before', referenceItem: 0}) - }, - [onInsert], - ) - - const handleAppend = useCallback( - (item: Item) => { - onInsert({items: [item], position: 'after', referenceItem: -1}) - }, - [onInsert], - ) - const memberKeys = useMemoCompare( useMemo(() => members.map((member) => member.key), [members]), shallowEquals, @@ -277,8 +265,8 @@ export function ListArrayInput(props: ArrayOfObjectsInp member.uploader) as ResolvedUploader[] } -export function uploadTarget(Component: ComponentType) { + +export function uploadTarget( + Component: ComponentType, +): ForwardRefExoticComponent< + PropsWithoutRef & RefAttributes +> { const FileTarget = fileTarget(Component) + // @ts-expect-error TODO fix PropsWithoutRef related union typings return forwardRef(function UploadTarget( props: UploadTargetProps & Props, forwardedRef: ForwardedRef, diff --git a/packages/sanity/src/core/form/inputs/common/fileTarget/fileTarget.tsx b/packages/sanity/src/core/form/inputs/common/fileTarget/fileTarget.tsx index bd8e69d3377..352cac1ecd4 100644 --- a/packages/sanity/src/core/form/inputs/common/fileTarget/fileTarget.tsx +++ b/packages/sanity/src/core/form/inputs/common/fileTarget/fileTarget.tsx @@ -4,7 +4,10 @@ import { type DragEvent, type ForwardedRef, forwardRef, + type ForwardRefExoticComponent, type KeyboardEvent, + type PropsWithoutRef, + type RefAttributes, useCallback, useEffect, useImperativeHandle, @@ -68,13 +71,17 @@ const PASTE_INPUT_STYLE = {opacity: 0, position: 'absolute'} as const * Higher order component that creates a file target from a given component. * Returns a component that acts both as a drop target and a paste target, emitting a list of Files upon drop or paste */ -export function fileTarget(Component: ComponentType) { +export function fileTarget( + Component: ComponentType, +): ForwardRefExoticComponent< + PropsWithoutRef & Props> & RefAttributes +> { + // @ts-expect-error TODO fix PropsWithoutRef related union typings return forwardRef(function FileTarget( props: Omit & Props, forwardedRef: ForwardedRef, ) { const {onFiles, onFilesOver, onFilesOut, disabled, ...rest} = props - const [showPasteInput, setShowPasteInput] = useState(false) const pasteInput = useRef(null) diff --git a/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx b/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx index bd2d32293a7..6e68f79e074 100644 --- a/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx +++ b/packages/sanity/src/core/form/members/object/fields/ArrayOfObjectsField.tsx @@ -262,17 +262,26 @@ export function ArrayOfObjectsField(props: { [handleChange, member.field.value], ) - const handlePrependItem = useCallback( - (item: any) => { - handleChange([setIfMissing([]), insert([ensureKey(item)], 'before', [0])]) + const handleItemPrepend = useCallback( + (item: ObjectItem) => { + handleInsert({ + items: [item], + position: 'before', + referenceItem: 0, + }) }, - [handleChange], + [handleInsert], ) - const handleAppendItem = useCallback( - (item: any) => { - handleChange([setIfMissing([]), insert([ensureKey(item)], 'after', [-1])]) + + const handleItemAppend = useCallback( + (item: ObjectItem) => { + handleInsert({ + items: [item], + position: 'after', + referenceItem: -1, + }) }, - [handleChange], + [handleInsert], ) const handleRemoveItem = useCallback( @@ -379,8 +388,8 @@ export function ArrayOfObjectsField(props: { onInsert: handleInsert, onItemMove: handleMoveItem, onItemRemove: handleRemoveItem, - onItemAppend: handleAppendItem, - onItemPrepend: handlePrependItem, + onItemAppend: handleItemAppend, + onItemPrepend: handleItemPrepend, onPathFocus: handleFocusChildPath, resolveInitialValue, onUpload: handleUpload, @@ -417,8 +426,8 @@ export function ArrayOfObjectsField(props: { handleInsert, handleMoveItem, handleRemoveItem, - handleAppendItem, - handlePrependItem, + handleItemAppend, + handleItemPrepend, handleFocusChildPath, resolveInitialValue, handleUpload, diff --git a/packages/sanity/src/core/form/store/__tests__/collapsible.test.ts b/packages/sanity/src/core/form/store/__tests__/collapsible.test.ts index 2d96e78ffac..4dceb7e077b 100644 --- a/packages/sanity/src/core/form/store/__tests__/collapsible.test.ts +++ b/packages/sanity/src/core/form/store/__tests__/collapsible.test.ts @@ -1,9 +1,9 @@ -import {describe, expect, it, test} from '@jest/globals' +import {beforeEach, describe, expect, it, test} from '@jest/globals' import {Schema} from '@sanity/schema' import {type ObjectSchemaType, type Path} from '@sanity/types' import {pathToString} from '../../../field' -import {prepareFormState} from '../formState' +import {createPrepareFormState, type PrepareFormState} from '../formState' import {type FieldMember, type ObjectFormNode} from '../types' import {isObjectFormNode} from '../types/asserters' import {DEFAULT_PROPS} from './shared' @@ -81,6 +81,12 @@ function getBookType(fieldOptions: { }).get('book') } +let prepareFormState!: PrepareFormState + +beforeEach(() => { + prepareFormState = createPrepareFormState() +}) + test("doesn't make primitive fields collapsed even if they are configured to be", () => { // Note: the schema validation should possibly enforce this // Note2: We might want to support making all kinds of fields collapsible, even primitive fields @@ -93,7 +99,7 @@ test("doesn't make primitive fields collapsed even if they are configured to be" const result = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + documentValue: {_id: 'foo', _type: 'book'}, }) expect(result).not.toBe(null) @@ -121,7 +127,7 @@ describe('collapsible object fields', () => { const result = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + documentValue: {_id: 'foo', _type: 'book'}, }) expect(result).not.toBe(null) @@ -141,7 +147,7 @@ describe('collapsible object fields', () => { const result = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + documentValue: {_id: 'foo', _type: 'book'}, }) expect(result).not.toBe(null) @@ -160,7 +166,7 @@ describe('collapsible object fields', () => { const result = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + value: {_id: 'foo', _type: 'book'}, }) expect(result).not.toBe(null) @@ -184,7 +190,7 @@ describe('collapsible object fields', () => { const result = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + documentValue: {_id: 'foo', _type: 'book'}, }) expect(result).not.toBe(null) diff --git a/packages/sanity/src/core/form/store/__tests__/equality.test.ts b/packages/sanity/src/core/form/store/__tests__/equality.test.ts index bfb227f4d4a..b458360c2c3 100644 --- a/packages/sanity/src/core/form/store/__tests__/equality.test.ts +++ b/packages/sanity/src/core/form/store/__tests__/equality.test.ts @@ -1,8 +1,8 @@ -import {expect, test} from '@jest/globals' +import {beforeEach, expect, test} from '@jest/globals' import {Schema} from '@sanity/schema' import {type ConditionalProperty} from '@sanity/types' -import {prepareFormState} from '../formState' +import {createPrepareFormState, type PrepareFormState} from '../formState' import {DEFAULT_PROPS} from './shared' function getBookType(properties: { @@ -73,20 +73,26 @@ function getBookType(properties: { }).get('book') } +let prepareFormState!: PrepareFormState + +beforeEach(() => { + prepareFormState = createPrepareFormState() +}) + test('it doesnt return new object equalities given the same input', () => { - const document = {_id: 'test', _type: 'foo'} + const documentValue = {_id: 'test', _type: 'foo'} const bookType = getBookType({}) const state1 = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document, + documentValue, }) const state2 = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document, + documentValue, }) expect(state1).not.toBe(null) expect(state2).not.toBe(null) diff --git a/packages/sanity/src/core/form/store/__tests__/formState.test.ts b/packages/sanity/src/core/form/store/__tests__/formState.test.ts new file mode 100644 index 00000000000..b902457a10c --- /dev/null +++ b/packages/sanity/src/core/form/store/__tests__/formState.test.ts @@ -0,0 +1,761 @@ +import {beforeEach, describe, expect, jest, test} from '@jest/globals' +import { + type CurrentUser, + defineField, + defineType, + isIndexTuple, + isKeySegment, + type ObjectSchemaType, + type Path, +} from '@sanity/types' +import {startsWith, toString} from '@sanity/util/paths' + +import {createSchema} from '../../../schema/createSchema' +import { + createPrepareFormState, + type PrepareFormState, + type RootFormStateOptions, +} from '../formState' +import {type FieldsetState} from '../types/fieldsetState' +import { + type ArrayOfObjectsItemMember, + type ArrayOfPrimitivesItemMember, + type FieldMember, +} from '../types/members' +import { + type ArrayOfObjectsFormNode, + type ArrayOfPrimitivesFormNode, + type ObjectFormNode, + type PrimitiveFormNode, +} from '../types/nodes' +import {type StateTree} from '../types/state' + +let prepareFormState!: PrepareFormState + +type RemoveFirstChar = S extends `${infer _}${infer R}` ? R : S + +beforeEach(() => { + prepareFormState = createPrepareFormState({ + decorators: { + prepareArrayOfObjectsInputState: jest.fn, + prepareArrayOfObjectsMember: jest.fn, + prepareArrayOfPrimitivesInputState: jest.fn, + prepareArrayOfPrimitivesMember: jest.fn, + prepareFieldMember: jest.fn, + prepareObjectInputState: jest.fn, + preparePrimitiveInputState: jest.fn, + }, + }) +}) +const schema = createSchema({ + name: 'default', + types: [ + defineType({ + name: 'testDocument', + type: 'document', + groups: [ + {name: 'groupA', title: 'Group A'}, + {name: 'groupB', title: 'Group B'}, + ], + fields: [ + defineField({ + name: 'title', + type: 'string', + validation: (Rule) => Rule.required(), + group: 'groupA', + }), + defineField({ + name: 'simpleObject', + type: 'object', + group: 'groupA', + fields: [ + {name: 'field1', type: 'string'}, + {name: 'field2', type: 'number'}, + ], + }), + defineField({ + name: 'arrayOfPrimitives', + type: 'array', + of: [{type: 'string'}], + group: 'groupB', + }), + defineField({ + name: 'arrayOfObjects', + type: 'array', + of: [ + { + type: 'object', + name: 'arrayObject', + fields: [ + defineField({name: 'objectTitle', type: 'string'}), + defineField({name: 'objectValue', type: 'number'}), + ], + }, + ], + group: 'groupB', + }), + defineField({ + name: 'nestedObject', + type: 'object', + group: 'groupA', + fields: [ + defineField({name: 'nestedField1', type: 'string'}), + defineField({ + name: 'nestedObject', + type: 'object', + fields: [ + defineField({ + name: 'deeplyNestedField', + type: 'string', + }), + ], + }), + defineField({name: 'nestedArray', type: 'array', of: [{type: 'string'}]}), + ], + }), + defineField({ + name: 'conditionalField', + type: 'string', + hidden: ({document}) => !document?.title, + }), + defineField({ + name: 'fieldsetField1', + type: 'string', + fieldset: 'testFieldset', + group: 'groupB', + }), + defineField({ + name: 'fieldsetField2', + type: 'number', + fieldset: 'testFieldset', + group: 'groupB', + }), + ], + fieldsets: [ + { + name: 'testFieldset', + options: {collapsible: true, collapsed: false}, + }, + ], + }), + ], +}) + +const schemaType = schema.get('testDocument') as ObjectSchemaType + +const currentUser: Omit = { + email: 'rico@sanity.io', + id: 'exampleId', + name: 'Rico Kahler', + roles: [], +} + +const documentValue = { + _type: 'testDocument', + title: 'Example Test Document', + simpleObject: { + field1: 'Simple Object String', + field2: 42, + }, + arrayOfPrimitives: ['First string', 'Second string', 'Third string'], + arrayOfObjects: [ + { + _type: 'arrayObject', + _key: 'object0', + objectTitle: 'First Object', + objectValue: 10, + }, + { + _type: 'arrayObject', + _key: 'object1', + objectTitle: 'Second Object', + objectValue: 20, + }, + ], + nestedObject: { + nestedField1: 'Nested Field Value', + nestedObject: { + deeplyNestedField: 'Deeply Nested Value', + }, + nestedArray: ['Nested Array Item 1', 'Nested Array Item 2'], + }, + conditionalField: 'This field is visible', + fieldsetField1: 'Fieldset String Value', + fieldsetField2: 99, +} + +function setAtPath(path: Path): StateTree { + const [first, ...rest] = path + if (typeof first === 'undefined') { + return {value: true} + } + + if (isIndexTuple(first)) return {} + + const key = typeof first === 'object' && '_key' in first ? first._key : first + + return { + children: { + [key]: setAtPath(rest), + }, + } +} + +function updateDocumentAtPath(path: Path, value: any): unknown { + const [first, ...rest] = path + if (isIndexTuple(first)) throw new Error('Unexpected index tuple') + + if (typeof first === 'undefined') return 'CHANGED' + if (typeof first === 'string') { + return {...value, [first]: updateDocumentAtPath(rest, value?.[first])} + } + + if (Array.isArray(value)) { + const index = isKeySegment(first) ? value.findIndex((item) => item?._key === first._key) : first + + return [ + ...value.slice(0, index), + updateDocumentAtPath(rest, value[index]), + ...value.slice(index + 1), + ] + } + + return updateDocumentAtPath(rest, []) +} + +type FormNode = + | ObjectFormNode + | ArrayOfObjectsFormNode + | ArrayOfPrimitivesFormNode + | PrimitiveFormNode + +type FormTraversalResult = [ + FormNode, + { + member?: FieldMember | ArrayOfObjectsItemMember | ArrayOfPrimitivesItemMember + fieldset?: FieldsetState + }, +] + +function* traverseForm( + formNode: FormNode | null, + parent?: FormTraversalResult[1], +): Generator { + if (!formNode) return + + yield [formNode, parent ?? {}] + + if (!('members' in formNode)) return + + for (const member of formNode.members) { + switch (member.kind) { + case 'field': { + yield* traverseForm(member.field as FormNode, {member}) + continue + } + case 'fieldSet': { + for (const fieldsetMember of member.fieldSet.members) { + if (fieldsetMember.kind === 'error') continue + yield* traverseForm(fieldsetMember.field as FormNode, { + member: fieldsetMember, + fieldset: member.fieldSet, + }) + } + continue + } + case 'item': { + yield* traverseForm(member.item as FormNode, {member}) + continue + } + default: { + continue + } + } + } +} + +const rootFormNodeOptions: Partial<{ + [K in keyof RootFormStateOptions]: { + deriveInput: (path: Path) => RootFormStateOptions[K] + assertOutput: (node: FormTraversalResult) => void + } +}> = { + focusPath: { + deriveInput: (path) => path, + assertOutput: ([node]) => expect(node.focused).toBe(true), + }, + openPath: { + deriveInput: (path) => path, + assertOutput: ([_node, {member}]) => expect(member?.open).toBe(true), + }, + validation: { + deriveInput: (path) => [{path, level: 'error', message: 'example marker'}], + assertOutput: ([node]) => + expect(node.validation).toEqual([ + {path: node.path, level: 'error', message: 'example marker'}, + ]), + }, + presence: { + deriveInput: (path) => [ + { + path, + lastActiveAt: '2024-09-12T21:59:08.362Z', + sessionId: 'exampleSession', + user: {id: 'exampleUser'}, + }, + ], + assertOutput: ([node]) => + expect(node.presence).toEqual([ + { + path: node.path, + lastActiveAt: '2024-09-12T21:59:08.362Z', + sessionId: 'exampleSession', + user: {id: 'exampleUser'}, + }, + ]), + }, + documentValue: { + deriveInput: (path) => updateDocumentAtPath(path, documentValue), + assertOutput: ([node]) => expect(node.value).toBe('CHANGED'), + }, + comparisonValue: { + deriveInput: (path) => updateDocumentAtPath(path, documentValue), + assertOutput: ([node]) => expect(node.changed).toBe(true), + }, + readOnly: { + deriveInput: (path) => setAtPath(path), + assertOutput: ([node]) => expect(node.readOnly).toBe(true), + }, +} + +const paths: { + path: Path + expectedCalls: {[K in RemoveFirstChar]: number} +}[] = [ + { + path: ['title'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 8, + prepareObjectInputState: 1, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['simpleObject', 'field1'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['arrayOfPrimitives', 1], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 1, + prepareArrayOfPrimitivesMember: 3, + prepareFieldMember: 8, + prepareObjectInputState: 1, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['arrayOfObjects', {_key: 'object1'}, 'objectTitle'], + expectedCalls: { + prepareArrayOfObjectsInputState: 1, + prepareArrayOfObjectsMember: 2, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['nestedObject', 'nestedField1'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 11, + prepareObjectInputState: 2, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['nestedObject', 'nestedObject', 'deeplyNestedField'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 12, + prepareObjectInputState: 3, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['nestedObject', 'nestedArray', 0], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 1, + prepareArrayOfPrimitivesMember: 2, + prepareFieldMember: 11, + prepareObjectInputState: 2, + preparePrimitiveInputState: 1, + }, + }, +] + +const defaultOptions: RootFormStateOptions = { + currentUser, + focusPath: [], + openPath: [], + presence: [], + schemaType, + validation: [], + changesOpen: false, + collapsedFieldSets: {}, + collapsedPaths: {}, + documentValue, + comparisonValue: documentValue, + fieldGroupState: {}, + hidden: undefined, + readOnly: undefined, +} + +describe.each( + Object.entries(rootFormNodeOptions).map(([property, {deriveInput, assertOutput}]) => ({ + property, + deriveInput, + assertOutput, + })), +)('$property', ({property, deriveInput, assertOutput}) => { + test.each(paths)('$path', ({path, expectedCalls}) => { + const initialFormState = prepareFormState(defaultOptions) + const initialNodes = new Set(Array.from(traverseForm(initialFormState)).map(([node]) => node)) + + // reset toHaveBeenCalledTimes amount + jest.clearAllMocks() + + const updatedFormState = prepareFormState({ + ...defaultOptions, + ...{[property]: deriveInput(path)}, + }) + const updatedNodes = Array.from(traverseForm(updatedFormState)).reverse() + + const differentNodes = updatedNodes.filter(([node]) => !initialNodes.has(node)) + expect(differentNodes).not.toHaveLength(0) + + assertOutput(differentNodes[0]) + + for (const [differentNode] of differentNodes) { + expect(startsWith(differentNode.path, path)).toBe(true) + } + + expect(prepareFormState._prepareArrayOfObjectsInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsInputState, + ) + expect(prepareFormState._prepareArrayOfObjectsMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsMember, + ) + expect(prepareFormState._prepareArrayOfPrimitivesInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesInputState, + ) + expect(prepareFormState._prepareArrayOfPrimitivesMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesMember, + ) + expect(prepareFormState._prepareFieldMember).toHaveBeenCalledTimes( + expectedCalls.prepareFieldMember, + ) + expect(prepareFormState._prepareObjectInputState).toHaveBeenCalledTimes( + expectedCalls.prepareObjectInputState, + ) + expect(prepareFormState._preparePrimitiveInputState).toHaveBeenCalledTimes( + expectedCalls.preparePrimitiveInputState, + ) + }) +}) + +describe('hidden', () => { + const pathsToTest: { + path: Path + expectedCalls: {[K in RemoveFirstChar]: number} + }[] = [ + { + path: ['title'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 8, + prepareObjectInputState: 1, + preparePrimitiveInputState: 0, + }, + }, + { + path: ['simpleObject', 'field1'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 0, + }, + }, + { + path: ['arrayOfPrimitives'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 1, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 8, + prepareObjectInputState: 1, + preparePrimitiveInputState: 0, + }, + }, + { + path: ['arrayOfObjects', {_key: 'object1'}, 'objectTitle'], + expectedCalls: { + prepareArrayOfObjectsInputState: 1, + prepareArrayOfObjectsMember: 2, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 0, + }, + }, + ] + + test.each(pathsToTest)('$path', ({path, expectedCalls}) => { + const hidden = setAtPath(path) + + const initialFormState = prepareFormState(defaultOptions) + const initialNodes = new Set(Array.from(traverseForm(initialFormState)).map(([node]) => node)) + + // reset toHaveBeenCalledTimes amount + jest.clearAllMocks() + + const updatedFormState = prepareFormState({ + ...defaultOptions, + hidden, + }) + const updatedNodes = Array.from(traverseForm(updatedFormState)).reverse() + const differentNodes = updatedNodes.filter(([node]) => !initialNodes.has(node)) + + expect(differentNodes).not.toHaveLength(0) + for (const [differentNode] of differentNodes) { + expect(startsWith(differentNode.path, path)).toBe(true) + } + + // Verify memoization: functions should be called only for affected nodes + expect(prepareFormState._prepareArrayOfObjectsInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsInputState, + ) + expect(prepareFormState._prepareArrayOfObjectsMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsMember, + ) + expect(prepareFormState._prepareArrayOfPrimitivesInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesInputState, + ) + expect(prepareFormState._prepareArrayOfPrimitivesMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesMember, + ) + expect(prepareFormState._prepareFieldMember).toHaveBeenCalledTimes( + expectedCalls.prepareFieldMember, + ) + expect(prepareFormState._prepareObjectInputState).toHaveBeenCalledTimes( + expectedCalls.prepareObjectInputState, + ) + expect(prepareFormState._preparePrimitiveInputState).toHaveBeenCalledTimes( + expectedCalls.preparePrimitiveInputState, + ) + }) +}) + +describe('collapsedPaths', () => { + const pathsToTest: { + path: Path + expectedCalls: {[K in RemoveFirstChar]: number} + }[] = [ + { + path: ['simpleObject'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 0, + }, + }, + { + path: ['arrayOfObjects', {_key: 'object1'}], + expectedCalls: { + prepareArrayOfObjectsInputState: 1, + prepareArrayOfObjectsMember: 2, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 0, + }, + }, + { + path: ['nestedObject', 'nestedObject'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 12, + prepareObjectInputState: 3, + preparePrimitiveInputState: 0, + }, + }, + ] + + test.each(pathsToTest)('$path', ({path, expectedCalls}) => { + const collapsedPaths = setAtPath(path) + + // Prepare initial form state + prepareFormState(defaultOptions) + + // reset toHaveBeenCalledTimes amount + jest.clearAllMocks() + + // Prepare updated form state with collapsedPaths set + const updatedFormState = prepareFormState({ + ...defaultOptions, + collapsedPaths, + }) + + // Traverse updated form state + const updatedNodes = Array.from(traverseForm(updatedFormState)) + + // Find the member at the path + const memberAtPath = updatedNodes.find( + ([node, {member}]) => toString(node.path) === toString(path) && member !== undefined, + ) + + expect(memberAtPath).toBeDefined() + const member = memberAtPath![1].member + expect(member && 'collapsed' in member && member.collapsed).toBe(true) + + // Verify memoization: functions should be called only for affected nodes + expect(prepareFormState._prepareArrayOfObjectsInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsInputState, + ) + expect(prepareFormState._prepareArrayOfObjectsMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsMember, + ) + expect(prepareFormState._prepareArrayOfPrimitivesInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesInputState, + ) + expect(prepareFormState._prepareArrayOfPrimitivesMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesMember, + ) + expect(prepareFormState._prepareFieldMember).toHaveBeenCalledTimes( + expectedCalls.prepareFieldMember, + ) + expect(prepareFormState._prepareObjectInputState).toHaveBeenCalledTimes( + expectedCalls.prepareObjectInputState, + ) + expect(prepareFormState._preparePrimitiveInputState).toHaveBeenCalledTimes( + expectedCalls.preparePrimitiveInputState, + ) + }) +}) + +describe('collapsedFieldSets', () => { + test('collapsedFieldSets', () => { + const fieldsetName = 'testFieldset' + const path = [fieldsetName] // Use the fieldset name directly + const collapsedFieldSets = setAtPath(path) + + // Prepare initial form state + prepareFormState(defaultOptions) + + jest.clearAllMocks() + + // Prepare updated form state with collapsedFieldSets set + const updatedFormState = prepareFormState({ + ...defaultOptions, + collapsedFieldSets, + }) + + // Traverse updated form state + const updatedNodes = Array.from(traverseForm(updatedFormState)) + + // Find the fieldset member + const fieldsetNode = updatedNodes.find( + ([_node, {fieldset}]) => fieldset?.name === fieldsetName, + )! + + const [, {fieldset}] = fieldsetNode + + expect(fieldset?.collapsed).toBe(true) + + // Verify memoization: functions should be called only for affected nodes + expect(prepareFormState._prepareArrayOfObjectsInputState).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareArrayOfObjectsMember).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareArrayOfPrimitivesInputState).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareArrayOfPrimitivesMember).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareFieldMember).toHaveBeenCalledTimes(8) + expect(prepareFormState._prepareObjectInputState).toHaveBeenCalledTimes(1) + expect(prepareFormState._preparePrimitiveInputState).toHaveBeenCalledTimes(0) + }) +}) + +describe('fieldGroupState', () => { + test('fieldGroupState', () => { + const initialFormState = prepareFormState(defaultOptions) + const initialNodes = new Set(Array.from(traverseForm(initialFormState)).map(([node]) => node)) + + // Reset call counts + jest.clearAllMocks() + + const updatedFormState = prepareFormState({ + ...defaultOptions, + fieldGroupState: {value: 'groupA'}, + }) + + const updatedNodes = Array.from(traverseForm(updatedFormState)).reverse() + expect(updatedNodes.length).toBeGreaterThan(1) + const differentNodes = updatedNodes.filter(([node]) => !initialNodes.has(node)) + expect(differentNodes.length).toBeGreaterThan(0) + expect(differentNodes.length).toBeLessThan(updatedNodes.length) + + expect(updatedFormState?.members.map((i) => i.key)).toEqual([ + 'field-title', + 'field-simpleObject', + 'field-nestedObject', + ]) + + // Verify memoization: functions should be called only for affected nodes + expect(prepareFormState._prepareArrayOfObjectsInputState).toHaveBeenCalledTimes(1) + expect(prepareFormState._prepareArrayOfObjectsMember).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareArrayOfPrimitivesInputState).toHaveBeenCalledTimes(1) + expect(prepareFormState._prepareArrayOfPrimitivesMember).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareFieldMember).toHaveBeenCalledTimes(8) + expect(prepareFormState._prepareObjectInputState).toHaveBeenCalledTimes(3) + expect(prepareFormState._preparePrimitiveInputState).toHaveBeenCalledTimes(4) + }) +}) diff --git a/packages/sanity/src/core/form/store/__tests__/members.hidden.test.ts b/packages/sanity/src/core/form/store/__tests__/members.hidden.test.ts index cf10a6568d7..deac7747412 100644 --- a/packages/sanity/src/core/form/store/__tests__/members.hidden.test.ts +++ b/packages/sanity/src/core/form/store/__tests__/members.hidden.test.ts @@ -1,12 +1,13 @@ -import {expect, test} from '@jest/globals' +import {beforeEach, expect, test} from '@jest/globals' import {Schema} from '@sanity/schema' import {type ConditionalProperty, type ObjectSchemaType} from '@sanity/types' -import {prepareFormState} from '../formState' -import {DEFAULT_PROPS} from './shared' - -// eslint-disable-next-line no-empty-function,@typescript-eslint/no-empty-function -const noop = () => {} +import { + createCallbackResolver, + type RootCallbackResolver, +} from '../conditional-property/createCallbackResolver' +import {createPrepareFormState, type PrepareFormState} from '../formState' +import {DEFAULT_PROPS, MOCK_USER} from './shared' function getBookType(properties: { root?: {hidden?: ConditionalProperty; readOnly?: ConditionalProperty} @@ -76,14 +77,26 @@ function getBookType(properties: { }).get('book') } +let prepareFormState!: PrepareFormState +let prepareHiddenState!: RootCallbackResolver<'hidden'> + +beforeEach(() => { + prepareFormState = createPrepareFormState() + prepareHiddenState = createCallbackResolver({property: 'hidden'}) +}) + test('it omits the hidden member field from the members array', () => { - const bookType: ObjectSchemaType = getBookType({ + const schemaType: ObjectSchemaType = getBookType({ subtitle: {hidden: () => true}, }) + + const documentValue = {_id: 'foo', _type: 'book'} const result = prepareFormState({ ...DEFAULT_PROPS, - schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + hidden: prepareHiddenState({currentUser: MOCK_USER, documentValue, schemaType}), + schemaType, + documentValue, + comparisonValue: documentValue, }) expect(result).not.toBe(null) @@ -95,13 +108,15 @@ test('it omits the hidden member field from the members array', () => { }) test('it omits nested hidden members from the members array', () => { - const bookType = getBookType({ + const schemaType = getBookType({ author: {hidden: () => true}, }) + const documentValue = {_id: 'foo', _type: 'book'} const result = prepareFormState({ ...DEFAULT_PROPS, - schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + schemaType: schemaType, + hidden: prepareHiddenState({currentUser: MOCK_USER, documentValue: documentValue, schemaType}), + documentValue, }) expect(result).not.toBe(null) @@ -114,14 +129,16 @@ test('it omits nested hidden members from the members array', () => { test('it "upward propagates" hidden fields', () => { // If the hidden callback for every field of an object type returns true, the whole object should be hidden - const bookType = getBookType({ + const schemaType = getBookType({ authorFirstName: {hidden: () => true}, authorLastName: {hidden: () => true}, }) + const document = {_id: 'foo', _type: 'book'} const result = prepareFormState({ - schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, ...DEFAULT_PROPS, + schemaType, + value: document, + hidden: prepareHiddenState({currentUser: MOCK_USER, documentValue: document, schemaType}), }) expect(result).not.toBe(null) if (result === null) { diff --git a/packages/sanity/src/core/form/store/__tests__/shared.ts b/packages/sanity/src/core/form/store/__tests__/shared.ts index 237e38210f3..9489c37bf93 100644 --- a/packages/sanity/src/core/form/store/__tests__/shared.ts +++ b/packages/sanity/src/core/form/store/__tests__/shared.ts @@ -1,21 +1,14 @@ -// eslint-disable-next-line no-empty-function,@typescript-eslint/no-empty-function -const noop = () => {} - export const MOCK_USER = {id: 'bjoerge', email: 'bjoerge@gmail.com', name: 'Bjørge', roles: []} export const DEFAULT_PROPS = { validation: [], presence: [], focusPath: [], - path: [], - hidden: false, - readOnly: false, currentUser: MOCK_USER, openPath: [], - onSetCollapsedField: noop, - onSetCollapsedFieldSet: noop, - onSetActiveFieldGroupAtPath: noop, - onChange: noop, - onBlur: noop, - onFocus: noop, - level: 0, + comparisonValue: undefined, + hidden: undefined, + readOnly: undefined, + fieldGroupState: undefined, + collapsedPaths: undefined, + collapsedFieldSets: undefined, } diff --git a/packages/sanity/src/core/form/store/conditional-property/createCallbackResolver.ts b/packages/sanity/src/core/form/store/conditional-property/createCallbackResolver.ts new file mode 100644 index 00000000000..7e1e4de5fae --- /dev/null +++ b/packages/sanity/src/core/form/store/conditional-property/createCallbackResolver.ts @@ -0,0 +1,197 @@ +import {type CurrentUser, isKeyedObject, type SchemaType} from '@sanity/types' + +import {EMPTY_ARRAY} from '../../../util/empty' +import {MAX_FIELD_DEPTH} from '../constants' +import {type StateTree} from '../types/state' +import {getId} from '../utils/getId' +import {getItemType} from '../utils/getItemType' +import {immutableReconcile} from '../utils/immutableReconcile' +import { + type ConditionalPropertyCallbackContext, + resolveConditionalProperty, +} from './resolveConditionalProperty' + +interface ResolveCallbackStateOptions { + property: 'readOnly' | 'hidden' + value: unknown + parent: unknown + document: unknown + currentUser: Omit | null + schemaType: SchemaType + level: number +} + +function resolveCallbackState({ + value, + parent, + document, + currentUser, + schemaType, + level, + property, +}: ResolveCallbackStateOptions): StateTree | undefined { + const context: ConditionalPropertyCallbackContext = { + value, + parent, + document: document as ConditionalPropertyCallbackContext['document'], + currentUser, + } + const selfValue = resolveConditionalProperty(schemaType[property], context) + + // we don't have to calculate the children if the current value is true + // because readOnly and hidden inherit. If the parent is readOnly or hidden + // then its children are assumed to also be readOnly or hidden respectively. + if (selfValue || level === MAX_FIELD_DEPTH) { + return {value: selfValue} + } + + const children: Record> = {} + + if (schemaType.jsonType === 'object') { + // note: this is needed because not all object types gets a ´fieldsets´ property during schema parsing. + // ideally members should be normalized as part of the schema parsing and not here + const normalizedSchemaMembers: typeof schemaType.fieldsets = schemaType.fieldsets + ? schemaType.fieldsets + : schemaType.fields.map((field) => ({single: true, field})) + + for (const fieldset of normalizedSchemaMembers) { + if (fieldset.single) { + const childResult = resolveCallbackState({ + currentUser, + document, + parent: value, + value: (value as any)?.[fieldset.field.name], + schemaType: fieldset.field.type, + level: level + 1, + property, + }) + if (!childResult) continue + + children[fieldset.field.name] = childResult + continue + } + + const fieldsetValue = resolveConditionalProperty(fieldset.hidden, context) + if (fieldsetValue) { + children[`fieldset:${fieldset.name}`] = { + value: fieldsetValue, + } + } + + for (const field of fieldset.fields) { + const childResult = resolveCallbackState({ + currentUser, + document, + parent: value, + value: (value as any)?.[field.name], + schemaType: field.type, + level: level + 1, + property, + }) + if (!childResult) continue + + children[field.name] = childResult + } + } + + for (const group of schemaType.groups ?? EMPTY_ARRAY) { + // should only be true for `'hidden'` + if (property in group) { + const groupResult = resolveConditionalProperty(group[property as 'hidden'], context) + if (!groupResult) continue + + children[`group:${group.name}`] = {value: groupResult} + } + } + } + + if (schemaType.jsonType === 'array' && Array.isArray(value)) { + if (value.every(isKeyedObject)) { + for (const item of value) { + const itemType = getItemType(schemaType, item) + if (!itemType) continue + + const childResult = resolveCallbackState({ + currentUser, + document, + level: level + 1, + value: item, + parent: value, + schemaType: itemType, + property, + }) + if (!childResult) continue + + children[item._key] = childResult + } + } + } + + if (Object.keys(children).length) return {children} + return undefined +} + +export interface CreateCallbackResolverOptions { + property: TProperty +} + +export type ResolveRootCallbackStateOptions = { + documentValue: unknown + currentUser: Omit | null + schemaType: SchemaType +} & {[K in TProperty]?: boolean} + +export type RootCallbackResolver = ( + options: ResolveRootCallbackStateOptions, +) => StateTree | undefined + +export function createCallbackResolver({ + property, +}: CreateCallbackResolverOptions): RootCallbackResolver { + const stableTrue = {value: true} + let last: {serializedHash: string; result: StateTree | undefined} | null = null + + function callbackResult({ + currentUser, + documentValue, + schemaType, + ...options + }: ResolveRootCallbackStateOptions) { + const hash = { + currentUser: getId(currentUser), + schemaType: getId(schemaType), + document: getId(documentValue), + } + const serializedHash = JSON.stringify(hash) + + if (property in options) { + if (options[property] === true) { + return stableTrue + } + } + + if (last?.serializedHash === serializedHash) return last.result + + const result = immutableReconcile( + last?.result ?? null, + resolveCallbackState({ + currentUser, + document: documentValue, + level: 0, + parent: null, + schemaType, + value: documentValue, + property, + }), + ) + + last = { + result, + serializedHash, + } + + return result + } + + return callbackResult +} diff --git a/packages/sanity/src/core/form/store/formState.ts b/packages/sanity/src/core/form/store/formState.ts index 6862208ddcd..0968ab4db91 100644 --- a/packages/sanity/src/core/form/store/formState.ts +++ b/packages/sanity/src/core/form/store/formState.ts @@ -1,3 +1,5 @@ +/* eslint-disable complexity */ +/* eslint-disable max-nested-callbacks */ /* eslint-disable max-statements */ /* eslint-disable camelcase, no-else-return */ @@ -7,6 +9,7 @@ import { type CurrentUser, isArrayOfObjectsSchemaType, isArraySchemaType, + isKeyedObject, isObjectSchemaType, type NumberSchemaType, type ObjectField, @@ -17,13 +20,12 @@ import { } from '@sanity/types' import {resolveTypeName} from '@sanity/util/content' import {isEqual, pathFor, startsWith, toString, trimChildPath} from '@sanity/util/paths' -import {castArray, isEqual as _isEqual, pick} from 'lodash' +import {castArray, isEqual as _isEqual} from 'lodash' import {type FIXME} from '../../FIXME' import {type FormNodePresence} from '../../presence' -import {EMPTY_ARRAY, isRecord} from '../../util' +import {EMPTY_ARRAY, EMPTY_OBJECT, isRecord} from '../../util' import {getFieldLevel} from '../studio/inputResolver/helpers' -import {resolveConditionalProperty} from './conditional-property' import {ALL_FIELDS_GROUP, MAX_FIELD_DEPTH} from './constants' import { type FieldSetMember, @@ -45,11 +47,72 @@ import { type ArrayOfPrimitivesFormNode, type ObjectFormNode, } from './types/nodes' +import {createMemoizer, type FunctionDecorator} from './utils/createMemoizer' import {getCollapsedWithDefaults} from './utils/getCollapsibleOptions' +import {getId} from './utils/getId' import {getItemType, getPrimitiveItemType} from './utils/getItemType' type PrimitiveSchemaType = BooleanSchemaType | NumberSchemaType | StringSchemaType +interface FormStateOptions { + schemaType: TSchemaType + path: Path + value?: T + comparisonValue?: T | null + changed?: boolean + currentUser: Omit | null + hidden?: true | StateTree | undefined + readOnly?: true | StateTree | undefined + openPath: Path + focusPath: Path + presence: FormNodePresence[] + validation: ValidationMarker[] + fieldGroupState?: StateTree + collapsedPaths?: StateTree + collapsedFieldSets?: StateTree + // nesting level + level: number + changesOpen?: boolean +} + +type PrepareFieldMember = (props: { + field: ObjectField + parent: FormStateOptions & { + groups: FormFieldGroup[] + selectedGroup: FormFieldGroup + } + index: number +}) => ObjectMember | HiddenField | null + +type PrepareObjectInputState = ( + props: FormStateOptions, + enableHiddenCheck?: boolean, +) => ObjectFormNode | null + +type PrepareArrayOfPrimitivesInputState = ( + props: FormStateOptions, +) => ArrayOfPrimitivesFormNode | null + +type PrepareArrayOfObjectsInputState = ( + props: FormStateOptions, +) => ArrayOfObjectsFormNode | null + +type PrepareArrayOfObjectsMember = (props: { + arrayItem: {_key: string} + parent: FormStateOptions + index: number +}) => ArrayOfObjectsMember + +type PrepareArrayOfPrimitivesMember = (props: { + arrayItem: unknown + parent: FormStateOptions + index: number +}) => ArrayOfPrimitivesMember + +type PreparePrimitiveInputState = ( + props: FormStateOptions, +) => PrimitiveFormNode + function isFieldEnabledByGroupFilter( // the groups config for the "enclosing object" type groupsConfig: FormFieldGroup[], @@ -128,149 +191,269 @@ function isChangedValue(value: any, comparisonValue: any) { return !_isEqual(value, comparisonValue) } -/* - * Takes a field in context of a parent object and returns prepared props for it - */ -function prepareFieldMember(props: { - field: ObjectField - parent: RawState & { - groups: FormFieldGroup[] - selectedGroup: FormFieldGroup - } - index: number -}): ObjectMember | HiddenField | null { - const {parent, field, index} = props - const fieldPath = pathFor([...parent.path, field.name]) - const fieldLevel = getFieldLevel(field.type, parent.level + 1) - - const parentValue = parent.value - const parentComparisonValue = parent.comparisonValue - if (!isAcceptedObjectValue(parentValue)) { - // Note: we validate each field, before passing it recursively to this function so getting this error means that the - // ´prepareFormState´ function itself has been called with a non-object value - throw new Error('Unexpected non-object value') +export interface CreatePrepareFormStateOptions { + decorators?: { + prepareFieldMember?: FunctionDecorator + prepareObjectInputState?: FunctionDecorator + prepareArrayOfPrimitivesInputState?: FunctionDecorator + prepareArrayOfObjectsInputState?: FunctionDecorator + prepareArrayOfObjectsMember?: FunctionDecorator + prepareArrayOfPrimitivesMember?: FunctionDecorator + preparePrimitiveInputState?: FunctionDecorator } +} - const normalizedFieldGroupNames = field.group ? castArray(field.group) : [] - const inSelectedGroup = isFieldEnabledByGroupFilter( - parent.groups, - field.group, - parent.selectedGroup, - ) +export interface RootFormStateOptions { + schemaType: ObjectSchemaType + documentValue: unknown + comparisonValue: unknown + currentUser: Omit | null + hidden: boolean | StateTree | undefined + readOnly: boolean | StateTree | undefined + openPath: Path + focusPath: Path + presence: FormNodePresence[] + validation: ValidationMarker[] + fieldGroupState: StateTree | undefined + collapsedPaths: StateTree | undefined + collapsedFieldSets: StateTree | undefined + changesOpen?: boolean +} - if (isObjectSchemaType(field.type)) { - const fieldValue = parentValue?.[field.name] - const fieldComparisonValue = isRecord(parentComparisonValue) - ? parentComparisonValue?.[field.name] - : undefined +export interface PrepareFormState { + (options: RootFormStateOptions): ObjectFormNode | null + + /** @internal */ + _prepareFieldMember: PrepareFieldMember + /** @internal */ + _prepareObjectInputState: PrepareObjectInputState + /** @internal */ + _prepareArrayOfPrimitivesInputState: PrepareArrayOfPrimitivesInputState + /** @internal */ + _prepareArrayOfObjectsInputState: PrepareArrayOfObjectsInputState + /** @internal */ + _prepareArrayOfObjectsMember: PrepareArrayOfObjectsMember + /** @internal */ + _prepareArrayOfPrimitivesMember: PrepareArrayOfPrimitivesMember + /** @internal */ + _preparePrimitiveInputState: PreparePrimitiveInputState +} - if (!isAcceptedObjectValue(fieldValue)) { +export function createPrepareFormState({ + decorators = {}, +}: CreatePrepareFormStateOptions = {}): PrepareFormState { + const memoizePrepareFieldMember = createMemoizer({ + decorator: decorators.prepareFieldMember, + getPath: ({parent, field}) => [...parent.path, field.name], + hashInput: ({parent, field}) => { + const path = [...parent.path, field.name] return { - kind: 'error', - key: field.name, - fieldName: field.name, - error: { - type: 'INCOMPATIBLE_TYPE', - expectedSchemaType: field.type, - resolvedValueType: resolveTypeName(fieldValue), - value: fieldValue, - }, + changesOpen: parent.changesOpen, + presence: parent.presence.filter((p) => startsWith(path, p.path)), + validation: parent.validation.filter((v) => startsWith(path, v.path)), + focusPath: startsWith(path, parent.focusPath) ? parent.focusPath : [], + openPath: startsWith(path, parent.openPath) ? parent.openPath : [], + value: getId((parent.value as any)?.[field.name]), + comparisonValue: getId((parent.comparisonValue as any)?.[field.name]), + collapsedFieldSets: getId(parent.collapsedFieldSets?.children?.[field.name]), + collapsedPaths: getId(parent.collapsedPaths?.children?.[field.name]), + currentUser: getId(parent.currentUser), + fieldGroupState: getId(parent.fieldGroupState), + hidden: + parent.hidden === true || + parent.hidden?.value || + getId(parent.hidden?.children?.[field.name]), + readOnly: + parent.readOnly === true || + parent.readOnly?.value || + getId(parent.readOnly?.children?.[field.name]), + schemaType: getId(parent.schemaType), } - } + }, + }) - const conditionalPropertyContext = { - value: fieldValue, - parent: parent.value, - document: parent.document, - currentUser: parent.currentUser, - } - const hidden = resolveConditionalProperty(field.type.hidden, conditionalPropertyContext) + const memoizePrepareObjectInputState = createMemoizer({ + decorator: decorators.prepareObjectInputState, + getPath: ({path}) => path, + hashInput: (state) => ({ + changesOpen: state.changesOpen, + presence: state.presence.filter((p) => startsWith(state.path, p.path)), + validation: state.validation.filter((v) => startsWith(state.path, v.path)), + focusPath: startsWith(state.path, state.focusPath) ? state.focusPath : [], + openPath: startsWith(state.path, state.openPath) ? state.openPath : [], + value: getId(state.value), + comparisonValue: getId(state.comparisonValue), + collapsedFieldSets: getId(state.collapsedFieldSets), + collapsedPaths: state.collapsedPaths, + currentUser: getId(state.currentUser), + fieldGroupState: getId(state.fieldGroupState), + hidden: state.hidden === true || state.hidden?.value || getId(state.hidden), + readOnly: state.readOnly === true || state.readOnly?.value || getId(state.readOnly), + schemaType: getId(state.schemaType), + }), + }) + + const memoizePrepareArrayOfPrimitivesInputState = + createMemoizer({ + decorator: decorators.prepareArrayOfPrimitivesInputState, + getPath: ({path}) => path, + hashInput: (state) => ({ + changesOpen: state.changesOpen, + presence: state.presence.filter((p) => startsWith(state.path, p.path)), + validation: state.validation.filter((v) => startsWith(state.path, v.path)), + focusPath: startsWith(state.path, state.focusPath) ? state.focusPath : [], + openPath: startsWith(state.path, state.openPath) ? state.openPath : [], + value: getId(state.value), + comparisonValue: getId(state.comparisonValue), + collapsedFieldSets: getId(state.collapsedFieldSets), + collapsedPaths: state.collapsedPaths, + currentUser: getId(state.currentUser), + fieldGroupState: getId(state.fieldGroupState), + hidden: state.hidden === true || state.hidden?.value || getId(state.hidden), + readOnly: state.readOnly === true || state.readOnly?.value || getId(state.readOnly), + schemaType: getId(state.schemaType), + }), + }) + + const memoizePrepareArrayOfObjectsInputState = createMemoizer({ + decorator: decorators.prepareArrayOfObjectsInputState, + getPath: ({path}) => path, + hashInput: (state) => ({ + changesOpen: state.changesOpen, + presence: state.presence.filter((p) => startsWith(state.path, p.path)), + validation: state.validation.filter((v) => startsWith(state.path, v.path)), + focusPath: startsWith(state.path, state.focusPath) ? state.focusPath : [], + openPath: startsWith(state.path, state.openPath) ? state.openPath : [], + value: getId(state.value), + comparisonValue: getId(state.comparisonValue), + collapsedFieldSets: getId(state.collapsedFieldSets), + collapsedPaths: state.collapsedPaths, + currentUser: getId(state.currentUser), + fieldGroupState: getId(state.fieldGroupState), + hidden: state.hidden === true || state.hidden?.value || getId(state.hidden), + readOnly: state.readOnly === true || state.readOnly?.value || getId(state.readOnly), + schemaType: getId(state.schemaType), + }), + }) + + const memoizePrepareArrayOfObjectsMember = createMemoizer({ + decorator: decorators.prepareArrayOfObjectsMember, + getPath: ({parent, arrayItem}) => [...parent.path, {_key: arrayItem._key}], + hashInput: ({parent, arrayItem}) => { + const comparisonValue = Array.isArray(parent.comparisonValue) + ? parent.comparisonValue.find((item) => isKeyedObject(item) && item._key === arrayItem._key) + : undefined + + const key = arrayItem._key + const path: Path = [...parent.path, {_key: key}] - if (hidden) { return { - kind: 'hidden', - key: `field-${field.name}`, - name: field.name, - index: index, + changesOpen: parent.changesOpen, + presence: parent.presence.filter((p) => startsWith(path, p.path)), + validation: parent.validation.filter((v) => startsWith(path, v.path)), + focusPath: startsWith(path, parent.focusPath) ? parent.focusPath : [], + openPath: startsWith(path, parent.openPath) ? parent.openPath : [], + value: getId(arrayItem), + comparisonValue: getId(comparisonValue), + collapsedFieldSets: getId(parent.collapsedFieldSets?.children?.[key]), + collapsedPaths: getId(parent.collapsedPaths?.children?.[key]), + currentUser: getId(parent.currentUser), + fieldGroupState: getId(parent.fieldGroupState?.children?.[key]), + hidden: + parent.hidden === true || parent.hidden?.value || getId(parent.hidden?.children?.[key]), + readOnly: + parent.readOnly === true || + parent.readOnly?.value || + getId(parent.readOnly?.children?.[key]), + schemaType: getId(parent.schemaType), } - } - - // readonly is inherited - const readOnly = - parent.readOnly || resolveConditionalProperty(field.type.readOnly, conditionalPropertyContext) - - // todo: consider requiring a _type annotation for object values on fields as well - // if (resolvedValueType !== field.type.name) { - // return { - // kind: 'error', - // key: field.name, - // error: { - // type: 'TYPE_ANNOTATION_MISMATCH', - // expectedSchemaType: field.type, - // resolvedValueType, - // }, - // } - // } - - const fieldGroupState = parent.fieldGroupState?.children?.[field.name] - const scopedCollapsedPaths = parent.collapsedPaths?.children?.[field.name] - const scopedCollapsedFieldsets = parent.collapsedFieldSets?.children?.[field.name] - - const inputState = prepareObjectInputState({ - schemaType: field.type, - currentUser: parent.currentUser, - parent: parent.value, - document: parent.document, - value: fieldValue, - changed: isChangedValue(fieldValue, fieldComparisonValue), - comparisonValue: fieldComparisonValue, - presence: parent.presence, - validation: parent.validation, - fieldGroupState, - path: fieldPath, - level: fieldLevel, - focusPath: parent.focusPath, - openPath: parent.openPath, - collapsedPaths: scopedCollapsedPaths, - collapsedFieldSets: scopedCollapsedFieldsets, - readOnly, - changesOpen: parent.changesOpen, - }) + }, + }) - if (inputState === null) { - // if inputState is null is either because we reached max field depth or if it has no visible members - return null - } + const memoizePrepareArrayOfPrimitivesMember = createMemoizer({ + decorator: decorators.prepareArrayOfPrimitivesMember, + getPath: ({parent, index}) => [...parent.path, index], + hashInput: ({parent, index, arrayItem}) => { + const comparisonValue = Array.isArray(parent.comparisonValue) + ? parent.comparisonValue[index] + : undefined - const defaultCollapsedState = getCollapsedWithDefaults(field.type.options as FIXME, fieldLevel) - const collapsed = scopedCollapsedPaths - ? scopedCollapsedPaths.value - : defaultCollapsedState.collapsed + const path: Path = [...parent.path, index] - return { - kind: 'field', - key: `field-${field.name}`, - name: field.name, - index: index, + return { + changesOpen: parent.changesOpen, + presence: parent.presence.filter((p) => startsWith(path, p.path)), + validation: parent.validation.filter((v) => startsWith(path, v.path)), + focusPath: startsWith(path, parent.focusPath) ? parent.focusPath : [], + openPath: startsWith(path, parent.openPath) ? parent.openPath : [], + collapsedFieldSets: getId(parent.collapsedFieldSets?.children?.[index]), + collapsedPaths: getId(parent.collapsedPaths?.children?.[index]), + currentUser: getId(parent.currentUser), + fieldGroupState: getId(parent.fieldGroupState?.children?.[index]), + hidden: + parent.hidden === true || parent.hidden?.value || getId(parent.hidden?.children?.[index]), + readOnly: + parent.readOnly === true || + parent.readOnly?.value || + getId(parent.readOnly?.children?.[index]), + schemaType: getId(parent.schemaType), + value: `${arrayItem}`, + comparisonValue: `${comparisonValue}`, + } + }, + }) - inSelectedGroup, - groups: normalizedFieldGroupNames, + const memoizePreparePrimitiveInputState = createMemoizer({ + decorator: decorators.preparePrimitiveInputState, + getPath: ({path}) => path, + hashInput: (state) => ({ + changesOpen: state.changesOpen, + presence: state.presence.filter((p) => startsWith(state.path, p.path)), + validation: state.validation.filter((v) => startsWith(state.path, v.path)), + focusPath: startsWith(state.path, state.focusPath) ? state.focusPath : [], + openPath: startsWith(state.path, state.openPath) ? state.openPath : [], + value: getId(state.value), + comparisonValue: getId(state.comparisonValue), + collapsedFieldSets: getId(state.collapsedFieldSets), + collapsedPaths: state.collapsedPaths, + currentUser: getId(state.currentUser), + fieldGroupState: getId(state.fieldGroupState), + hidden: state.hidden === true || state.hidden?.value || getId(state.hidden), + readOnly: state.readOnly === true || state.readOnly?.value || getId(state.readOnly), + schemaType: getId(state.schemaType), + }), + }) - open: startsWith(fieldPath, parent.openPath), - field: inputState, - collapsed, - collapsible: defaultCollapsedState.collapsible, + /* + * Takes a field in context of a parent object and returns prepared props for it + */ + const prepareFieldMember = memoizePrepareFieldMember(function _prepareFieldMember(props) { + const {field, index, parent} = props + const fieldPath = pathFor([...parent.path, field.name]) + const fieldLevel = getFieldLevel(field.type, parent.level + 1) + + const parentValue = parent.value + const parentComparisonValue = parent.comparisonValue + if (!isAcceptedObjectValue(parentValue)) { + // Note: we validate each field, before passing it recursively to this function so getting this error means that the + // ´prepareFormState´ function itself has been called with a non-object value + throw new Error('Unexpected non-object value') } - } else if (isArraySchemaType(field.type)) { - const fieldValue = parentValue?.[field.name] as unknown[] | undefined - const fieldComparisonValue = isRecord(parentComparisonValue) - ? parentComparisonValue?.[field.name] - : undefined - if (isArrayOfObjectsSchemaType(field.type)) { - const hasValue = typeof fieldValue !== 'undefined' - if (hasValue && !isValidArrayOfObjectsValue(fieldValue)) { - const resolvedValueType = resolveTypeName(fieldValue) + const normalizedFieldGroupNames = field.group ? castArray(field.group) : [] + const inSelectedGroup = isFieldEnabledByGroupFilter( + parent.groups, + field.group, + parent.selectedGroup, + ) + + if (isObjectSchemaType(field.type)) { + const fieldValue = parentValue?.[field.name] + const fieldComparisonValue = isRecord(parentComparisonValue) + ? parentComparisonValue?.[field.name] + : undefined + + if (!isAcceptedObjectValue(fieldValue)) { return { kind: 'error', key: field.name, @@ -278,787 +461,844 @@ function prepareFieldMember(props: { error: { type: 'INCOMPATIBLE_TYPE', expectedSchemaType: field.type, - resolvedValueType, + resolvedValueType: resolveTypeName(fieldValue), value: fieldValue, }, } } - if (hasValue && !everyItemIsObject(fieldValue)) { - return { - kind: 'error', - key: field.name, - fieldName: field.name, - error: { - type: 'MIXED_ARRAY', - schemaType: field.type, - value: fieldValue, - }, - } - } + const hidden = + parent.hidden === true || + parent?.hidden?.value || + parent.hidden?.children?.[field.name]?.value - if (hasValue && !everyItemHasKey(fieldValue)) { + if (hidden) { return { - kind: 'error', - key: field.name, - fieldName: field.name, - error: { - type: 'MISSING_KEYS', - value: fieldValue, - schemaType: field.type, - }, + kind: 'hidden', + key: `field-${field.name}`, + name: field.name, + index: index, } } - const duplicateKeyEntries = hasValue ? findDuplicateKeyEntries(fieldValue) : [] - if (duplicateKeyEntries.length > 0) { - return { - kind: 'error', - key: field.name, - fieldName: field.name, - error: { - type: 'DUPLICATE_KEYS', - duplicates: duplicateKeyEntries, - schemaType: field.type, - }, - } - } + // todo: consider requiring a _type annotation for object values on fields as well + // if (resolvedValueType !== field.type.name) { + // return { + // kind: 'error', + // key: field.name, + // error: { + // type: 'TYPE_ANNOTATION_MISMATCH', + // expectedSchemaType: field.type, + // resolvedValueType, + // }, + // } + // } const fieldGroupState = parent.fieldGroupState?.children?.[field.name] const scopedCollapsedPaths = parent.collapsedPaths?.children?.[field.name] - const scopedCollapsedFieldSets = parent.collapsedFieldSets?.children?.[field.name] - - const readOnly = - parent.readOnly || - resolveConditionalProperty(field.type.readOnly, { - value: fieldValue, - parent: parent.value, - document: parent.document, - currentUser: parent.currentUser, - }) - - const fieldState = prepareArrayOfObjectsInputState({ + const scopedCollapsedFieldsets = parent.collapsedFieldSets?.children?.[field.name] + const scopedHidden = + parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[field.name] + const scopedReadOnly = + parent.readOnly === true || + parent.readOnly?.value || + parent.readOnly?.children?.[field.name] + + const inputState = prepareObjectInputState({ schemaType: field.type, - parent: parent.value, currentUser: parent.currentUser, - document: parent.document, value: fieldValue, changed: isChangedValue(fieldValue, fieldComparisonValue), - comparisonValue: fieldComparisonValue as FIXME, + comparisonValue: fieldComparisonValue, + presence: parent.presence, + validation: parent.validation, fieldGroupState, + path: fieldPath, + level: fieldLevel, focusPath: parent.focusPath, openPath: parent.openPath, - presence: parent.presence, - validation: parent.validation, collapsedPaths: scopedCollapsedPaths, - collapsedFieldSets: scopedCollapsedFieldSets, - level: fieldLevel, - path: fieldPath, - readOnly, + collapsedFieldSets: scopedCollapsedFieldsets, + hidden: scopedHidden, + readOnly: scopedReadOnly, + changesOpen: parent.changesOpen, }) - if (fieldState === null) { + if (inputState === null) { + // if inputState is null is either because we reached max field depth or if it has no visible members return null } + const defaultCollapsedState = getCollapsedWithDefaults(field.type.options, fieldLevel) + const collapsed = scopedCollapsedPaths + ? scopedCollapsedPaths.value + : defaultCollapsedState.collapsed + return { kind: 'field', key: `field-${field.name}`, name: field.name, index: index, - open: startsWith(fieldPath, parent.openPath), - inSelectedGroup, groups: normalizedFieldGroupNames, - collapsible: false, - collapsed: false, - // note: this is what we actually end up passing down as to the next input component - field: fieldState, + open: startsWith(fieldPath, parent.openPath), + field: inputState, + collapsed, + collapsible: defaultCollapsedState.collapsible, } - } else { - // array of primitives - if (!isValidArrayOfPrimitivesValue(fieldValue)) { - const resolvedValueType = resolveTypeName(fieldValue) + } else if (isArraySchemaType(field.type)) { + const fieldValue = parentValue?.[field.name] as unknown[] | undefined + const fieldComparisonValue = isRecord(parentComparisonValue) + ? parentComparisonValue?.[field.name] + : undefined + if (isArrayOfObjectsSchemaType(field.type)) { + const hasValue = typeof fieldValue !== 'undefined' + if (hasValue && !isValidArrayOfObjectsValue(fieldValue)) { + const resolvedValueType = resolveTypeName(fieldValue) + + return { + kind: 'error', + key: field.name, + fieldName: field.name, + error: { + type: 'INCOMPATIBLE_TYPE', + expectedSchemaType: field.type, + resolvedValueType, + value: fieldValue, + }, + } + } - return { - kind: 'error', - key: field.name, - fieldName: field.name, - error: { - type: 'INCOMPATIBLE_TYPE', - expectedSchemaType: field.type, - resolvedValueType, - value: fieldValue, - }, + if (hasValue && !everyItemIsObject(fieldValue)) { + return { + kind: 'error', + key: field.name, + fieldName: field.name, + error: { + type: 'MIXED_ARRAY', + schemaType: field.type, + value: fieldValue, + }, + } } - } - const fieldGroupState = parent.fieldGroupState?.children?.[field.name] - const scopedCollapsedPaths = parent.collapsedPaths?.children?.[field.name] - const scopedCollapsedFieldSets = parent.collapsedFieldSets?.children?.[field.name] + if (hasValue && !everyItemHasKey(fieldValue)) { + return { + kind: 'error', + key: field.name, + fieldName: field.name, + error: { + type: 'MISSING_KEYS', + value: fieldValue, + schemaType: field.type, + }, + } + } + + const duplicateKeyEntries = hasValue ? findDuplicateKeyEntries(fieldValue) : [] + if (duplicateKeyEntries.length > 0) { + return { + kind: 'error', + key: field.name, + fieldName: field.name, + error: { + type: 'DUPLICATE_KEYS', + duplicates: duplicateKeyEntries, + schemaType: field.type, + }, + } + } - const readOnly = - parent.readOnly || - resolveConditionalProperty(field.type.readOnly, { + const fieldGroupState = parent.fieldGroupState?.children?.[field.name] + const scopedCollapsedPaths = parent.collapsedPaths?.children?.[field.name] + const scopedCollapsedFieldSets = parent.collapsedFieldSets?.children?.[field.name] + const scopedHidden = + parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[field.name] + const scopedReadOnly = + parent.readOnly === true || + parent.readOnly?.value || + parent.readOnly?.children?.[field.name] + + const fieldState = prepareArrayOfObjectsInputState({ + schemaType: field.type, + currentUser: parent.currentUser, value: fieldValue, - parent: parent.value, - document: parent.document, + changed: isChangedValue(fieldValue, fieldComparisonValue), + comparisonValue: fieldComparisonValue as FIXME, + fieldGroupState, + focusPath: parent.focusPath, + openPath: parent.openPath, + presence: parent.presence, + validation: parent.validation, + collapsedPaths: scopedCollapsedPaths, + collapsedFieldSets: scopedCollapsedFieldSets, + level: fieldLevel, + path: fieldPath, + readOnly: scopedReadOnly, + hidden: scopedHidden, + changesOpen: parent.changesOpen, + }) + + if (fieldState === null) { + return null + } + + return { + kind: 'field', + key: `field-${field.name}`, + name: field.name, + index: index, + + open: startsWith(fieldPath, parent.openPath), + + inSelectedGroup, + groups: normalizedFieldGroupNames, + + collapsible: false, + collapsed: false, + // note: this is what we actually end up passing down as to the next input component + field: fieldState, + } + } else { + // array of primitives + if (!isValidArrayOfPrimitivesValue(fieldValue)) { + const resolvedValueType = resolveTypeName(fieldValue) + + return { + kind: 'error', + key: field.name, + fieldName: field.name, + error: { + type: 'INCOMPATIBLE_TYPE', + expectedSchemaType: field.type, + resolvedValueType, + value: fieldValue, + }, + } + } + + const fieldGroupState = parent.fieldGroupState?.children?.[field.name] + const scopedCollapsedPaths = parent.collapsedPaths?.children?.[field.name] + const scopedCollapsedFieldSets = parent.collapsedFieldSets?.children?.[field.name] + const scopedHidden = + parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[field.name] + const scopedReadOnly = + parent.readOnly === true || + parent.readOnly?.value || + parent.readOnly?.children?.[field.name] + + const fieldState = prepareArrayOfPrimitivesInputState({ + changed: isChangedValue(fieldValue, fieldComparisonValue), + comparisonValue: fieldComparisonValue as FIXME, + schemaType: field.type, currentUser: parent.currentUser, + value: fieldValue, + fieldGroupState, + focusPath: parent.focusPath, + openPath: parent.openPath, + presence: parent.presence, + validation: parent.validation, + collapsedPaths: scopedCollapsedPaths, + collapsedFieldSets: scopedCollapsedFieldSets, + level: fieldLevel, + path: fieldPath, + readOnly: scopedReadOnly, + hidden: scopedHidden, + changesOpen: parent.changesOpen, }) - const fieldState = prepareArrayOfPrimitivesInputState({ - changed: isChangedValue(fieldValue, fieldComparisonValue), - comparisonValue: fieldComparisonValue as FIXME, - schemaType: field.type, - parent: parent.value, - currentUser: parent.currentUser, - document: parent.document, - value: fieldValue, - fieldGroupState, - focusPath: parent.focusPath, - openPath: parent.openPath, - presence: parent.presence, - validation: parent.validation, - collapsedPaths: scopedCollapsedPaths, - collapsedFieldSets: scopedCollapsedFieldSets, - level: fieldLevel, - path: fieldPath, - readOnly, - }) + if (fieldState === null) { + return null + } - if (fieldState === null) { + return { + kind: 'field', + key: `field-${field.name}`, + name: field.name, + index: index, + + inSelectedGroup, + groups: normalizedFieldGroupNames, + + open: startsWith(fieldPath, parent.openPath), + + // todo: consider support for collapsible arrays + collapsible: false, + collapsed: false, + // note: this is what we actually end up passing down as to the next input component + field: fieldState, + } + } + } else { + // primitive fields + + const fieldValue = parentValue?.[field.name] as undefined | boolean | string | number + const fieldComparisonValue = isRecord(parentComparisonValue) + ? parentComparisonValue?.[field.name] + : undefined + + // note: we *only* want to call the conditional props here, as it's handled by the prepareInputProps otherwise + const hidden = + parent.hidden === true || + parent.hidden?.value || + parent.hidden?.children?.[field.name]?.value + + if (hidden) { return null } + const scopedHidden = + parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[field.name] + const scopedReadOnly = + parent.readOnly === true || + parent.readOnly?.value || + parent.readOnly?.children?.[field.name] + + const fieldState = preparePrimitiveInputState({ + ...parent, + comparisonValue: fieldComparisonValue, + value: fieldValue as boolean | string | number | undefined, + schemaType: field.type as PrimitiveSchemaType, + path: fieldPath, + readOnly: scopedReadOnly, + hidden: scopedHidden, + }) + return { kind: 'field', key: `field-${field.name}`, name: field.name, index: index, + open: startsWith(fieldPath, parent.openPath), inSelectedGroup, groups: normalizedFieldGroupNames, - open: startsWith(fieldPath, parent.openPath), - - // todo: consider support for collapsible arrays + // todo: consider support for collapsible primitive fields collapsible: false, collapsed: false, - // note: this is what we actually end up passing down as to the next input component field: fieldState, } } - } else { - // primitive fields - - const fieldValue = parentValue?.[field.name] as undefined | boolean | string | number - const fieldComparisonValue = isRecord(parentComparisonValue) - ? parentComparisonValue?.[field.name] - : undefined - - const conditionalPropertyContext = { - value: fieldValue, - parent: parent.value, - document: parent.document, - currentUser: parent.currentUser, - } - - // note: we *only* want to call the conditional props here, as it's handled by the prepareInputProps otherwise - const hidden = resolveConditionalProperty(field.type.hidden, conditionalPropertyContext) + }) - if (hidden) { + const prepareObjectInputState = memoizePrepareObjectInputState(function _prepareObjectInputState( + props, + enableHiddenCheck = true, + ) { + if (props.level === MAX_FIELD_DEPTH) { return null } - const readOnly = - parent.readOnly || resolveConditionalProperty(field.type.readOnly, conditionalPropertyContext) - - const fieldState = preparePrimitiveInputState({ - ...parent, - comparisonValue: fieldComparisonValue, - value: fieldValue as boolean | string | number | undefined, - schemaType: field.type as PrimitiveSchemaType, - path: fieldPath, - readOnly, - }) + const readOnly = props.readOnly === true || props.readOnly?.value + + const schemaTypeGroupConfig = props.schemaType.groups || [] + const defaultGroupName = (schemaTypeGroupConfig.find((g) => g.default) || ALL_FIELDS_GROUP) + ?.name + + const groups = [ALL_FIELDS_GROUP, ...schemaTypeGroupConfig].flatMap( + (group): FormFieldGroup[] => { + const groupHidden = + props.hidden === true || + props.hidden?.value || + props.hidden?.children?.[`group:${group.name}`]?.value + const isSelected = group.name === (props.fieldGroupState?.value || defaultGroupName) + + // Set the "all-fields" group as selected when review changes is open to enable review of all + // fields and changes together. When review changes is closed - switch back to the selected tab. + const selected = props.changesOpen ? group.name === ALL_FIELDS_GROUP.name : isSelected + // Also disable non-selected groups when review changes is open + const disabled = props.changesOpen ? !selected : false + + return groupHidden + ? [] + : [ + { + disabled, + icon: group?.icon, + name: group.name, + selected, + title: group.title, + i18n: group.i18n, + }, + ] + }, + ) - return { - kind: 'field', - key: `field-${field.name}`, - name: field.name, - index: index, - open: startsWith(fieldPath, parent.openPath), + const selectedGroup = groups.find((group) => group.selected)! - inSelectedGroup, - groups: normalizedFieldGroupNames, + // note: this is needed because not all object types gets a ´fieldsets´ property during schema parsing. + // ideally members should be normalized as part of the schema parsing and not here + const normalizedSchemaMembers: typeof props.schemaType.fieldsets = props.schemaType.fieldsets + ? props.schemaType.fieldsets + : props.schemaType.fields.map((field) => ({single: true, field})) - // todo: consider support for collapsible primitive fields - collapsible: false, - collapsed: false, - field: fieldState, - } - } -} + // create a members array for the object + const members = normalizedSchemaMembers.flatMap( + (fieldSet, index): (ObjectMember | HiddenField)[] => { + // "single" means not part of a fieldset + if (fieldSet.single) { + const field = fieldSet.field -interface RawState { - schemaType: SchemaType - value?: T - comparisonValue?: T | null - changed?: boolean - document: FIXME_SanityDocument - currentUser: Omit | null - parent?: unknown - hidden?: boolean - readOnly?: boolean - path: Path - openPath: Path - focusPath: Path - presence: FormNodePresence[] - validation: ValidationMarker[] - fieldGroupState?: StateTree - collapsedPaths?: StateTree - collapsedFieldSets?: StateTree - // nesting level - level: number - changesOpen?: boolean -} - -function prepareObjectInputState( - props: RawState, - enableHiddenCheck?: false, -): ObjectFormNode -function prepareObjectInputState( - props: RawState, - enableHiddenCheck?: true, -): ObjectFormNode | null -function prepareObjectInputState( - props: RawState, - enableHiddenCheck = true, -): ObjectFormNode | null { - if (props.level === MAX_FIELD_DEPTH) { - return null - } - - const conditionalPropertyContext = { - value: props.value, - parent: props.parent, - document: props.document, - currentUser: props.currentUser, - } + const fieldMember = prepareFieldMember({ + field: field, + parent: {...props, groups, selectedGroup}, + index, + }) - // readonly is inherited - const readOnly = - props.readOnly || - resolveConditionalProperty(props.schemaType.readOnly, conditionalPropertyContext) + return fieldMember ? [fieldMember] : [] + } - const schemaTypeGroupConfig = props.schemaType.groups || [] - const defaultGroupName = (schemaTypeGroupConfig.find((g) => g.default) || ALL_FIELDS_GROUP)?.name + // it's an actual fieldset + const fieldsetHidden = + props.hidden === true || + props.hidden?.value || + props.hidden?.children?.[`fieldset:${fieldSet.name}`]?.value + + const fieldsetMembers = fieldSet.fields.flatMap( + (field): (FieldMember | FieldError | HiddenField)[] => { + if (fieldsetHidden) { + return [ + { + kind: 'hidden', + key: `field-${field.name}`, + name: field.name, + index: index, + }, + ] + } + const fieldMember = prepareFieldMember({ + field: field, + parent: {...props, groups, selectedGroup}, + index, + }) as FieldMember | FieldError | HiddenField + + return fieldMember ? [fieldMember] : [] + }, + ) - const groups = [ALL_FIELDS_GROUP, ...schemaTypeGroupConfig].flatMap((group): FormFieldGroup[] => { - const groupHidden = resolveConditionalProperty(group.hidden, conditionalPropertyContext) - const isSelected = group.name === (props.fieldGroupState?.value || defaultGroupName) + const defaultCollapsedState = getCollapsedWithDefaults(fieldSet.options, props.level) - // Set the "all-fields" group as selected when review changes is open to enable review of all - // fields and changes together. When review changes is closed - switch back to the selected tab. - const selected = props.changesOpen ? group.name === ALL_FIELDS_GROUP.name : isSelected - // Also disable non-selected groups when review changes is open - const disabled = props.changesOpen ? !selected : false + const collapsed = + (props.collapsedFieldSets?.children || {})[fieldSet.name]?.value ?? + defaultCollapsedState.collapsed - return groupHidden - ? [] - : [ + return [ { - disabled, - icon: group?.icon, - name: group.name, - selected, - title: group.title, - i18n: group.i18n, + kind: 'fieldSet', + key: `fieldset-${fieldSet.name}`, + _inSelectedGroup: isFieldEnabledByGroupFilter(groups, fieldSet.group, selectedGroup), + groups: fieldSet.group ? castArray(fieldSet.group) : [], + fieldSet: { + path: pathFor(props.path.concat(fieldSet.name)), + name: fieldSet.name, + title: fieldSet.title, + description: fieldSet.description, + hidden: false, + level: props.level + 1, + members: fieldsetMembers.filter( + (member): member is FieldMember => member.kind !== 'hidden', + ), + collapsible: defaultCollapsedState?.collapsible, + collapsed, + columns: fieldSet?.options?.columns, + }, }, ] - }) + }, + ) - const selectedGroup = groups.find((group) => group.selected)! + const hasFieldGroups = schemaTypeGroupConfig.length > 0 - // note: this is needed because not all object types gets a ´fieldsets´ property during schema parsing. - // ideally members should be normalized as part of the schema parsing and not here - const normalizedSchemaMembers: typeof props.schemaType.fieldsets = props.schemaType.fieldsets - ? props.schemaType.fieldsets - : props.schemaType.fields.map((field) => ({single: true, field})) + const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path)) + const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY - // create a members array for the object - const members = normalizedSchemaMembers.flatMap( - (fieldSet, index): (ObjectMember | HiddenField)[] => { - // "single" means not part of a fieldset - if (fieldSet.single) { - const field = fieldSet.field + const validation = props.validation + .filter((item) => isEqual(item.path, props.path)) + .map((v) => ({level: v.level, message: v.message, path: v.path})) - const fieldMember = prepareFieldMember({ - field: field, - parent: {...props, readOnly, groups, selectedGroup}, - index, - }) + const visibleMembers = members.filter( + (member): member is ObjectMember => member.kind !== 'hidden', + ) - return fieldMember ? [fieldMember] : [] - } + // Return null here only when enableHiddenCheck, or we end up with array members that have 'item: null' when they + // really should not be. One example is when a block object inside the PT-input have a type with one single hidden field. + // Then it should still be possible to see the member item, even though all of it's fields are null. + if (visibleMembers.length === 0 && enableHiddenCheck) { + return null + } - // it's an actual fieldset - const fieldsetFieldNames = fieldSet.fields.map((f) => f.name) - const fieldsetHidden = resolveConditionalProperty(fieldSet.hidden, { - currentUser: props.currentUser, - document: props.document, - parent: props.value, - value: pick(props.value, fieldsetFieldNames), - }) + const visibleGroups = hasFieldGroups + ? groups.flatMap((group) => { + // The "all fields" group is always visible + if (group.name === ALL_FIELDS_GROUP.name) { + return group + } + const hasVisibleMembers = visibleMembers.some((member) => { + if (member.kind === 'error') { + return false + } + if (member.kind === 'field') { + return member.groups.includes(group.name) + } + + return ( + member.groups.includes(group.name) || + member.fieldSet.members.some( + (fieldsetMember) => + fieldsetMember.kind !== 'error' && fieldsetMember.groups.includes(group.name), + ) + ) + }) + return hasVisibleMembers ? group : [] + }) + : [] - const fieldsetReadOnly = resolveConditionalProperty(fieldSet.readOnly, { - currentUser: props.currentUser, - document: props.document, - parent: props.value, - value: pick(props.value, fieldsetFieldNames), - }) + const filtereredMembers = visibleMembers.flatMap( + (member): (FieldError | FieldMember | FieldSetMember)[] => { + if (member.kind === 'error') { + return [member] + } + if (member.kind === 'field') { + return member.inSelectedGroup ? [member] : [] + } - const fieldsetMembers = fieldSet.fields.flatMap( - (field): (FieldMember | FieldError | HiddenField)[] => { - if (fieldsetHidden) { - return [ + const filteredFieldsetMembers: ObjectMember[] = member.fieldSet.members.filter( + (fieldsetMember) => fieldsetMember.kind !== 'field' || fieldsetMember.inSelectedGroup, + ) + return filteredFieldsetMembers.length > 0 + ? [ { - kind: 'hidden', - key: `field-${field.name}`, - name: field.name, - index: index, - }, + ...member, + fieldSet: {...member.fieldSet, members: filteredFieldsetMembers}, + } as FieldSetMember, ] - } - const fieldMember = prepareFieldMember({ - field: field, - parent: {...props, readOnly: readOnly || fieldsetReadOnly, groups, selectedGroup}, - index, - }) as FieldMember | FieldError | HiddenField + : [] + }, + ) - return fieldMember ? [fieldMember] : [] - }, - ) + const node = { + value: props.value as Record | undefined, + changed: isChangedValue(props.value, props.comparisonValue), + schemaType: props.schemaType, + readOnly, + path: props.path, + id: toString(props.path), + level: props.level, + focused: isEqual(props.path, props.focusPath), + focusPath: trimChildPath(props.path, props.focusPath), + presence, + validation, + // this is currently needed by getExpandOperations which needs to know about hidden members + // (e.g. members not matching current group filter) in order to determine what to expand + members: filtereredMembers, + groups: visibleGroups, + } + Object.defineProperty(node, '_allMembers', { + value: members, + enumerable: false, + }) + return node + }) + + const prepareArrayOfPrimitivesInputState = memoizePrepareArrayOfPrimitivesInputState( + function _prepareArrayOfPrimitivesInputState(props) { + if (props.level === MAX_FIELD_DEPTH) { + return null + } - const defaultCollapsedState = getCollapsedWithDefaults(fieldSet.options, props.level) + if (props.hidden === true || props.hidden?.value) { + return null + } - const collapsed = - (props.collapsedFieldSets?.children || {})[fieldSet.name]?.value ?? - defaultCollapsedState.collapsed + // Todo: improve error handling at the parent level so that the value here is either undefined or an array + const items = Array.isArray(props.value) ? props.value : [] - return [ - { - kind: 'fieldSet', - key: `fieldset-${fieldSet.name}`, - _inSelectedGroup: isFieldEnabledByGroupFilter(groups, fieldSet.group, selectedGroup), - groups: fieldSet.group ? castArray(fieldSet.group) : [], - fieldSet: { - path: pathFor(props.path.concat(fieldSet.name)), - name: fieldSet.name, - title: fieldSet.title, - description: fieldSet.description, - hidden: false, - level: props.level + 1, - members: fieldsetMembers.filter( - (member): member is FieldMember => member.kind !== 'hidden', - ), - collapsible: defaultCollapsedState?.collapsible, - collapsed, - columns: fieldSet?.options?.columns, - }, - }, - ] + const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path)) + const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY + const validation = props.validation + .filter((item) => isEqual(item.path, props.path)) + .map((v) => ({level: v.level, message: v.message, path: v.path})) + const members = items.flatMap((item, index) => + prepareArrayOfPrimitivesMember({arrayItem: item, parent: props, index}), + ) + return { + // checks for changes not only on the array itself, but also on any of its items + changed: props.changed || members.some((m) => m.kind === 'item' && m.item.changed), + value: props.value, + readOnly: props.readOnly === true || props.readOnly?.value, + schemaType: props.schemaType, + focused: isEqual(props.path, props.focusPath), + focusPath: trimChildPath(props.path, props.focusPath), + path: props.path, + id: toString(props.path), + level: props.level, + validation, + presence, + members, + } }, ) - const hasFieldGroups = schemaTypeGroupConfig.length > 0 - - const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path)) - const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY - - const validation = props.validation - .filter((item) => isEqual(item.path, props.path)) - .map((v) => ({level: v.level, message: v.message, path: v.path})) + const prepareArrayOfObjectsInputState = memoizePrepareArrayOfObjectsInputState( + function _prepareArrayOfObjectsInputState(props) { + if (props.level === MAX_FIELD_DEPTH) { + return null + } - const visibleMembers = members.filter( - (member): member is ObjectMember => member.kind !== 'hidden', - ) + if (props.hidden === true || props.hidden?.value) { + return null + } - // Return null here only when enableHiddenCheck, or we end up with array members that have 'item: null' when they - // really should not be. One example is when a block object inside the PT-input have a type with one single hidden field. - // Then it should still be possible to see the member item, even though all of it's fields are null. - if (visibleMembers.length === 0 && enableHiddenCheck) { - return null - } + // Todo: improve error handling at the parent level so that the value here is either undefined or an array + const items = Array.isArray(props.value) ? props.value : [] - const visibleGroups = hasFieldGroups - ? groups.flatMap((group) => { - // The "all fields" group is always visible - if (group.name === ALL_FIELDS_GROUP.name) { - return group - } - const hasVisibleMembers = visibleMembers.some((member) => { - if (member.kind === 'error') { - return false - } - if (member.kind === 'field') { - return member.groups.includes(group.name) - } + const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path)) + const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY + const validation = props.validation + .filter((item) => isEqual(item.path, props.path)) + .map((v) => ({level: v.level, message: v.message, path: v.path})) - return ( - member.groups.includes(group.name) || - member.fieldSet.members.some( - (fieldsetMember) => - fieldsetMember.kind !== 'error' && fieldsetMember.groups.includes(group.name), - ) - ) - }) - return hasVisibleMembers ? group : [] - }) - : [] + const members = items.flatMap((item, index) => + prepareArrayOfObjectsMember({ + arrayItem: item, + parent: props, + index, + }), + ) - const filtereredMembers = visibleMembers.flatMap( - (member): (FieldError | FieldMember | FieldSetMember)[] => { - if (member.kind === 'error') { - return [member] - } - if (member.kind === 'field') { - return member.inSelectedGroup ? [member] : [] + return { + // checks for changes not only on the array itself, but also on any of its items + changed: props.changed || members.some((m) => m.kind === 'item' && m.item.changed), + value: props.value, + readOnly: props.readOnly === true || props.readOnly?.value, + schemaType: props.schemaType, + focused: isEqual(props.path, props.focusPath), + focusPath: trimChildPath(props.path, props.focusPath), + path: props.path, + id: toString(props.path), + level: props.level, + validation, + presence, + members, } - - const filteredFieldsetMembers: ObjectMember[] = member.fieldSet.members.filter( - (fieldsetMember) => fieldsetMember.kind !== 'field' || fieldsetMember.inSelectedGroup, - ) - return filteredFieldsetMembers.length > 0 - ? [ - { - ...member, - fieldSet: {...member.fieldSet, members: filteredFieldsetMembers}, - } as FieldSetMember, - ] - : [] }, ) - const node = { - value: props.value as Record | undefined, - changed: isChangedValue(props.value, props.comparisonValue), - schemaType: props.schemaType, - readOnly, - path: props.path, - id: toString(props.path), - level: props.level, - focused: isEqual(props.path, props.focusPath), - focusPath: trimChildPath(props.path, props.focusPath), - presence, - validation, - // this is currently needed by getExpandOperations which needs to know about hidden members - // (e.g. members not matching current group filter) in order to determine what to expand - members: filtereredMembers, - groups: visibleGroups, - } - Object.defineProperty(node, '_allMembers', { - value: members, - enumerable: false, - }) - return node -} + /* + * Takes a field in context of a parent object and returns prepared props for it + */ + const prepareArrayOfObjectsMember = memoizePrepareArrayOfObjectsMember( + function _prepareArrayOfObjectsMember(props) { + const {arrayItem, parent, index} = props -function prepareArrayOfPrimitivesInputState( - props: RawState, -): ArrayOfPrimitivesFormNode | null { - if (props.level === MAX_FIELD_DEPTH) { - return null - } + const itemType = getItemType(parent.schemaType, arrayItem) as ObjectSchemaType - const conditionalPropertyContext = { - comparisonValue: props.comparisonValue, - value: props.value, - parent: props.parent, - document: props.document, - currentUser: props.currentUser, - } + const key = arrayItem._key - const hidden = resolveConditionalProperty(props.schemaType.hidden, conditionalPropertyContext) + if (!itemType) { + const itemTypeName = resolveTypeName(arrayItem) + return { + kind: 'error', + key, + index, + error: { + type: 'INVALID_ITEM_TYPE', + resolvedValueType: itemTypeName, + value: arrayItem, + validTypes: parent.schemaType.of, + }, + } + } - if (hidden) { - return null - } + const itemPath = pathFor([...parent.path, {_key: key}]) + const itemLevel = parent.level + 1 - const readOnly = - props.readOnly || - resolveConditionalProperty(props.schemaType.readOnly, conditionalPropertyContext) + const fieldGroupState = parent.fieldGroupState?.children?.[key] + const scopedCollapsedPaths = parent.collapsedPaths?.children?.[key] + const scopedCollapsedFieldsets = parent.collapsedFieldSets?.children?.[key] - // Todo: improve error handling at the parent level so that the value here is either undefined or an array - const items = Array.isArray(props.value) ? props.value : [] + const scopedHidden = + parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[key] + const scopedReadOnly = + parent.readOnly === true || parent.readOnly?.value || parent.readOnly?.children?.[key] - const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path)) - const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY - const validation = props.validation - .filter((item) => isEqual(item.path, props.path)) - .map((v) => ({level: v.level, message: v.message, path: v.path})) - const members = items.flatMap((item, index) => - prepareArrayOfPrimitivesMember({arrayItem: item, parent: props, index}), - ) - return { - // checks for changes not only on the array itself, but also on any of its items - changed: props.changed || members.some((m) => m.kind === 'item' && m.item.changed), - value: props.value as T, - readOnly, - schemaType: props.schemaType, - focused: isEqual(props.path, props.focusPath), - focusPath: trimChildPath(props.path, props.focusPath), - path: props.path, - id: toString(props.path), - level: props.level, - validation, - presence, - members, - } -} + const comparisonValue = + (Array.isArray(parent.comparisonValue) && + parent.comparisonValue.find((i) => i._key === arrayItem._key)) || + undefined -function prepareArrayOfObjectsInputState( - props: RawState, -): ArrayOfObjectsFormNode | null { - if (props.level === MAX_FIELD_DEPTH) { - return null - } + const itemState = prepareObjectInputState( + { + schemaType: itemType, + level: itemLevel, + value: arrayItem, + comparisonValue, + changed: isChangedValue(arrayItem, comparisonValue), + path: itemPath, + focusPath: parent.focusPath, + openPath: parent.openPath, + currentUser: parent.currentUser, + collapsedPaths: scopedCollapsedPaths, + collapsedFieldSets: scopedCollapsedFieldsets, + presence: parent.presence, + validation: parent.validation, + fieldGroupState, + readOnly: scopedReadOnly, + hidden: scopedHidden, + }, + false, + ) as ObjectArrayFormNode - const conditionalPropertyContext = { - value: props.value, - parent: props.parent, - document: props.document, - currentUser: props.currentUser, - } - const hidden = resolveConditionalProperty(props.schemaType.hidden, conditionalPropertyContext) + const defaultCollapsedState = getCollapsedWithDefaults(itemType.options, itemLevel) + const collapsed = scopedCollapsedPaths?.value ?? defaultCollapsedState.collapsed + return { + kind: 'item', + key, + index, + open: startsWith(itemPath, parent.openPath), + collapsed: collapsed, + collapsible: true, + parentSchemaType: parent.schemaType, + item: itemState, + } + }, + ) - if (hidden) { - return null - } + /* + * Takes a field in contet of a parent object and returns prepared props for it + */ + const prepareArrayOfPrimitivesMember = memoizePrepareArrayOfPrimitivesMember( + function _prepareArrayOfPrimitivesMember(props) { + const {arrayItem, parent, index} = props + const itemType = getPrimitiveItemType(parent.schemaType, arrayItem) + + const itemPath = pathFor([...parent.path, index]) + const itemValue = (parent.value as unknown[] | undefined)?.[index] as + | string + | boolean + | number + const itemComparisonValue = (parent.comparisonValue as unknown[] | undefined)?.[index] as + | string + | boolean + | number + const itemLevel = parent.level + 1 + + // Best effort attempt to make a stable key for each item in the array + // Since items may be reordered and change at any time, there's no way to reliably address each item uniquely + // This is a "best effort"-attempt at making sure we don't re-use internal state for item inputs + // when items are added to or removed from the array + const key = `${itemType?.name || 'invalid-type'}-${String(index)}` + + if (!itemType) { + return { + kind: 'error', + key, + index, + error: { + type: 'INVALID_ITEM_TYPE', + validTypes: parent.schemaType.of, + resolvedValueType: resolveTypeName(itemType), + value: itemValue, + }, + } + } + + // const scopedHidden = + // parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[field.name] + const scopedReadOnly = + parent.readOnly === true || parent.readOnly?.value || parent.readOnly?.children?.[index] - const readOnly = - props.readOnly || - resolveConditionalProperty(props.schemaType.readOnly, conditionalPropertyContext) + const item = preparePrimitiveInputState({ + ...parent, + path: itemPath, + schemaType: itemType as PrimitiveSchemaType, + level: itemLevel, + value: itemValue, + comparisonValue: itemComparisonValue, + readOnly: scopedReadOnly, + }) - // Todo: improve error handling at the parent level so that the value here is either undefined or an array - const items = Array.isArray(props.value) ? props.value : [] + return { + kind: 'item', + key, + index, + parentSchemaType: parent.schemaType, + open: isEqual(itemPath, parent.openPath), + item, + } + }, + ) - const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path)) - const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY - const validation = props.validation - .filter((item) => isEqual(item.path, props.path)) - .map((v) => ({level: v.level, message: v.message, path: v.path})) + const preparePrimitiveInputState = memoizePreparePrimitiveInputState( + function _preparePrimitiveInputState(props) { + const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path)) + const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY - const members = items.flatMap((item, index) => - prepareArrayOfObjectsMember({ - arrayItem: item, - parent: props, - index, - }), + const validation = props.validation + .filter((item) => isEqual(item.path, props.path)) + .map((v) => ({level: v.level, message: v.message, path: v.path})) + return { + schemaType: props.schemaType, + changed: isChangedValue(props.value, props.comparisonValue), + value: props.value, + level: props.level, + id: toString(props.path), + readOnly: props.readOnly === true || props.readOnly?.value, + focused: isEqual(props.path, props.focusPath), + path: props.path, + presence, + validation, + } as PrimitiveFormNode + }, ) - return { - // checks for changes not only on the array itself, but also on any of its items - changed: props.changed || members.some((m) => m.kind === 'item' && m.item.changed), - value: props.value as T, + function prepareFormState({ + collapsedFieldSets, + collapsedPaths, + comparisonValue, + currentUser, + documentValue, + fieldGroupState, + focusPath, + hidden, + openPath, + presence, readOnly, - schemaType: props.schemaType, - focused: isEqual(props.path, props.focusPath), - focusPath: trimChildPath(props.path, props.focusPath), - path: props.path, - id: toString(props.path), - level: props.level, + schemaType, validation, - presence, - members, - } -} - -/* - * Takes a field in context of a parent object and returns prepared props for it - */ -function prepareArrayOfObjectsMember(props: { - arrayItem: {_key: string} - parent: RawState - index: number -}): ArrayOfObjectsMember { - const {arrayItem, parent, index} = props - - const itemType = getItemType(parent.schemaType, arrayItem) as ObjectSchemaType - - const key = arrayItem._key - - if (!itemType) { - const itemTypeName = resolveTypeName(arrayItem) - return { - kind: 'error', - key, - index, - error: { - type: 'INVALID_ITEM_TYPE', - resolvedValueType: itemTypeName, - value: arrayItem, - validTypes: parent.schemaType.of, - }, - } - } - - const itemPath = pathFor([...parent.path, {_key: key}]) - const itemLevel = parent.level + 1 - - const conditionalPropertyContext = { - value: parent.value, - parent: props.parent, - document: parent.document, - currentUser: parent.currentUser, - } - const readOnly = - parent.readOnly || - resolveConditionalProperty(parent.schemaType.readOnly, conditionalPropertyContext) - - const fieldGroupState = parent.fieldGroupState?.children?.[key] - const scopedCollapsedPaths = parent.collapsedPaths?.children?.[key] - const scopedCollapsedFieldsets = parent.collapsedFieldSets?.children?.[key] - const comparisonValue = - (Array.isArray(parent.comparisonValue) && - parent.comparisonValue.find((i) => i._key === arrayItem._key)) || - undefined - - const itemState = prepareObjectInputState( - { - schemaType: itemType, - level: itemLevel, - document: parent.document, - value: arrayItem, + changesOpen, + }: RootFormStateOptions): ObjectFormNode | null { + return prepareObjectInputState({ + collapsedFieldSets, + collapsedPaths, comparisonValue, - changed: isChangedValue(arrayItem, comparisonValue), - path: itemPath, - focusPath: parent.focusPath, - openPath: parent.openPath, - currentUser: parent.currentUser, - collapsedPaths: scopedCollapsedPaths, - collapsedFieldSets: scopedCollapsedFieldsets, - presence: parent.presence, - validation: parent.validation, + currentUser, + value: documentValue, fieldGroupState, - readOnly, - }, - false, - ) as ObjectArrayFormNode - - const defaultCollapsedState = getCollapsedWithDefaults(itemType.options, itemLevel) - const collapsed = scopedCollapsedPaths?.value ?? defaultCollapsedState.collapsed - return { - kind: 'item', - key, - index, - open: startsWith(itemPath, parent.openPath), - collapsed: collapsed, - collapsible: true, - parentSchemaType: parent.schemaType, - item: itemState, - } -} - -/* - * Takes a field in contet of a parent object and returns prepared props for it - */ -function prepareArrayOfPrimitivesMember(props: { - arrayItem: unknown - parent: RawState - index: number -}): ArrayOfPrimitivesMember { - const {arrayItem, parent, index} = props - const itemType = getPrimitiveItemType(parent.schemaType, arrayItem) - - const itemPath = pathFor([...parent.path, index]) - const itemValue = (parent.value as unknown[] | undefined)?.[index] as string | boolean | number - const itemComparisonValue = (parent.comparisonValue as unknown[] | undefined)?.[index] as - | string - | boolean - | number - const itemLevel = parent.level + 1 - - // Best effort attempt to make a stable key for each item in the array - // Since items may be reordered and change at any time, there's no way to reliably address each item uniquely - // This is a "best effort"-attempt at making sure we don't re-use internal state for item inputs - // when items are added to or removed from the array - const key = `${itemType?.name || 'invalid-type'}-${String(index)}` - - if (!itemType) { - return { - kind: 'error', - key, - index, - error: { - type: 'INVALID_ITEM_TYPE', - validTypes: parent.schemaType.of, - resolvedValueType: resolveTypeName(itemType), - value: itemValue, - }, - } - } - - const readOnly = - parent.readOnly || - resolveConditionalProperty(itemType.readOnly, { - value: itemValue, - parent: parent.value, - document: parent.document, - currentUser: parent.currentUser, + focusPath, + hidden: hidden === false ? EMPTY_OBJECT : hidden, + openPath, + presence, + readOnly: readOnly === false ? EMPTY_OBJECT : readOnly, + schemaType, + validation, + changesOpen, + level: 0, + path: [], }) - - const item = preparePrimitiveInputState({ - ...parent, - path: itemPath, - schemaType: itemType as PrimitiveSchemaType, - level: itemLevel, - value: itemValue, - comparisonValue: itemComparisonValue, - readOnly, - }) - - return { - kind: 'item', - key, - index, - parentSchemaType: parent.schemaType, - open: isEqual(itemPath, parent.openPath), - item, } -} - -function preparePrimitiveInputState( - props: RawState, -): PrimitiveFormNode { - const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path)) - const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY - - const validation = props.validation - .filter((item) => isEqual(item.path, props.path)) - .map((v) => ({level: v.level, message: v.message, path: v.path})) - return { - schemaType: props.schemaType, - changed: isChangedValue(props.value, props.comparisonValue), - value: props.value, - level: props.level, - id: toString(props.path), - readOnly: props.readOnly, - focused: isEqual(props.path, props.focusPath), - path: props.path, - presence, - validation, - } as PrimitiveFormNode -} -/** @internal */ -export type FIXME_SanityDocument = Record + prepareFormState._prepareFieldMember = prepareFieldMember + prepareFormState._prepareFieldMember = prepareFieldMember + prepareFormState._prepareObjectInputState = prepareObjectInputState + prepareFormState._prepareArrayOfPrimitivesInputState = prepareArrayOfPrimitivesInputState + prepareFormState._prepareArrayOfObjectsInputState = prepareArrayOfObjectsInputState + prepareFormState._prepareArrayOfObjectsMember = prepareArrayOfObjectsMember + prepareFormState._prepareArrayOfPrimitivesMember = prepareArrayOfPrimitivesMember + prepareFormState._preparePrimitiveInputState = preparePrimitiveInputState -/** @internal */ -export function prepareFormState( - props: RawState, -): ObjectFormNode | null { - return prepareObjectInputState(props) + return prepareFormState } diff --git a/packages/sanity/src/core/form/store/index.ts b/packages/sanity/src/core/form/store/index.ts index 8abc6d4de7c..68c063ea9f5 100644 --- a/packages/sanity/src/core/form/store/index.ts +++ b/packages/sanity/src/core/form/store/index.ts @@ -1,5 +1,4 @@ export {resolveConditionalProperty} from './conditional-property' -export type {FIXME_SanityDocument} from './formState' // eslint-disable-line camelcase export * from './stateTreeHelper' export * from './types' export * from './useFormState' diff --git a/packages/sanity/src/core/form/store/types/state.ts b/packages/sanity/src/core/form/store/types/state.ts index 81fcdff61ef..a9ca23ecbe3 100644 --- a/packages/sanity/src/core/form/store/types/state.ts +++ b/packages/sanity/src/core/form/store/types/state.ts @@ -2,7 +2,7 @@ * @hidden * @beta */ export interface StateTree { - value: T | undefined + value?: T | undefined children?: { [key: string]: StateTree } diff --git a/packages/sanity/src/core/form/store/useFormState.ts b/packages/sanity/src/core/form/store/useFormState.ts index 9d997e0cd24..a57912da68b 100644 --- a/packages/sanity/src/core/form/store/useFormState.ts +++ b/packages/sanity/src/core/form/store/useFormState.ts @@ -1,14 +1,13 @@ /* eslint-disable camelcase */ import {type ObjectSchemaType, type Path, type ValidationMarker} from '@sanity/types' -import {pathFor} from '@sanity/util/paths' -import {useLayoutEffect, useMemo, useRef} from 'react' +import {useMemo} from 'react' import {type FormNodePresence} from '../../presence' import {useCurrentUser} from '../../store' -import {type FIXME_SanityDocument, prepareFormState} from './formState' +import {createCallbackResolver} from './conditional-property/createCallbackResolver' +import {createPrepareFormState} from './formState' import {type ObjectFormNode, type StateTree} from './types' -import {type DocumentFormNode} from './types/nodes' import {immutableReconcile} from './utils/immutableReconcile' /** @internal */ @@ -17,82 +16,138 @@ export type FormState< S extends ObjectSchemaType = ObjectSchemaType, > = ObjectFormNode +/** @internal */ +export interface UseFormStateOptions { + schemaType: ObjectSchemaType + documentValue: unknown + comparisonValue: unknown + openPath: Path + focusPath: Path + presence: FormNodePresence[] + validation: ValidationMarker[] + fieldGroupState?: StateTree | undefined + collapsedFieldSets?: StateTree | undefined + collapsedPaths?: StateTree | undefined + readOnly?: boolean + changesOpen?: boolean +} + /** @internal */ export function useFormState< T extends {[key in string]: unknown} = {[key in string]: unknown}, S extends ObjectSchemaType = ObjectSchemaType, ->( - schemaType: ObjectSchemaType, - { - comparisonValue, - value, - fieldGroupState, - collapsedFieldSets, - collapsedPaths, - focusPath, - openPath, - presence, - validation, - readOnly, - changesOpen, - }: { - fieldGroupState?: StateTree | undefined - collapsedFieldSets?: StateTree | undefined - collapsedPaths?: StateTree | undefined - value: Partial - comparisonValue: Partial | null - openPath: Path - focusPath: Path - presence: FormNodePresence[] - validation: ValidationMarker[] - changesOpen?: boolean - readOnly?: boolean - }, -): FormState | null { +>({ + comparisonValue, + documentValue, + fieldGroupState, + collapsedFieldSets, + collapsedPaths, + focusPath, + openPath, + presence, + validation, + readOnly: inputReadOnly, + changesOpen, + schemaType, +}: UseFormStateOptions): FormState | null { // note: feel free to move these state pieces out of this hook const currentUser = useCurrentUser() - const prev = useRef(null) + const prepareHiddenState = useMemo(() => createCallbackResolver({property: 'hidden'}), []) + const prepareReadOnlyState = useMemo(() => createCallbackResolver({property: 'readOnly'}), []) + const prepareFormState = useMemo(() => createPrepareFormState(), []) - useLayoutEffect(() => { - prev.current = null - }, [schemaType]) + const reconcileFieldGroupState = useMemo(() => { + let last: StateTree | undefined + return (state: StateTree | undefined) => { + const result = immutableReconcile(last ?? null, state) + last = result + return result + } + }, []) + + const reconciledFieldGroupState = useMemo(() => { + return reconcileFieldGroupState(fieldGroupState) + }, [fieldGroupState, reconcileFieldGroupState]) + + const reconcileCollapsedPaths = useMemo(() => { + let last: StateTree | undefined + return (state: StateTree | undefined) => { + const result = immutableReconcile(last ?? null, state) + last = result + return result + } + }, []) + const reconciledCollapsedPaths = useMemo( + () => reconcileCollapsedPaths(collapsedPaths), + [collapsedPaths, reconcileCollapsedPaths], + ) + + const reconcileCollapsedFieldsets = useMemo(() => { + let last: StateTree | undefined + return (state: StateTree | undefined) => { + const result = immutableReconcile(last ?? null, state) + last = result + return result + } + }, []) + const reconciledCollapsedFieldsets = useMemo( + () => reconcileCollapsedFieldsets(collapsedFieldSets), + [collapsedFieldSets, reconcileCollapsedFieldsets], + ) + + const {hidden, readOnly} = useMemo(() => { + return { + hidden: prepareHiddenState({ + currentUser, + documentValue: documentValue, + schemaType, + }), + readOnly: prepareReadOnlyState({ + currentUser, + documentValue: documentValue, + schemaType, + readOnly: inputReadOnly, + }), + } + }, [ + prepareHiddenState, + currentUser, + documentValue, + schemaType, + prepareReadOnlyState, + inputReadOnly, + ]) return useMemo(() => { - // console.time('derive form state') - const next = prepareFormState({ + return prepareFormState({ schemaType, - document: value, - fieldGroupState, - collapsedFieldSets, - collapsedPaths, - value, + fieldGroupState: reconciledFieldGroupState, + collapsedFieldSets: reconciledCollapsedFieldsets, + collapsedPaths: reconciledCollapsedPaths, + documentValue, comparisonValue, focusPath, openPath, readOnly, - path: pathFor([]), - level: 0, + hidden, currentUser, presence, validation, changesOpen, - }) as ObjectFormNode // TODO: remove type cast - - const reconciled = immutableReconcile(prev.current, next) - prev.current = reconciled - // console.timeEnd('derive form state') - return reconciled + }) as ObjectFormNode }, [ + prepareFormState, schemaType, - value, - fieldGroupState, - collapsedFieldSets, - collapsedPaths, + reconciledFieldGroupState, + reconciledCollapsedFieldsets, + reconciledCollapsedPaths, + documentValue, comparisonValue, focusPath, openPath, readOnly, + hidden, currentUser, presence, validation, diff --git a/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts b/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts index 90796f09109..36cef64f4f7 100644 --- a/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts +++ b/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts @@ -1,132 +1,106 @@ -import {expect, test} from '@jest/globals' +import {beforeEach, expect, jest, test} from '@jest/globals' +import {defineField, defineType} from '@sanity/types' -import {immutableReconcile} from '../immutableReconcile' +import {createSchema} from '../../../../schema/createSchema' +import {createImmutableReconcile} from '../immutableReconcile' + +const immutableReconcile = createImmutableReconcile({decorator: jest.fn}) + +beforeEach(() => { + ;(immutableReconcile as jest.Mock).mockClear() +}) test('it preserves previous value if shallow equal', () => { const prev = {test: 'hi'} const next = {test: 'hi'} - expect(immutableReconcile(prev, next)).toBe(prev) + const reconciled = immutableReconcile(prev, next) + expect(reconciled).toBe(prev) + expect(immutableReconcile).toHaveBeenCalledTimes(2) }) test('it preserves previous value if deep equal', () => { const prev = {arr: [{foo: 'bar'}]} const next = {arr: [{foo: 'bar'}]} - expect(immutableReconcile(prev, next)).toBe(prev) + const reconciled = immutableReconcile(prev, next) + expect(reconciled).toBe(prev) + expect(immutableReconcile).toHaveBeenCalledTimes(4) }) test('it preserves previous nodes that are deep equal', () => { const prev = {arr: [{foo: 'bar'}], x: 1} const next = {arr: [{foo: 'bar'}]} - expect(immutableReconcile(prev, next).arr).toBe(prev.arr) + const reconciled = immutableReconcile(prev, next) + expect(reconciled.arr).toBe(prev.arr) }) test('it keeps equal objects in arrays', () => { const prev = {arr: ['foo', {greet: 'hello'}, {other: []}], x: 1} const next = {arr: ['bar', {greet: 'hello'}, {other: []}]} - expect(immutableReconcile(prev, next).arr).not.toBe(prev.arr) - expect(immutableReconcile(prev, next).arr[1]).toBe(prev.arr[1]) - expect(immutableReconcile(prev, next).arr[2]).toBe(prev.arr[2]) -}) - -test('it handles changing cyclic structures', () => { - const createObject = (differentiator: string) => { - // will be different if differentiator is different - const root: Record = {id: 'root'} - - // will be different if differentiator is different - root.a = {id: 'a'} - - // will be different if differentiator is different - root.a.b = {id: 'b', diff: differentiator} - - // cycle - root.a.b.a = root.a - // will never be different - root.a.b.c = {id: 'c'} - - return root - } - - const prev = createObject('previous') - const next = createObject('next') - const reconciled = immutableReconcile(prev, next) - expect(prev).not.toBe(reconciled) - expect(next).not.toBe(reconciled) - - // A sub object of root has changed, creating new object - expect(next.a).not.toBe(reconciled.a) - - // A sub-object of root.a has changed, creating new object - expect(next.a.b).not.toBe(reconciled.a.b) - - // root.a.b.c is has not changed, therefore reuse. - expect(next.a.b.c).not.toBe(reconciled.a.b.c) - - expect(prev.a.b.c).toBe(reconciled.a.b.c) - - // The new reconcile will retain reconcilable objects also within loops. - expect(prev.a.b.a.b.c).toBe(reconciled.a.b.a.b.c) - - // This is because it retains the loop. - expect(reconciled.a).toBe(reconciled.a.b.a) - expect(prev.a.b.c).toBe(reconciled.a.b.a.b.c) + expect(reconciled.arr).not.toBe(prev.arr) + expect(reconciled.arr[1]).toBe(prev.arr[1]) + expect(reconciled.arr[2]).toBe(prev.arr[2]) + expect(immutableReconcile).toHaveBeenCalledTimes(7) }) -test('it handles non-changing cyclic structures', () => { - const cyclic: Record = {test: 'foo'} - cyclic.self = cyclic - +test('keeps the previous values where they deep equal to the next', () => { const prev = { - cyclic, - arr: [ - {cyclic, value: 'old'}, - {cyclic, value: 'unchanged'}, - ], - other: {cyclic, value: 'unchanged'}, + test: 'hi', + array: ['aloha', {foo: 'bar'}], + object: { + x: {y: 'CHANGE'}, + keep: {foo: 'bar'}, + }, } const next = { - cyclic, - arr: [ - {cyclic, value: 'new'}, - {cyclic, value: 'unchanged'}, - ], - other: {cyclic, value: 'unchanged'}, + test: 'hi', + array: ['aloha', {foo: 'bar'}], + object: { + x: {y: 'CHANGED'}, + keep: {foo: 'bar'}, + }, + new: ['foo', 'bar'], } const reconciled = immutableReconcile(prev, next) - expect(reconciled.arr).not.toBe(prev.arr) - expect(reconciled.arr[1]).toBe(prev.arr[1]) - expect(reconciled.other).toBe(prev.other) + + expect(reconciled).not.toBe(prev) + expect(reconciled).not.toBe(next) + + expect(reconciled.array).toBe(prev.array) + expect(reconciled.object.keep).toBe(prev.object.keep) + expect(immutableReconcile).toHaveBeenCalledTimes(11) }) -test('keeps the previous values where they deep equal to the next', () => { +test('skips reconciling if the previous sub-values are already referentially equal', () => { + const keep = {foo: 'bar'} const prev = { test: 'hi', - array: ['aloha', {foo: 'bar'}], + array: ['aloha', keep], object: { x: {y: 'CHANGE'}, - keep: {foo: 'bar'}, + keep, }, } const next = { test: 'hi', - array: ['aloha', {foo: 'bar'}], + array: ['aloha', keep], object: { x: {y: 'CHANGED'}, - keep: {foo: 'bar'}, + keep, }, new: ['foo', 'bar'], } - const result = immutableReconcile(prev, next) + const reconciled = immutableReconcile(prev, next) - expect(result).not.toBe(prev) - expect(result).not.toBe(next) + expect(reconciled).not.toBe(prev) + expect(reconciled).not.toBe(next) - expect(result.array).toBe(prev.array) - expect(result.object.keep).toBe(prev.object.keep) + expect(reconciled.array).toBe(prev.array) + expect(reconciled.object.keep).toBe(prev.object.keep) + expect(immutableReconcile).toHaveBeenCalledTimes(9) }) test('does not mutate any of its input', () => { @@ -172,6 +146,33 @@ test('returns new array when previous and next has different length', () => { expect(immutableReconcile(lessItems, moreItems)).not.toBe(lessItems) }) +test('does not reconcile schema type values', () => { + const schema = createSchema({ + name: 'default', + types: [ + defineType({ + name: 'myType', + type: 'document', + fields: [defineField({name: 'myString', type: 'string'})], + }), + defineType({ + name: 'myOtherType', + type: 'document', + fields: [defineField({name: 'myString2', type: 'string'})], + }), + ], + }) + const schemaType = schema.get('myType')! + const otherSchemaType = schema.get('myOtherType')! + + const prev = {schemaType} + const next = {schemaType: otherSchemaType} + + const reconciled = immutableReconcile(prev, next) + expect(reconciled.schemaType).toBe(otherSchemaType) + expect(immutableReconcile).toHaveBeenCalledTimes(2) +}) + test('returns latest non-enumerable value', () => { const prev = {enumerable: true} const next = {enumerable: true} diff --git a/packages/sanity/src/core/form/store/utils/createMemoizer.ts b/packages/sanity/src/core/form/store/utils/createMemoizer.ts new file mode 100644 index 00000000000..e440148db11 --- /dev/null +++ b/packages/sanity/src/core/form/store/utils/createMemoizer.ts @@ -0,0 +1,42 @@ +import {type Path} from '@sanity/types' +import {toString} from '@sanity/util/paths' + +export type FunctionDecorator unknown> = ( + fn: TFunction, +) => TFunction + +export interface MemoizerOptions unknown> { + getPath: (...args: Parameters) => Path + hashInput: (...args: Parameters) => unknown + decorator: ((fn: TFunction) => TFunction) | undefined +} + +function identity(t: T) { + return t +} + +export function createMemoizer unknown>({ + getPath, + hashInput, + decorator = identity, +}: MemoizerOptions): FunctionDecorator { + const cache = new Map}>() + + function memoizer(fn: TFunction): TFunction { + function memoizedFn(...args: Parameters) { + const path = toString(getPath(...args)) + const hashed = hashInput(...args) + const serializedHash = JSON.stringify(hashed) + const cached = cache.get(path) + if (serializedHash === cached?.serializedHash) return cached.result + + const result = fn(...args) as ReturnType + cache.set(path, {serializedHash, result}) + return result + } + + return decorator(memoizedFn as TFunction) + } + + return memoizer +} diff --git a/packages/sanity/src/core/form/store/utils/getId.ts b/packages/sanity/src/core/form/store/utils/getId.ts new file mode 100644 index 00000000000..745b867c526 --- /dev/null +++ b/packages/sanity/src/core/form/store/utils/getId.ts @@ -0,0 +1,41 @@ +import {nanoid} from 'nanoid' + +const idCache = new WeakMap() +const undefinedKey = {key: 'GetIdUndefined'} +const nullKey = {key: 'GetIdNull'} + +/** + * Generates a stable ID for various types of values, including `undefined`, `null`, objects, functions, and symbols. + * + * - **Primitives (string, number, boolean):** The value itself is used as the ID. + * - **Undefined and null:** Special symbols (`undefinedKey` and `nullKey`) are used to generate unique IDs. + * - **Objects and functions:** An ID is generated using the `nanoid` library and cached in a `WeakMap` for stable future retrieval. + * + * This function is used to reconcile inputs in `prepareFormState` immutably, allowing IDs to be generated and cached based + * on the reference of the object. This ensures that memoization functions can use these IDs for consistent hashing without + * recalculating on each call, as the inputs themselves are immutably edited. + * + * @internal + */ +export function getId(value: unknown): string { + switch (typeof value) { + case 'undefined': { + return getId(undefinedKey) + } + case 'function': + case 'object': + case 'symbol': { + if (value === null) return getId(nullKey) + + const cached = idCache.get(value as object) + if (cached) return cached + + const id = nanoid() + idCache.set(value as object, id) + return id + } + default: { + return `${value}` + } + } +} diff --git a/packages/sanity/src/core/form/store/utils/immutableReconcile.ts b/packages/sanity/src/core/form/store/utils/immutableReconcile.ts index 8e8da4d12f1..1a65981c43e 100644 --- a/packages/sanity/src/core/form/store/utils/immutableReconcile.ts +++ b/packages/sanity/src/core/form/store/utils/immutableReconcile.ts @@ -1,78 +1,97 @@ -/** - * Reconciles two versions of a state tree by iterating over the next and deep comparing against the next towards the previous. - * Wherever identical values are found, the previous value is kept, preserving object identities for arrays and objects where possible - * @param previous - the previous value - * @param next - the next/current value - */ -export function immutableReconcile(previous: unknown, next: T): T { - return _immutableReconcile(previous, next, new WeakMap()) +import {type SchemaType} from '@sanity/types' + +function isPlainObject(obj: unknown): boolean { + return obj !== null && typeof obj === 'object' && obj.constructor === Object +} + +function isSchemaType(obj: unknown): obj is SchemaType { + if (typeof obj !== 'object') return false + if (!obj) return false + if (!('jsonType' in obj) || typeof obj.jsonType !== 'string') return false + if (!('name' in obj) || typeof obj.name !== 'string') return false + return true } -function _immutableReconcile( - previous: unknown, - next: T, - /** - * Keep track of visited nodes to prevent infinite recursion in case of circular structures - */ - parents: WeakMap, -): T { - if (previous === next) return previous as T - - if (parents.has(next)) { - return parents.get(next) - } - - // eslint-disable-next-line no-eq-null - if (previous == null || next == null) return next - - const prevType = typeof previous - const nextType = typeof next - - // Different types - if (prevType !== nextType) return next - - if (Array.isArray(next)) { - assertType(previous) - assertType(next) - - let allEqual = previous.length === next.length - const result: unknown[] = [] - parents.set(next, result) - for (let index = 0; index < next.length; index++) { - const nextItem = _immutableReconcile(previous[index], next[index], parents) - - if (nextItem !== previous[index]) { - allEqual = false +interface ImmutableReconcile { + (prev: T | null, curr: T): T +} + +export interface CreateImmutableReconcileOptions { + decorator?: (fn: ImmutableReconcile) => ImmutableReconcile +} + +function identity(t: T) { + return t +} + +export function createImmutableReconcile({ + decorator = identity, +}: CreateImmutableReconcileOptions = {}): (prev: T | null, curr: T) => T { + const immutableReconcile = decorator(function _immutableReconcile(prev: T | null, curr: T): T { + if (prev === curr) return curr + if (prev === null) return curr + if (typeof prev !== 'object' || typeof curr !== 'object') return curr + + if (Array.isArray(prev) && Array.isArray(curr)) { + if (prev.length !== curr.length) return curr + + const reconciled = curr.map((item, index) => immutableReconcile(prev[index], item)) + if (reconciled.every((item, index) => item === prev[index])) return prev + return reconciled as T + } + + // skip these, they're recursive structures and will cause stack overflows + // they're stable anyway + if (isSchemaType(prev) || isSchemaType(curr)) return curr + + // skip these as well + if (!isPlainObject(prev) || !isPlainObject(curr)) return curr + + const prevObj = prev as Record + const currObj = curr as Record + + const reconciled: Record = {} + let changed = false + + const enumerableKeys = new Set(Object.keys(currObj)) + + for (const key of Object.getOwnPropertyNames(currObj)) { + if (key in prevObj) { + const reconciledValue = immutableReconcile(prevObj[key], currObj[key]) + if (enumerableKeys.has(key)) { + reconciled[key] = reconciledValue + } else { + Object.defineProperty(reconciled, key, { + value: reconciledValue, + enumerable: false, + }) + } + changed = changed || reconciledValue !== prevObj[key] + } else { + if (enumerableKeys.has(key)) { + reconciled[key] = currObj[key] + } else { + Object.defineProperty(reconciled, key, { + value: currObj[key], + enumerable: false, + }) + } + changed = true } - result[index] = nextItem } - parents.set(next, allEqual ? previous : result) - return (allEqual ? previous : result) as any - } - - if (typeof next === 'object') { - assertType>(previous) - assertType>(next) - - const nextKeys = Object.getOwnPropertyNames(next) - let allEqual = Object.getOwnPropertyNames(previous).length === nextKeys.length - const result: Record = {} - parents.set(next, result) - for (const key of nextKeys) { - const nextValue = next.propertyIsEnumerable(key) - ? _immutableReconcile(previous[key], next[key]!, parents) - : next[key] - if (nextValue !== previous[key]) { - allEqual = false + + // Check if any keys were removed + for (const key of Object.getOwnPropertyNames(prevObj)) { + if (!(key in currObj)) { + changed = true + break } - result[key] = nextValue } - parents.set(next, allEqual ? previous : result) - return (allEqual ? previous : result) as T - } - return next + + return changed ? (reconciled as T) : prev + }) + + return immutableReconcile } -// just some typescript trickery get type assertion -// eslint-disable-next-line @typescript-eslint/no-empty-function, no-empty-function -function assertType(value: unknown): asserts value is T {} +export const immutableReconcile = createImmutableReconcile() diff --git a/packages/sanity/src/core/form/studio/FormBuilder.test.tsx b/packages/sanity/src/core/form/studio/FormBuilder.test.tsx index f486e994f4d..f9bf868b7e9 100644 --- a/packages/sanity/src/core/form/studio/FormBuilder.test.tsx +++ b/packages/sanity/src/core/form/studio/FormBuilder.test.tsx @@ -54,7 +54,7 @@ describe('FormBuilder', () => { const focusPath: Path = [] const openPath: Path = [] - const value = {_id: 'test', _type: 'test'} + const documentValue = {_id: 'test', _type: 'test'} const onChange = jest.fn() const onFieldGroupSelect = jest.fn() @@ -79,9 +79,10 @@ describe('FormBuilder', () => { const patchChannel = useMemo(() => createPatchChannel(), []) - const formState = useFormState(schemaType, { - value, - comparisonValue: value, + const formState = useFormState({ + schemaType, + documentValue, + comparisonValue: documentValue, focusPath, collapsedPaths: undefined, collapsedFieldSets: undefined, @@ -150,7 +151,7 @@ describe('FormBuilder', () => { const focusPath: Path = [] const openPath: Path = [] - const value = {_id: 'test', _type: 'test'} + const documentValue = {_id: 'test', _type: 'test'} const onChange = jest.fn() const onFieldGroupSelect = jest.fn() @@ -175,9 +176,10 @@ describe('FormBuilder', () => { const patchChannel = useMemo(() => createPatchChannel(), []) - const formState = useFormState(schemaType, { - value, - comparisonValue: value, + const formState = useFormState({ + schemaType, + documentValue, + comparisonValue: documentValue, focusPath, collapsedPaths: undefined, collapsedFieldSets: undefined, diff --git a/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts b/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts index e8efc9f7856..391ffaa5fc2 100644 --- a/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts +++ b/packages/sanity/src/core/form/studio/inputs/client-adapters/reference.ts @@ -131,7 +131,10 @@ export function getReferenceInfo( ) const value$ = combineLatest([draftPreview$, publishedPreview$]).pipe( - map(([draft, published]) => ({draft, published})), + map(([draft, published]) => ({ + draft, + published, + })), ) return value$.pipe( @@ -144,7 +147,6 @@ export function getReferenceInfo( pairAvailability.published.reason === 'PERMISSION_DENIED' ? PERMISSION_DENIED : NOT_FOUND - return { type: typeName, id: publishedId, @@ -201,7 +203,7 @@ export function referenceSearch( }) return search(textTerm, {includeDrafts: true}).pipe( map(({hits}) => hits.map(({hit}) => hit)), - map(collate), + map((docs) => collate(docs)), // pick the 100 best matches map((collated) => collated.slice(0, 100)), mergeMap((collated) => { diff --git a/packages/sanity/src/core/form/studio/inputs/crossDatasetReference/datastores/search.ts b/packages/sanity/src/core/form/studio/inputs/crossDatasetReference/datastores/search.ts index f588326d102..2b8fe92f93d 100644 --- a/packages/sanity/src/core/form/studio/inputs/crossDatasetReference/datastores/search.ts +++ b/packages/sanity/src/core/form/studio/inputs/crossDatasetReference/datastores/search.ts @@ -31,7 +31,7 @@ export function search( isCrossDataset: true, }).pipe( map(({hits}) => hits.map(({hit}) => hit)), - map(collate), + map((docs) => collate(docs)), map((collated) => collated.map((entry) => ({ id: entry.id, diff --git a/packages/sanity/src/core/hooks/useValidationStatus.ts b/packages/sanity/src/core/hooks/useValidationStatus.ts index e1ad1e92d74..165e059d932 100644 --- a/packages/sanity/src/core/hooks/useValidationStatus.ts +++ b/packages/sanity/src/core/hooks/useValidationStatus.ts @@ -1,7 +1,8 @@ import {useMemo} from 'react' import {useObservable} from 'react-rx' -import {useDocumentStore, type ValidationStatus} from '../store' +import {useDocumentStore} from '../store' +import {type ValidationStatus} from '../validation' const INITIAL: ValidationStatus = {validation: [], isValidating: false} diff --git a/packages/sanity/src/core/search/text-search/createTextSearch.ts b/packages/sanity/src/core/search/text-search/createTextSearch.ts index 1afbd3a20c2..a005d7eefb3 100644 --- a/packages/sanity/src/core/search/text-search/createTextSearch.ts +++ b/packages/sanity/src/core/search/text-search/createTextSearch.ts @@ -142,6 +142,7 @@ export const createTextSearch: SearchStrategyFactory = ( searchOptions.includeDrafts === false && "!(_id in path('drafts.**'))", factoryOptions.filter ? `(${factoryOptions.filter})` : false, searchTerms.filter ? `(${searchTerms.filter})` : false, + '!(_id in path("versions.**"))', ].filter((baseFilter): baseFilter is string => Boolean(baseFilter)) const textSearchParams: TextSearchParams = { diff --git a/packages/sanity/src/core/search/weighted/createSearchQuery.test.ts b/packages/sanity/src/core/search/weighted/createSearchQuery.test.ts index 7cd847d75dc..93b56c5f38e 100644 --- a/packages/sanity/src/core/search/weighted/createSearchQuery.test.ts +++ b/packages/sanity/src/core/search/weighted/createSearchQuery.test.ts @@ -46,7 +46,7 @@ describe('createSearchQuery', () => { expect(query).toEqual( `// findability-mvi:${FINDABILITY_MVI}\n` + - '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0)]' + + '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && !(_id in path("versions.**"))]' + '| order(_id asc)' + '[0...$__limit]' + '{_type, _id, ...select(_type == "basic-schema-test" => { "w0": _id,"w1": _type,"w2": title })}', @@ -106,7 +106,7 @@ describe('createSearchQuery', () => { }) expect(query).toContain( - '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0 || object.field match $t0)]', + '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0 || object.field match $t0) && !(_id in path("versions.**"))]', ) }) @@ -117,7 +117,7 @@ describe('createSearchQuery', () => { }) expect(query).toContain( - '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && (_id match $t1 || _type match $t1 || title match $t1)]', + '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && (_id match $t1 || _type match $t1 || title match $t1) && !(_id in path("versions.**"))]', ) expect(params.t0).toEqual('term0*') expect(params.t1).toEqual('term1*') @@ -147,7 +147,7 @@ describe('createSearchQuery', () => { const result = [ `// findability-mvi:${FINDABILITY_MVI}\n` + - '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0)]{_type, _id, object{field}}', + '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && !(_id in path("versions.**"))]{_type, _id, object{field}}', '|order(_id asc)[0...$__limit]', '{_type, _id, ...select(_type == "basic-schema-test" => { "w0": _id,"w1": _type,"w2": title })}', ].join('') @@ -193,7 +193,7 @@ describe('createSearchQuery', () => { ) expect(query).toContain( - '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && (randomCondition == $customParam)]', + '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && (randomCondition == $customParam) && !(_id in path("versions.**"))]', ) expect(params.customParam).toEqual('custom') }) @@ -241,7 +241,7 @@ describe('createSearchQuery', () => { expect(query).toEqual( `// findability-mvi:${FINDABILITY_MVI}\n` + - '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0)]' + + '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && !(_id in path("versions.**"))]' + '| order(exampleField desc)' + '[0...$__limit]' + '{_type, _id, ...select(_type == "basic-schema-test" => { "w0": _id,"w1": _type,"w2": title })}', @@ -275,7 +275,7 @@ describe('createSearchQuery', () => { const result = [ `// findability-mvi:${FINDABILITY_MVI}\n`, - '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0)]| ', + '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && !(_id in path("versions.**"))]| ', 'order(exampleField desc,anotherExampleField asc,lower(mapWithField) asc)', '[0...$__limit]{_type, _id, ...select(_type == "basic-schema-test" => { "w0": _id,"w1": _type,"w2": title })}', ].join('') @@ -291,7 +291,7 @@ describe('createSearchQuery', () => { expect(query).toEqual( `// findability-mvi:${FINDABILITY_MVI}\n` + - '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0)]' + + '*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && !(_id in path("versions.**"))]' + '| order(_id asc)' + '[0...$__limit]' + '{_type, _id, ...select(_type == "basic-schema-test" => { "w0": _id,"w1": _type,"w2": title })}', @@ -403,7 +403,7 @@ describe('createSearchQuery', () => { * This is an improvement over before, where an illegal term was used (number-as-string, ala ["0"]), * which lead to no hits at all. */ `// findability-mvi:${FINDABILITY_MVI}\n` + - '*[_type in $__types && (_id match $t0 || _type match $t0 || cover[].cards[].title match $t0) && (_id match $t1 || _type match $t1 || cover[].cards[].title match $t1)]' + + '*[_type in $__types && (_id match $t0 || _type match $t0 || cover[].cards[].title match $t0) && (_id match $t1 || _type match $t1 || cover[].cards[].title match $t1) && !(_id in path("versions.**"))]' + '| order(_id asc)' + '[0...$__limit]' + // at this point we could refilter using cover[0].cards[0].title. diff --git a/packages/sanity/src/core/search/weighted/createSearchQuery.ts b/packages/sanity/src/core/search/weighted/createSearchQuery.ts index 605660351ae..15b924da74d 100644 --- a/packages/sanity/src/core/search/weighted/createSearchQuery.ts +++ b/packages/sanity/src/core/search/weighted/createSearchQuery.ts @@ -135,6 +135,7 @@ export function createSearchQuery( ...createConstraints(terms, specs), filter ? `(${filter})` : '', searchTerms.filter ? `(${searchTerms.filter})` : '', + '!(_id in path("versions.**"))', ].filter(Boolean) const selections = specs.map((spec) => { diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/serverOperations/patch.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/serverOperations/patch.ts index 4fce51f99a3..5592de150bf 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/serverOperations/patch.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/serverOperations/patch.ts @@ -44,7 +44,11 @@ export const patch: OperationImpl<[patches: any[], initialDocument?: Record { - return reduceJSON( - obj, - (acc, node) => { - if (isReference(node)) { - acc.add(node._ref) - } - return acc - }, - new Set(), - ) -} - -const EMPTY_VALIDATION: ValidationMarker[] = [] - -type GetDocumentExists = NonNullable - -type ObserveDocumentPairAvailability = (id: string) => Observable - -const listenDocumentExists = ( - observeDocumentAvailability: ObserveDocumentPairAvailability, - id: string, -): Observable => - observeDocumentAvailability(id).pipe(map(({published}) => published.available)) - // throttle delay for document updates (i.e. time between responding to changes in the current document) const DOC_UPDATE_DELAY = 200 -// throttle delay for referenced document updates (i.e. time between responding to changes in referenced documents) -const REF_UPDATE_DELAY = 1000 - function shareLatestWithRefCount() { return shareReplay({bufferSize: 1, refCount: true}) } @@ -92,7 +27,7 @@ export const validation = memoize( ctx: { client: SanityClient getClient: (options: SourceClientOptions) => SanityClient - observeDocumentPairAvailability: ObserveDocumentPairAvailability + observeDocumentPairAvailability: (id: string) => Observable schema: Schema i18n: LocaleSource serverActionsEnabled: Observable @@ -114,81 +49,7 @@ export const validation = memoize( shareLatestWithRefCount(), ) - const referenceIds$ = document$.pipe( - map((document) => findReferenceIds(document)), - mergeMap((ids) => from(ids)), - ) - - // Note: we only use this to trigger a re-run of validation when a referenced document is published/unpublished - const referenceExistence$ = referenceIds$.pipe( - groupBy((id) => id, {duration: () => timer(1000 * 60 * 30)}), - mergeMap((id$) => - id$.pipe( - distinct(), - mergeMap((id) => - listenDocumentExists(ctx.observeDocumentPairAvailability, id).pipe( - map( - // eslint-disable-next-line max-nested-callbacks - (result) => [id, result] as const, - ), - ), - ), - ), - ), - scan((acc: Record, [id, result]): Record => { - if (acc[id] === result) { - return acc - } - return {...acc, [id]: result} - }, {}), - distinctUntilChanged(shallowEquals), - shareLatestWithRefCount(), - ) - - // Provided to individual validation functions to support using existence of a weakly referenced document - // as part of the validation rule (used by references in place) - const getDocumentExists: GetDocumentExists = ({id}) => - lastValueFrom( - referenceExistence$.pipe( - // If the id is not present as key in the `referenceExistence` map it means it's existence status - // isn't yet loaded, so we want to wait until it is - first((referenceExistence) => id in referenceExistence), - map((referenceExistence) => referenceExistence[id]), - ), - ) - - const referenceDocumentUpdates$ = referenceExistence$.pipe( - // we'll skip the first emission since the document already gets an initial validation pass - // we're only interested in updates in referenced documents after that - skip(1), - throttleTime(REF_UPDATE_DELAY, asyncScheduler, {leading: true, trailing: true}), - ) - - return combineLatest([document$, concat(of(null), referenceDocumentUpdates$)]).pipe( - map(([document]) => document), - exhaustMapWithTrailing((document) => { - return defer(() => { - if (!document?._type) { - return of({validation: EMPTY_VALIDATION, isValidating: false}) - } - return concat( - of({isValidating: true, revision: document._rev}), - validateDocumentObservable({ - document, - getClient: ctx.getClient, - getDocumentExists, - i18n: ctx.i18n, - schema: ctx.schema, - environment: 'studio', - }).pipe( - map((validationMarkers) => ({validation: validationMarkers, isValidating: false})), - ), - ) - }) - }), - scan((acc, next) => ({...acc, ...next}), INITIAL_VALIDATION_STATUS), - shareLatestWithRefCount(), - ) + return validateDocumentWithReferences(ctx, document$) }, (ctx, idPair, typeName) => { return memoizeKeyGen(ctx.client, idPair, typeName) diff --git a/packages/sanity/src/core/store/_legacy/document/document-store.ts b/packages/sanity/src/core/store/_legacy/document/document-store.ts index 64ed02d48cc..7fcc203c829 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-store.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-store.ts @@ -8,7 +8,8 @@ import {type LocaleSource} from '../../../i18n' import {type DocumentPreviewStore} from '../../../preview' import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../studioClient' import {type Template} from '../../../templates' -import {getDraftId, isDraftId} from '../../../util' +import {getDraftId, isDraftId, isVersionId} from '../../../util' +import {type ValidationStatus} from '../../../validation' import {type HistoryStore} from '../history' import {checkoutPair, type DocumentVersionEvent, type Pair} from './document-pair/checkoutPair' import {consistencyStatus} from './document-pair/consistencyStatus' @@ -21,7 +22,7 @@ import { type OperationSuccess, } from './document-pair/operationEvents' import {type OperationsAPI} from './document-pair/operations' -import {validation, type ValidationStatus} from './document-pair/validation' +import {validation} from './document-pair/validation' import {getInitialValueStream, type InitialValueMsg, type InitialValueOptions} from './initialValue' import {listenQuery, type ListenQueryOptions} from './listenQuery' import {resolveTypeForDocument} from './resolveTypeForDocument' @@ -33,6 +34,9 @@ import {type IdPair} from './types' export type QueryParams = Record function getIdPairFromPublished(publishedId: string): IdPair { + if (isVersionId(publishedId)) { + throw new Error('editOpsOf does not expect a version id.') + } if (isDraftId(publishedId)) { throw new Error('editOpsOf does not expect a draft id.') } diff --git a/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrialButton.tsx b/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrialButton.tsx index e03fc3ec031..61bce6d386f 100644 --- a/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrialButton.tsx +++ b/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrialButton.tsx @@ -93,7 +93,7 @@ export const FreeTrialButtonTopbar = forwardRef(function FreeTrialButtonTopbar( }) export const FreeTrialButtonSidebar = forwardRef(function FreeTrialButtonSidebar( - {toggleShowContent, daysLeft}: Omit, + {toggleShowContent, daysLeft}: Pick, ref: Ref, ) { const {t} = useTranslation() diff --git a/packages/sanity/src/core/studio/copyPaste/utils.ts b/packages/sanity/src/core/studio/copyPaste/utils.ts index 9f521f51097..79b9bf6e8b1 100644 --- a/packages/sanity/src/core/studio/copyPaste/utils.ts +++ b/packages/sanity/src/core/studio/copyPaste/utils.ts @@ -26,7 +26,6 @@ const MIMETYPE_PLAINTEXT = 'text/plain' const SUPPORTS_SANITY_CLIPBOARD_MIMETYPE = typeof ClipboardItem !== 'undefined' && 'supports' in ClipboardItem && - // @ts-expect-error `ClipboardItem.supports` does not have types yet ClipboardItem.supports(MIMETYPE_SANITY_CLIPBOARD) /** diff --git a/packages/sanity/src/core/studio/packageVersionStatus/PackageVersionStatusProvider.tsx b/packages/sanity/src/core/studio/packageVersionStatus/PackageVersionStatusProvider.tsx index 8a2a43dea2a..db7fd4163bd 100644 --- a/packages/sanity/src/core/studio/packageVersionStatus/PackageVersionStatusProvider.tsx +++ b/packages/sanity/src/core/studio/packageVersionStatus/PackageVersionStatusProvider.tsx @@ -1,4 +1,4 @@ -import {Box, Stack, useToast} from '@sanity/ui' +import {Box, useToast} from '@sanity/ui' import {type ReactNode, useCallback, useEffect, useRef} from 'react' import {SANITY_VERSION} from 'sanity' import semver from 'semver' @@ -10,7 +10,7 @@ import {checkForLatestVersions} from './checkForLatestVersions' // How often to to check last timestamp. at 30 min, should fetch new version const REFRESH_INTERVAL = 1000 * 30 // every 30 seconds -const SHOW_TOAST_FREQUENCY = 1000 * 60 * 30 //half hour +const SHOW_TOAST_FREQUENCY = 1000 * 60 * 30 // half hour const currentPackageVersions: Record = { sanity: SANITY_VERSION, @@ -32,17 +32,15 @@ export function PackageVersionStatusProvider({children}: {children: ReactNode}) id: 'new-package-available', title: t('package-version.new-package-available.title'), description: ( - - -