diff --git a/dev/test-create-integration-studio/.depcheckignore.json b/dev/test-create-integration-studio/.depcheckignore.json new file mode 100644 index 00000000000..0958cae05dc --- /dev/null +++ b/dev/test-create-integration-studio/.depcheckignore.json @@ -0,0 +1,3 @@ +{ + "ignore": ["styled-components", "react", "react-dom", "sanity"] +} diff --git a/dev/test-create-integration-studio/.gitignore b/dev/test-create-integration-studio/.gitignore new file mode 100644 index 00000000000..9b1c8b133c9 --- /dev/null +++ b/dev/test-create-integration-studio/.gitignore @@ -0,0 +1 @@ +/dist diff --git a/dev/test-create-integration-studio/package.json b/dev/test-create-integration-studio/package.json new file mode 100644 index 00000000000..ad3b6ccf5cd --- /dev/null +++ b/dev/test-create-integration-studio/package.json @@ -0,0 +1,22 @@ +{ + "name": "test-create-integration-studio", + "version": "3.61.0", + "private": true, + "license": "MIT", + "author": "Sanity.io ", + "scripts": { + "build": "../.bin/sanity build", + "clean": "rimraf .sanity dist", + "deploy": "npx sanity deploy", + "dev": "../.bin/sanity dev", + "lint": "eslint .", + "start": "../.bin/sanity start" + }, + "dependencies": { + "@sanity/code-input": "^4.1.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sanity": "workspace:*", + "styled-components": "^6.1.0" + } +} diff --git a/dev/test-create-integration-studio/sanity.cli.ts b/dev/test-create-integration-studio/sanity.cli.ts new file mode 100644 index 00000000000..89fd9fa0307 --- /dev/null +++ b/dev/test-create-integration-studio/sanity.cli.ts @@ -0,0 +1,11 @@ +import {defineCliConfig} from 'sanity/cli' + +export default defineCliConfig({ + api: { + projectId: 'ppsg7ml5', + dataset: 'test', + }, + + studioHost: 'create-integration-test', + autoUpdates: false, +}) diff --git a/dev/test-create-integration-studio/sanity.config.ts b/dev/test-create-integration-studio/sanity.config.ts new file mode 100644 index 00000000000..146861de727 --- /dev/null +++ b/dev/test-create-integration-studio/sanity.config.ts @@ -0,0 +1,22 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import {codeInput} from '@sanity/code-input' +import {defineConfig} from 'sanity' +import {structureTool} from 'sanity/structure' + +import {schemaTypes} from './schema' + +export default defineConfig({ + plugins: [structureTool(), codeInput()], + title: 'Strict', + name: 'default', + projectId: 'ppsg7ml5', + dataset: 'test', + schema: {types: schemaTypes}, + + beta: { + create: { + startInCreateEnabled: true, + fallbackStudioOrigin: 'create-integration-test.sanity.studio', + }, + }, +}) diff --git a/dev/test-create-integration-studio/schema.ts b/dev/test-create-integration-studio/schema.ts new file mode 100644 index 00000000000..70edcbcb80d --- /dev/null +++ b/dev/test-create-integration-studio/schema.ts @@ -0,0 +1,354 @@ +import {defineArrayMember, defineField, defineType, type FieldGroupDefinition} from 'sanity' + +export const mainGroup: FieldGroupDefinition = { + name: 'main', + title: 'Common fields', +} +export const seoGroup: FieldGroupDefinition = { + name: 'seo', + title: 'SEO fields', +} + +export const schemaTypes = [ + defineType({ + title: 'Documentation Article', + name: 'create-test-article', + type: 'document', + groups: [mainGroup, seoGroup], + fieldsets: [{name: 'switches', title: 'Article settings', options: {columns: 2}}], + fields: [ + defineField({ + name: 'title', + title: 'Title', + type: 'string', + description: 'Main header and page title.', + group: [mainGroup.name], + }), + defineField({ + name: 'description', + title: 'Description', + type: 'text', + rows: 3, + description: 'Lede and page summary.', + group: [mainGroup.name], + }), + defineField({ + type: 'image', + name: 'image', + title: 'Image', + description: 'Primary image for content.', + group: [mainGroup.name], + }), + defineField({ + name: 'authors', + title: 'Authors', + type: 'array', + description: 'One or more content authors', + validation: (Rule) => Rule.required().min(1).unique(), + of: [ + defineArrayMember({ + type: 'object', + name: 'authors', + title: 'Authors', + fields: [ + defineField({ + type: 'string', + name: 'name', + title: 'Full Name', + }), + defineField({ + type: 'string', + name: 'email', + title: 'Email', + }), + ], + }), + ], + group: [mainGroup.name], + }), + { + title: 'Slug', + name: 'slug', + type: 'slug', + description: 'Last part of the page URL.', + options: { + source: 'title', + auto: true, + }, + }, + { + title: 'Hide this article?', + name: 'hidden', + type: 'boolean', + description: 'Turn this on to prevent this document from showing up in search results.', + }, + { + title: 'Enterprise Feature', + name: 'enterprise', + type: 'boolean', + description: 'This article describes a feature only available on enterprise plans', + }, + { + title: 'Experimental Feature', + name: 'experimental', + type: 'boolean', + description: + 'This article describes a feature that should be considered experimental, where the API and feature set might change', + }, + { + title: 'Body', + name: 'body', + type: 'blockContent', + }, + { + title: 'Search keywords', + name: 'keywords', + type: 'array', + of: [{type: 'string'}], + options: { + layout: 'tags', + }, + description: 'A list of keywords to supplement search index.', + }, + { + title: 'Related Articles', + name: 'articles', + type: 'array', + of: [ + { + type: 'reference', + to: [{type: 'create-test-article'}], + }, + ], + }, + defineField({ + name: 'seoTitle', + title: 'SEO Title', + type: 'string', + description: 'Will override title used for SEO and social media previews.', + group: [seoGroup.name], + }), + defineField({ + name: 'seoDescription', + title: 'SEO Description', + type: 'text', + rows: 3, + description: 'Will override description used for SEO and social media previews.', + group: [seoGroup.name], + }), + defineField({ + name: 'seoImage', + title: 'SEO Image', + type: 'image', + description: 'Will override image used for SEO and social media previews.', + group: [seoGroup.name], + }), + ], + }), + defineType({ + title: 'Block Content', + name: 'blockContent', + type: 'array', + of: [ + { + title: 'Block', + type: 'block', + styles: [ + {title: 'Normal', value: 'normal'}, + {title: 'H1', value: 'h1'}, + {title: 'H2', value: 'h2'}, + {title: 'H3', value: 'h3'}, + {title: 'H4', value: 'h4'}, + {title: 'Quote', value: 'blockquote'}, + ], + marks: { + decorators: [ + {title: 'Strong', value: 'strong'}, + {title: 'Emphasis', value: 'em'}, + {title: 'Code', value: 'code'}, + ], + annotations: [ + { + title: 'Abbreviation', + name: 'abbreviation', + type: 'object', + description: 'Add definitions for abbreviations, initialisms, and acronyms', + fields: [ + { + title: 'Expansion', + name: 'title', + type: 'string', + description: 'Spell out the full term', + }, + ], + }, + ], + }, + }, + { + title: 'Call to action', + name: 'callToAction', + type: 'object', + fields: [ + { + title: 'Label', + name: 'label', + type: 'string', + }, + { + title: 'Url', + name: 'url', + type: 'string', + }, + ], + }, + { + title: 'Image', + type: 'image', + options: { + hotspot: true, + }, + preview: { + select: { + imageUrl: 'asset.url', + title: 'caption', + }, + }, + fields: [ + { + title: 'Caption', + name: 'caption', + type: 'string', + }, + { + name: 'alt', + type: 'string', + title: 'Alt text', + description: 'Alternative text for screenreaders. Falls back on caption if not set', + }, + { + title: 'Enable lightbox', + description: + '❓ Optional. The default behavior is to enable it if image is large enough to benefit from it.', + name: 'enableLightbox', + type: 'boolean', + }, + { + title: 'Icon', + name: 'isIcon', + type: 'boolean', + }, + { + title: 'Disable shadow', + description: 'Not implemented in most surfaces.', + name: 'disableShadow', + type: 'boolean', + }, + { + title: 'Large', + description: 'Not implemented in most surfaces.', + name: 'isLarge', + type: 'boolean', + }, + { + name: 'infoBox', + title: 'Info Box', + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + ], + }, + ], + }, + { + name: 'infoBox', + title: 'Info Box', + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + title: 'Box Content', + name: 'body', + type: 'text', + }, + ], + preview: { + select: { + title: 'title', + body: 'body', + }, + prepare(selection) { + return selection + }, + }, + }, + {name: 'code', type: 'code'}, + { + name: 'protip', + type: 'object', + fields: [ + { + title: 'Protip', + name: 'body', + type: 'text', + }, + ], + preview: { + select: { + body: 'body', + }, + prepare(selection) { + return selection + }, + }, + }, + { + name: 'gotcha', + type: 'object', + fields: [ + { + title: 'Gotcha', + name: 'body', + type: 'text', + }, + ], + preview: { + select: { + body: 'body', + }, + prepare(selection) { + return selection + }, + }, + }, + { + name: 'example', + type: 'object', + fields: [ + { + title: 'Example', + name: 'body', + description: 'Use this to exemplify something that’s not just a code block', + type: 'text', + }, + ], + preview: { + select: { + body: 'body', + }, + prepare(selection) { + return selection + }, + }, + }, + ], + }), +] diff --git a/dev/test-create-integration-studio/tsconfig.json b/dev/test-create-integration-studio/tsconfig.json new file mode 100644 index 00000000000..e10fe0a8eaa --- /dev/null +++ b/dev/test-create-integration-studio/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.dev" +} diff --git a/packages/@sanity/types/src/reference/types.ts b/packages/@sanity/types/src/reference/types.ts index 8a2e51a00a4..89d3a9ce472 100644 --- a/packages/@sanity/types/src/reference/types.ts +++ b/packages/@sanity/types/src/reference/types.ts @@ -2,6 +2,7 @@ import {type SanityClient} from '@sanity/client' import {type SanityDocument} from '../documents' import {type Path} from '../paths' +import {type BaseSchemaTypeOptions} from '../schema' /** @public */ export interface Reference { @@ -56,7 +57,7 @@ export interface ReferenceFilterQueryOptions { } /** @public */ -export interface ReferenceBaseOptions { +export interface ReferenceBaseOptions extends BaseSchemaTypeOptions { disableNew?: boolean } diff --git a/packages/@sanity/types/src/schema/definition/type/array.ts b/packages/@sanity/types/src/schema/definition/type/array.ts index efecfdc1cfb..2a966859728 100644 --- a/packages/@sanity/types/src/schema/definition/type/array.ts +++ b/packages/@sanity/types/src/schema/definition/type/array.ts @@ -8,12 +8,17 @@ import { type IntrinsicTypeName, type TypeAliasDefinition, } from '../schemaDefinition' -import {type BaseSchemaDefinition, type SearchConfiguration, type TitledListValue} from './common' +import { + type BaseSchemaDefinition, + type BaseSchemaTypeOptions, + type SearchConfiguration, + type TitledListValue, +} from './common' export type {InsertMenuOptions} /** @public */ -export interface ArrayOptions extends SearchConfiguration { +export interface ArrayOptions extends SearchConfiguration, BaseSchemaTypeOptions { list?: TitledListValue[] | V[] // inferring the array.of value for ArrayDefinition cause too much code-noise and was removed. // Since we don't have the type-info needed here, we allow values diff --git a/packages/@sanity/types/src/schema/definition/type/block.ts b/packages/@sanity/types/src/schema/definition/type/block.ts index d1613dd53dd..7c045b7e58c 100644 --- a/packages/@sanity/types/src/schema/definition/type/block.ts +++ b/packages/@sanity/types/src/schema/definition/type/block.ts @@ -3,13 +3,13 @@ import {type ComponentType, type ReactNode} from 'react' import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty} from '../../types' import {type ArrayOfType} from './array' -import {type BaseSchemaDefinition} from './common' +import {type BaseSchemaDefinition, type BaseSchemaTypeOptions} from './common' import {type ObjectDefinition} from './object' /** * Schema options for a Block schema definition * @public */ -export interface BlockOptions { +export interface BlockOptions extends BaseSchemaTypeOptions { /** * Turn on or off the builtin browser spellchecking. Default is on. */ diff --git a/packages/@sanity/types/src/schema/definition/type/boolean.ts b/packages/@sanity/types/src/schema/definition/type/boolean.ts index 9c5bf9ca6ae..30c0ef83ffd 100644 --- a/packages/@sanity/types/src/schema/definition/type/boolean.ts +++ b/packages/@sanity/types/src/schema/definition/type/boolean.ts @@ -1,9 +1,9 @@ import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty} from '../../types' -import {type BaseSchemaDefinition} from './common' +import {type BaseSchemaDefinition, type BaseSchemaTypeOptions} from './common' /** @public */ -export interface BooleanOptions { +export interface BooleanOptions extends BaseSchemaTypeOptions { layout?: 'switch' | 'checkbox' } diff --git a/packages/@sanity/types/src/schema/definition/type/common.ts b/packages/@sanity/types/src/schema/definition/type/common.ts index 91e1c638965..b970f82e3fe 100644 --- a/packages/@sanity/types/src/schema/definition/type/common.ts +++ b/packages/@sanity/types/src/schema/definition/type/common.ts @@ -24,6 +24,33 @@ export type FieldGroupDefinition = { i18n?: I18nTextRecord<'title'> } +/** + * Options for configuring how Sanity Create interfaces with the type or field. + * + * @public + */ +export interface SanityCreateOptions { + /** Set to true to exclude a type or field from appearing in Sanity Create */ + exclude?: boolean + + /** + * A short description of what the type or field is used for. + * Purpose can be used to improve how and when content mapping uses the field. + * */ + purpose?: string +} + +/** + * `BaseOptions` applies to all type options. + * + * It can be extended by interface declaration merging in plugins to provide generic options to all types and fields. + * + * @public + * */ +export interface BaseSchemaTypeOptions { + sanityCreate?: SanityCreateOptions +} + /** @public */ export interface BaseSchemaDefinition { name: string diff --git a/packages/@sanity/types/src/schema/definition/type/date.ts b/packages/@sanity/types/src/schema/definition/type/date.ts index 7a234ffc00b..b76e17009dd 100644 --- a/packages/@sanity/types/src/schema/definition/type/date.ts +++ b/packages/@sanity/types/src/schema/definition/type/date.ts @@ -1,10 +1,10 @@ import {type FieldReference} from '../../../validation' import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty} from '../../types' -import {type BaseSchemaDefinition} from './common' +import {type BaseSchemaDefinition, type BaseSchemaTypeOptions} from './common' /** @public */ -export interface DateOptions { +export interface DateOptions extends BaseSchemaTypeOptions { dateFormat?: string } diff --git a/packages/@sanity/types/src/schema/definition/type/datetime.ts b/packages/@sanity/types/src/schema/definition/type/datetime.ts index dcc102b2112..7cc85c042f0 100644 --- a/packages/@sanity/types/src/schema/definition/type/datetime.ts +++ b/packages/@sanity/types/src/schema/definition/type/datetime.ts @@ -1,10 +1,10 @@ import {type FieldReference} from '../../../validation' import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty} from '../../types' -import {type BaseSchemaDefinition} from './common' +import {type BaseSchemaDefinition, type BaseSchemaTypeOptions} from './common' /** @public */ -export interface DatetimeOptions { +export interface DatetimeOptions extends BaseSchemaTypeOptions { dateFormat?: string timeFormat?: string timeStep?: number diff --git a/packages/@sanity/types/src/schema/definition/type/document.ts b/packages/@sanity/types/src/schema/definition/type/document.ts index 0783ada5cee..e567dd18b42 100644 --- a/packages/@sanity/types/src/schema/definition/type/document.ts +++ b/packages/@sanity/types/src/schema/definition/type/document.ts @@ -1,6 +1,7 @@ import {type SanityDocument} from '../../../documents/types' import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty, type SortOrdering} from '../../types' +import {type BaseSchemaTypeOptions} from './common' import {type ObjectDefinition} from './object' /** @@ -9,7 +10,7 @@ import {type ObjectDefinition} from './object' * @public */ // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface DocumentOptions {} +export interface DocumentOptions extends BaseSchemaTypeOptions {} /** @public */ // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/packages/@sanity/types/src/schema/definition/type/email.ts b/packages/@sanity/types/src/schema/definition/type/email.ts index d97efbbedd6..001c2a7672a 100644 --- a/packages/@sanity/types/src/schema/definition/type/email.ts +++ b/packages/@sanity/types/src/schema/definition/type/email.ts @@ -1,6 +1,6 @@ import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty} from '../../types' -import {type BaseSchemaDefinition} from './common' +import {type BaseSchemaDefinition, type BaseSchemaTypeOptions} from './common' /** @public */ // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -9,7 +9,7 @@ export interface EmailRule extends RuleDef {} /** @public */ // only exists to support declaration extensions // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface EmailOptions {} +export interface EmailOptions extends BaseSchemaTypeOptions {} /** @public */ export interface EmailDefinition extends BaseSchemaDefinition { diff --git a/packages/@sanity/types/src/schema/definition/type/geopoint.ts b/packages/@sanity/types/src/schema/definition/type/geopoint.ts index fa505eac894..2e3d00bfac3 100644 --- a/packages/@sanity/types/src/schema/definition/type/geopoint.ts +++ b/packages/@sanity/types/src/schema/definition/type/geopoint.ts @@ -1,6 +1,6 @@ import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty} from '../../types' -import {type BaseSchemaDefinition} from './common' +import {type BaseSchemaDefinition, type BaseSchemaTypeOptions} from './common' /** * Geographical point representing a pair of latitude and longitude coordinates, @@ -37,7 +37,7 @@ export interface GeopointRule extends RuleDef {} /** @public */ // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface GeopointOptions {} +export interface GeopointOptions extends BaseSchemaTypeOptions {} /** @public */ export interface GeopointDefinition extends BaseSchemaDefinition { diff --git a/packages/@sanity/types/src/schema/definition/type/number.ts b/packages/@sanity/types/src/schema/definition/type/number.ts index 19082922544..939383f240c 100644 --- a/packages/@sanity/types/src/schema/definition/type/number.ts +++ b/packages/@sanity/types/src/schema/definition/type/number.ts @@ -1,11 +1,11 @@ import {type FieldReference} from '../../../validation' import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty} from '../../types' -import {type BaseSchemaDefinition, type EnumListProps} from './common' +import {type BaseSchemaDefinition, type BaseSchemaTypeOptions, type EnumListProps} from './common' /** @public */ // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface NumberOptions extends EnumListProps {} +export interface NumberOptions extends EnumListProps, BaseSchemaTypeOptions {} /** @public */ export interface NumberRule extends RuleDef { diff --git a/packages/@sanity/types/src/schema/definition/type/object.ts b/packages/@sanity/types/src/schema/definition/type/object.ts index 3f8d94226b0..443bdf5d830 100644 --- a/packages/@sanity/types/src/schema/definition/type/object.ts +++ b/packages/@sanity/types/src/schema/definition/type/object.ts @@ -4,12 +4,13 @@ import {type InitialValueProperty} from '../../types' import {type FieldDefinition} from '../schemaDefinition' import { type BaseSchemaDefinition, + type BaseSchemaTypeOptions, type FieldGroupDefinition, type FieldsetDefinition, } from './common' /** @public */ -export interface ObjectOptions { +export interface ObjectOptions extends BaseSchemaTypeOptions { collapsible?: boolean collapsed?: boolean columns?: number diff --git a/packages/@sanity/types/src/schema/definition/type/slug.ts b/packages/@sanity/types/src/schema/definition/type/slug.ts index ca6edcc0242..cf1accd73d9 100644 --- a/packages/@sanity/types/src/schema/definition/type/slug.ts +++ b/packages/@sanity/types/src/schema/definition/type/slug.ts @@ -3,7 +3,7 @@ import {type SlugifierFn, type SlugSourceFn} from '../../../slug' import {type SlugIsUniqueValidator} from '../../../validation' import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty} from '../../types' -import {type BaseSchemaDefinition} from './common' +import {type BaseSchemaDefinition, type BaseSchemaTypeOptions} from './common' /** @public */ export interface SlugValue { @@ -16,7 +16,7 @@ export interface SlugValue { export interface SlugRule extends RuleDef {} /** @public */ -export interface SlugOptions { +export interface SlugOptions extends BaseSchemaTypeOptions { source?: string | Path | SlugSourceFn maxLength?: number slugify?: SlugifierFn diff --git a/packages/@sanity/types/src/schema/definition/type/string.ts b/packages/@sanity/types/src/schema/definition/type/string.ts index 0b708079e3b..dd7ed8034eb 100644 --- a/packages/@sanity/types/src/schema/definition/type/string.ts +++ b/packages/@sanity/types/src/schema/definition/type/string.ts @@ -1,11 +1,19 @@ import {type FieldReference} from '../../../validation' import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty} from '../../types' -import {type BaseSchemaDefinition, type EnumListProps, type SearchConfiguration} from './common' +import { + type BaseSchemaDefinition, + type BaseSchemaTypeOptions, + type EnumListProps, + type SearchConfiguration, +} from './common' /** @public */ // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface StringOptions extends EnumListProps, SearchConfiguration {} +export interface StringOptions + extends EnumListProps, + SearchConfiguration, + BaseSchemaTypeOptions {} /** @public */ export interface StringRule extends RuleDef { diff --git a/packages/@sanity/types/src/schema/definition/type/url.ts b/packages/@sanity/types/src/schema/definition/type/url.ts index a8c6e2554bc..906314b21fd 100644 --- a/packages/@sanity/types/src/schema/definition/type/url.ts +++ b/packages/@sanity/types/src/schema/definition/type/url.ts @@ -1,7 +1,7 @@ import {type UriValidationOptions} from '../../../validation/types' import {type RuleDef, type ValidationBuilder} from '../../ruleBuilder' import {type InitialValueProperty} from '../../types' -import {type BaseSchemaDefinition} from './common' +import {type BaseSchemaDefinition, type BaseSchemaTypeOptions} from './common' /** @public */ export interface UrlRule extends RuleDef { @@ -11,7 +11,7 @@ export interface UrlRule extends RuleDef { /** @public */ // only exists to support declaration extensions // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface UrlOptions {} +export interface UrlOptions extends BaseSchemaTypeOptions {} /** @public */ export interface UrlDefinition extends BaseSchemaDefinition { diff --git a/packages/@sanity/types/test/boolean.test.ts b/packages/@sanity/types/test/boolean.test.ts index 1ba1a92abc8..5cb0fc796a3 100644 --- a/packages/@sanity/types/test/boolean.test.ts +++ b/packages/@sanity/types/test/boolean.test.ts @@ -29,6 +29,9 @@ describe('boolean types', () => { hidden: () => false, options: { layout: 'checkbox', + sanityCreate: { + exclude: true, + }, }, }) diff --git a/packages/sanity/src/_singletons/context/SanityCreateConfigContext.tsx b/packages/sanity/src/_singletons/context/SanityCreateConfigContext.tsx new file mode 100644 index 00000000000..2f89b9833fa --- /dev/null +++ b/packages/sanity/src/_singletons/context/SanityCreateConfigContext.tsx @@ -0,0 +1,13 @@ +import {createContext} from 'sanity/_createContext' + +import type {SanityCreateConfigContextValue} from '../../core' + +/** + * @internal + */ +export const SanityCreateConfigContext = createContext( + 'sanity/_singletons/context/start-in-create-enabled', + { + startInCreateEnabled: false, + }, +) diff --git a/packages/sanity/src/_singletons/index.ts b/packages/sanity/src/_singletons/index.ts index 05761b9609f..6679e2e0282 100644 --- a/packages/sanity/src/_singletons/index.ts +++ b/packages/sanity/src/_singletons/index.ts @@ -47,6 +47,7 @@ export * from './context/ResourceCacheContext' export * from './context/ReviewChangesContext' export * from './context/RouterContext' export * from './context/RouterHistoryContext' +export * from './context/SanityCreateConfigContext' export * from './context/ScheduledPublishingEnabledContext' export * from './context/SchedulePublishingUpsellContext' export * from './context/Schedules' diff --git a/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts b/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts index 34cb13bea71..7eaed5a6dc6 100644 --- a/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts +++ b/packages/sanity/src/core/config/__tests__/resolveConfig.test.ts @@ -162,6 +162,7 @@ describe('resolveConfig', () => { {name: 'sanity/comments'}, {name: 'sanity/tasks'}, {name: 'sanity/scheduled-publishing'}, + {name: 'sanity/create-integration'}, ]) }) @@ -188,6 +189,7 @@ describe('resolveConfig', () => { expect(workspace.__internal.options.plugins).toMatchObject([ {name: 'sanity/comments'}, {name: 'sanity/tasks'}, + {name: 'sanity/create-integration'}, ]) }) }) diff --git a/packages/sanity/src/core/config/configPropertyReducers.ts b/packages/sanity/src/core/config/configPropertyReducers.ts index c2b6a66205a..221ad852b12 100644 --- a/packages/sanity/src/core/config/configPropertyReducers.ts +++ b/packages/sanity/src/core/config/configPropertyReducers.ts @@ -391,3 +391,48 @@ export const legacySearchEnabledReducer: ConfigPropertyReducer { + const {config, initialValue} = opts + const flattenedConfig = flattenConfig(config, []) + + const result = flattenedConfig.reduce((acc, {config: innerConfig}) => { + const resolver = innerConfig.beta?.create?.startInCreateEnabled + + if (!resolver && typeof resolver !== 'boolean') return acc + if (typeof resolver === 'boolean') return resolver + + throw new Error( + `Expected \`beta.create.startInCreateEnabled\` to be a boolean, but received ${getPrintableType( + resolver, + )}`, + ) + }, initialValue) + + return result +} + +export const createFallbackOriginReducer = (config: PluginOptions): string | undefined => { + const flattenedConfig = flattenConfig(config, []) + + const result = flattenedConfig.reduce( + (acc, {config: innerConfig}) => { + const resolver = innerConfig.beta?.create?.fallbackStudioOrigin + + if (!resolver) return acc + if (typeof resolver === 'string') return resolver + + throw new Error( + `Expected \`beta.create.fallbackStudioOrigin\` to be a string, but received ${getPrintableType( + resolver, + )}`, + ) + }, + undefined as string | undefined, + ) + + return result +} diff --git a/packages/sanity/src/core/config/create/__tests__/startInCreateSortedActions.test.ts b/packages/sanity/src/core/config/create/__tests__/startInCreateSortedActions.test.ts new file mode 100644 index 00000000000..ccc2cecc84b --- /dev/null +++ b/packages/sanity/src/core/config/create/__tests__/startInCreateSortedActions.test.ts @@ -0,0 +1,40 @@ +import {describe, expect, it} from 'vitest' + +import {type DocumentActionComponent} from '../../document/actions' +import { + getStartInCreateSortedActions, + START_IN_CREATE_ACTION_NAME, +} from '../startInCreateSortedActions' + +describe('getStartInCreateSortedActions', () => { + it(`sorts "Start in Create" action first`, async () => { + const StartInCreateAction: DocumentActionComponent = () => { + return null + } + StartInCreateAction.action = START_IN_CREATE_ACTION_NAME + + const Action1: DocumentActionComponent = () => { + return null + } + const Action2: DocumentActionComponent = () => { + return null + } + + const actions = [Action1, Action2, StartInCreateAction] + const sortedActions = getStartInCreateSortedActions(actions) + expect(sortedActions).toEqual([StartInCreateAction, Action1, Action2]) + }) + + it(`leaves actions untouched when "Start in Create" actions is not present`, async () => { + const Action1: DocumentActionComponent = () => { + return null + } + const Action2: DocumentActionComponent = () => { + return null + } + + const actions = [Action1, Action2] + const sortedActions = getStartInCreateSortedActions(actions) + expect(sortedActions).toEqual([Action1, Action2]) + }) +}) diff --git a/packages/sanity/src/core/config/create/startInCreateSortedActions.ts b/packages/sanity/src/core/config/create/startInCreateSortedActions.ts new file mode 100644 index 00000000000..0f3938722ab --- /dev/null +++ b/packages/sanity/src/core/config/create/startInCreateSortedActions.ts @@ -0,0 +1,23 @@ +//this file has to live here to avoid cyclic dependencies between config<->create +import {type DocumentActionComponent} from '../document' + +// The "Start in Create" action must be sorted first, so we need a sort key; the action string – +// we also don't want this string in the config interfaces, so we need the cheeky cast to smuggle it through +export const START_IN_CREATE_ACTION_NAME = + 'startInCreate' as unknown as DocumentActionComponent['action'] + +/** + * Sorts "Start in Create" action first, when present + */ +export function getStartInCreateSortedActions( + actions: DocumentActionComponent[], +): DocumentActionComponent[] { + return [...actions].sort((a, b) => { + if (a.action === START_IN_CREATE_ACTION_NAME) { + return -1 + } else if (b.action === START_IN_CREATE_ACTION_NAME) { + return 1 + } + return 0 + }) +} diff --git a/packages/sanity/src/core/config/prepareConfig.ts b/packages/sanity/src/core/config/prepareConfig.ts index 054250acc5b..dbc82006572 100644 --- a/packages/sanity/src/core/config/prepareConfig.ts +++ b/packages/sanity/src/core/config/prepareConfig.ts @@ -25,6 +25,7 @@ import {operatorDefinitions} from '../studio/components/navbar/search/definition import {type InitialValueTemplateItem, type Template, type TemplateItem} from '../templates' import {EMPTY_ARRAY, isNonNullable} from '../util' import { + createFallbackOriginReducer, documentActionsReducer, documentBadgesReducer, documentCommentsEnabledReducer, @@ -42,9 +43,11 @@ import { partialIndexingEnabledReducer, resolveProductionUrlReducer, schemaTemplatesReducer, + startInCreateEnabledReducer, toolsReducer, } from './configPropertyReducers' import {ConfigResolutionError} from './ConfigResolutionError' +import {getStartInCreateSortedActions} from './create/startInCreateSortedActions' import {createDefaultIcon} from './createDefaultIcon' import {documentFieldActionsReducer, initialDocumentFieldActions} from './document' import {resolveConfigProperty} from './resolveConfigProperty' @@ -495,14 +498,16 @@ function resolveSource({ config, }), document: { - actions: (partialContext) => - resolveConfigProperty({ + actions: (partialContext) => { + const actions = resolveConfigProperty({ config, context: {...context, ...partialContext}, initialValue: initialDocumentActions, propertyName: 'document.actions', reducer: documentActionsReducer, - }), + }) + return getStartInCreateSortedActions(actions) + }, badges: (partialContext) => resolveConfigProperty({ config, @@ -648,6 +653,10 @@ function resolveSource({ // This beta feature is no longer available. enabled: false, }, + create: { + startInCreateEnabled: startInCreateEnabledReducer({config, initialValue: false}), + fallbackStudioOrigin: createFallbackOriginReducer(config), + }, }, } diff --git a/packages/sanity/src/core/config/resolveDefaultPlugins.ts b/packages/sanity/src/core/config/resolveDefaultPlugins.ts index 2e79260b243..b567b8f839a 100644 --- a/packages/sanity/src/core/config/resolveDefaultPlugins.ts +++ b/packages/sanity/src/core/config/resolveDefaultPlugins.ts @@ -1,4 +1,5 @@ import {comments} from '../comments/plugin' +import {createIntegration} from '../create/createIntegrationPlugin' import {DEFAULT_SCHEDULED_PUBLISH_PLUGIN_OPTIONS} from '../scheduledPublishing/constants' import {SCHEDULED_PUBLISHING_NAME, scheduledPublishing} from '../scheduledPublishing/plugin' import {tasks, TASKS_NAME} from '../tasks/plugin' @@ -9,7 +10,7 @@ import { type WorkspaceOptions, } from './types' -const defaultPlugins = [comments(), tasks(), scheduledPublishing()] +const defaultPlugins = [comments(), tasks(), scheduledPublishing(), createIntegration()] export function getDefaultPlugins( options: DefaultPluginsWorkspaceOptions, diff --git a/packages/sanity/src/core/config/types.ts b/packages/sanity/src/core/config/types.ts index 5903b68b763..2d1beb7db52 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -388,10 +388,12 @@ export interface PluginOptions { */ enableLegacySearch?: boolean } + /** Configuration for studio beta features. * @internal */ beta?: BetaFeatures + /** Configuration for error handling. * @beta */ @@ -917,4 +919,41 @@ interface BetaFeatures { */ enabled: boolean } + + /** + * @beta + */ + create?: { + /** + * When true, a "Start in Sanity Create" action will be shown for all new documents, in place of regular document actions, + * when the following are true: + * - the origin of the current url is listed under Studios in sanity.to/manage (OR fallbackStudioOrigin is provided) + * - [origin]/static/create-manifest.json is available over HTTP GET + * + * The manifest file is automatically created and deployed when deploying studios with `sanity deploy` + * + * @see #fallbackStudioOrigin + */ + startInCreateEnabled?: boolean + + /** + * To show the "Start in Create" button on localhost, or in studios not listed under Studios in sanity.io/manage + * provide a fallback origin as a string. + * + * The string must be the exactly equal `name` as shown for the Studio in manage, and the studio must have create-manifest.json available. + * + * If the provided fallback Studio does not expose create-manifest.json "Start in Sanity Create" will fail when using the fallback. + * + * Example: `wonderful.sanity.studio` + * + * Keep in mind that when fallback origin is used, Sanity Create will used the schema types and dataset in the *deployed* Studio, + * not from localhost. + * + * To see data synced from Sanity Create in your localhost Studio, you must ensure that the deployed fallback studio uses the same + * workspace and schemas as your local configuration. + * + * @see #startInCreateEnabled + */ + fallbackStudioOrigin?: string + } } diff --git a/packages/sanity/src/core/create/__telemetry__/create.telemetry.ts b/packages/sanity/src/core/create/__telemetry__/create.telemetry.ts new file mode 100644 index 00000000000..d950e671b59 --- /dev/null +++ b/packages/sanity/src/core/create/__telemetry__/create.telemetry.ts @@ -0,0 +1,32 @@ +import {defineEvent} from '@sanity/telemetry' + +export const CreateDocumentLinkCtaClicked = defineEvent({ + name: 'Create Document Link CTA Clicked', + version: 1, + description: 'The "Start in Sanity Create" button is clicked.', +}) + +export const CreateDocumentLinkAccepted = defineEvent({ + name: 'Create Document Link Accepted', + version: 1, + description: + 'Continue in the "Start in Sanity Create" dialog was pressed, or auto-confirm was enabled.', +}) + +export const CreateDocumentUnlinkCtaClicked = defineEvent({ + name: 'Create Document Unlink CTA Clicked', + version: 1, + description: 'The Unlink action was clicked', +}) + +export const CreateDocumentUnlinkApproved = defineEvent({ + name: 'Create Document Unlink Approved', + version: 1, + description: 'User confirmed that they want the Studio document unlinked', +}) + +export const CreateDocumentOpened = defineEvent({ + name: 'Create Document Opened', + version: 1, + description: 'User clicked "Edit in Create"', +}) diff --git a/packages/sanity/src/core/create/__tests__/createDocumentUrl.test.ts b/packages/sanity/src/core/create/__tests__/createDocumentUrl.test.ts new file mode 100644 index 00000000000..4fffbf93f88 --- /dev/null +++ b/packages/sanity/src/core/create/__tests__/createDocumentUrl.test.ts @@ -0,0 +1,70 @@ +import {describe, expect, it} from 'vitest' + +import {getCreateDocumentUrl, getCreateLinkUrl} from '../createDocumentUrls' +import {type CreateLinkMetadata} from '../types' + +describe('createDocumentUrls', () => { + describe('getCreateDocumentUrl', () => { + it(`returns Create prod document url`, async () => { + const metadata: CreateLinkMetadata = { + _id: 'id', + dataset: 'dataset', + ejected: false, + } + expect(getCreateDocumentUrl(metadata)).toEqual('https://www.sanity.io/app/create/dataset/id') + }) + + it(`returns Create staging document url`, async () => { + const metadata: CreateLinkMetadata = { + _id: 'id', + dataset: 'dataset', + ejected: false, + host: 'https://www.sanity.work', + } + expect(getCreateDocumentUrl(metadata)).toEqual( + 'https://create-staging.sanity.build/app/create/dataset/id', + ) + }) + }) + + describe('getCreateLinkUrl', () => { + it(`returns Create prod create link url`, async () => { + expect( + getCreateLinkUrl({ + docId: 'id', + documentType: 'documentType', + appId: 'appId', + projectId: 'projectId', + workspaceName: 'workspace', + }), + ).toEqual( + 'https://www.sanity.io/app/create/studio-import?' + + 'projectId=projectId&' + + 'applicationId=appId&' + + 'workspaceName=workspace&' + + 'documentType=documentType&' + + 'documentId=id', + ) + }) + + it(`returns Create staging create link url`, async () => { + expect( + getCreateLinkUrl({ + docId: 'id', + documentType: 'documentType', + appId: 'appId', + projectId: 'projectId', + workspaceName: 'workspace', + customHost: 'https://www.sanity.work', + }), + ).toEqual( + 'https://create-staging.sanity.build/app/create/studio-import?' + + 'projectId=projectId&' + + 'applicationId=appId&' + + 'workspaceName=workspace&' + + 'documentType=documentType&' + + 'documentId=id', + ) + }) + }) +}) diff --git a/packages/sanity/src/core/create/components/CreateIntegrationWrapper.tsx b/packages/sanity/src/core/create/components/CreateIntegrationWrapper.tsx new file mode 100644 index 00000000000..9d6fb12b88e --- /dev/null +++ b/packages/sanity/src/core/create/components/CreateIntegrationWrapper.tsx @@ -0,0 +1,6 @@ +import {type LayoutProps} from '../../config' +import {SanityCreateConfigProvider} from '../context/SanityCreateConfigProvider' + +export function CreateIntegrationWrapper(props: LayoutProps) { + return {props.renderDefault(props)} +} diff --git a/packages/sanity/src/core/create/components/CreateLearnMoreButton.tsx b/packages/sanity/src/core/create/components/CreateLearnMoreButton.tsx new file mode 100644 index 00000000000..c01110e95ae --- /dev/null +++ b/packages/sanity/src/core/create/components/CreateLearnMoreButton.tsx @@ -0,0 +1,25 @@ +import {LaunchIcon} from '@sanity/icons' +import {type ForwardedRef, forwardRef} from 'react' + +import {Button} from '../../../ui-components' +import {useTranslation} from '../../i18n' +import {createLocaleNamespace} from '../i18n' + +export const CreateLearnMoreButton = forwardRef(function CreateLearnMoreButton( + props, + ref: ForwardedRef, +) { + const {t} = useTranslation(createLocaleNamespace) + return ( + + {troubleshootingOpen && ( + + + {t('linking-in-progress-dialog.troubleshooting.content')} + + + )} + + + + + ) +} diff --git a/packages/sanity/src/core/create/start-in-create/StartInCreateAction.tsx b/packages/sanity/src/core/create/start-in-create/StartInCreateAction.tsx new file mode 100644 index 00000000000..d4f0275be97 --- /dev/null +++ b/packages/sanity/src/core/create/start-in-create/StartInCreateAction.tsx @@ -0,0 +1,92 @@ +import {useCallback, useState} from 'react' + +import { + type DocumentActionComponent, + type DocumentActionDescription, + type DocumentActionProps, +} from '../../config' +import {START_IN_CREATE_ACTION_NAME} from '../../config/create/startInCreateSortedActions' +import {useTranslation} from '../../i18n' +import {useSchemaType} from '../../scheduledPublishing/hooks/useSchemaType' +import {isStartInCreateAutoConfirmed, setStartInCreateAutoConfirm} from '../createStorage' +import {isSanityCreateExcludedType} from '../createUtils' +import {createLocaleNamespace} from '../i18n' +import {type AppIdCache} from '../studio-app/appIdCache' +import {useStudioAppIdStore} from '../studio-app/useStudioAppIdStore' +import {type CreateLinkedSanityDocument} from '../types' +import {useSanityCreateTelemetry} from '../useSanityCreateTelemetry' +import {CreateLinkingDialog} from './CreateLinkingDialog' +import {StartInCreateDialog} from './StartInCreateDialog' + +export function createStartInCreateAction(appIdCache: AppIdCache): DocumentActionComponent { + const StartInCreateActionWrapper: DocumentActionComponent = function StartInCreateActionWrapper( + props: DocumentActionProps, + ): DocumentActionDescription | null { + return StartInCreateAction({appIdCache, ...props}) + } + + StartInCreateActionWrapper.action = START_IN_CREATE_ACTION_NAME + return StartInCreateActionWrapper +} + +export function StartInCreateAction( + props: DocumentActionProps & {appIdCache: AppIdCache}, +): DocumentActionDescription | null { + const {id, type, draft, liveEdit, published, appIdCache} = props + const doc = (draft ?? published) as CreateLinkedSanityDocument + + const {appId} = useStudioAppIdStore(appIdCache) + const {t} = useTranslation(createLocaleNamespace) + const schemaType = useSchemaType(type) + const telemetry = useSanityCreateTelemetry() + + const [isDialogOpen, setDialogOpen] = useState(false) + const [isLinking, setLinking] = useState(false) + const [autoConfirm, setAutoConfirm] = useState(() => isStartInCreateAutoConfirmed()) + const closeDialog = useCallback(() => setDialogOpen(false), []) + + const linkingStarted = useCallback((dontShowAgain: boolean) => { + setStartInCreateAutoConfirm(dontShowAgain) + setAutoConfirm(dontShowAgain) + setLinking(true) + }, []) + + const isExcludedByOption = schemaType && isSanityCreateExcludedType(schemaType) + const createLinkId = (draft?._id ?? published?._id ?? liveEdit) ? id : `drafts.${id}` + + //appId will always be undefined when start in create is disabled via config + if (isExcludedByOption || !appId || doc?._createdAt) { + return null + } + + return { + label: t('start-in-create-action.label'), + dialog: isLinking + ? { + type: 'custom', + component: , + } + : isDialogOpen && { + type: 'dialog', + onClose: closeDialog, + header: t('start-in-create-dialog.header'), + width: 'small', + content: ( + + ), + }, + onHandle: () => { + if (!isDialogOpen) { + telemetry.linkCtaClicked() + } + setDialogOpen(true) + }, + tone: 'default', + } +} diff --git a/packages/sanity/src/core/create/start-in-create/StartInCreateDialog.tsx b/packages/sanity/src/core/create/start-in-create/StartInCreateDialog.tsx new file mode 100644 index 00000000000..962f4b4a5d1 --- /dev/null +++ b/packages/sanity/src/core/create/start-in-create/StartInCreateDialog.tsx @@ -0,0 +1,88 @@ +import {LaunchIcon} from '@sanity/icons' +import {Checkbox, Flex, Stack, Text, useToast} from '@sanity/ui' +import {useCallback, useEffect, useId, useState} from 'react' + +import {Button} from '../../../ui-components' +import {useTranslation} from '../../i18n' +import {useWorkspace} from '../../studio' +import {CreateLearnMoreButton} from '../components/CreateLearnMoreButton' +import {CreateSvg} from '../components/media/CreateSvg' +import {getCreateLinkUrl} from '../createDocumentUrls' +import {createLocaleNamespace} from '../i18n' +import {useSanityCreateTelemetry} from '../useSanityCreateTelemetry' + +export interface StartInCreateDialogProps { + createLinkId: string + appId: string + type: string + onLinkingStarted: (autoConfirm: boolean) => void + autoConfirm: boolean +} + +export function StartInCreateDialog(props: StartInCreateDialogProps) { + const {createLinkId, appId, type, onLinkingStarted, autoConfirm} = props + const {t} = useTranslation(createLocaleNamespace) + const checkboxId = useId() + const [dontShowAgain, setDontShowAgain] = useState(false) + + const telemetry = useSanityCreateTelemetry() + const toggleDontShowAgain = useCallback(() => setDontShowAgain((current) => !current), []) + + const {push: pushToast} = useToast() + const workspace = useWorkspace() + + const createUrl = getCreateLinkUrl({ + projectId: workspace.projectId, + appId, + workspaceName: workspace.name, + documentType: type, + docId: createLinkId, + }) + + const startLinking = useCallback(() => { + if (!createUrl) { + pushToast({ + title: t('start-in-create-dialog.error-toast.unresolved-url'), + status: 'warning', + }) + return + } + + window?.open(createUrl, '_blank')?.focus() + onLinkingStarted(autoConfirm || dontShowAgain) + telemetry.linkAccepted() + }, [createUrl, onLinkingStarted, pushToast, t, dontShowAgain, autoConfirm, telemetry]) + + useEffect(() => { + if (autoConfirm && createUrl) { + startLinking() + } + }, [autoConfirm, startLinking, createUrl]) + + return ( + + + + {t('start-in-create-dialog.lede')} + + + {t('start-in-create-dialog.details')} + + + + + {t('start-in-create-dialog.dont-remind-me-checkbox')} + + + + +