From 53aa46b0b87bda5f167c9523de8e83206dc67d81 Mon Sep 17 00:00:00 2001 From: Fred Carlsen Date: Thu, 11 Jul 2024 22:43:45 +0200 Subject: [PATCH] feat(core): global copy paste (#6856) * feat(form): copy paste of document and fields prototype edx-1263 Signed-off-by: Fred Carlsen * feat(core): add global copy paste provider Signed-off-by: Fred Carlsen * refactor(dev): use new copy paste api in actions Signed-off-by: Fred Carlsen * refactor(core): move value transfer to own function (tbc) * test(core): add value transfer test for global copy/paste * refactor: prepare for using clipboard insted of LS Signed-off-by: Fred Carlsen * refactor(core): use clipboard when handling onCopy/onPaste Signed-off-by: Fred Carlsen * feat(core): add resolveSchemaTypeForPath utility Signed-off-by: Fred Carlsen * fix(core): pass onChange as props instead of importing from structure Signed-off-by: Fred Carlsen * test(core): add missing _type to test Signed-off-by: Fred Carlsen * feat(core): add support for copy/paste via ctrl-c/v Signed-off-by: Fred Carlsen * refactor(test-studio): use new copy paste signature in doc actions Signed-off-by: Fred Carlsen * feat(structure): add copy/paste actions into structure Signed-off-by: Fred Carlsen * refactor(core): use new value transfer function for copy/paste + tests (#6878) * refactor(core): support multiple sources and targets for copy/paste * fix(core): change copy on copy/paste messaging This will use the correct field name in the copy confirmation message * fix(core): set new keys on object for copy/paste Create new _key (if exists) for transferred object value * feat(core): add focus handling to reference previews fixes edx-1450 Signed-off-by: Fred Carlsen * feat(core): allow focus on objects Signed-off-by: Fred Carlsen * fix(core): skip handling copy event on selections Signed-off-by: Fred Carlsen * feat(core): highlight border on focused objects Signed-off-by: Fred Carlsen * fix(form): remove focus terminator from cdr focuspath fixes edx-1510 Signed-off-by: Fred Carlsen * feat(core): move copy paste field actions to core fixes edx-1512 fixes edx-1513 Signed-off-by: Fred Carlsen * fix(core): fix imports in actions Signed-off-by: Fred Carlsen * feat(core): add telemetry to copy paste hook fixes EDX-1508 Signed-off-by: Fred Carlsen * fix(core): limit object focus to children incld in array modals Signed-off-by: Fred Carlsen * test(form): add tests for copy pasting fields Signed-off-by: Fred Carlsen * refactor(core): don't interfere with native editable elements + use clipboardItem (#6931) * fix(core): don't show paste field action on readonly fields * fix(core): copy paste string field must account for string lists * fix(core): don't iterate on target schemaType if sourceValue is empty * fix(core): quote copied fields in toast msg * fix(core): adjust for weak/hard refs when pasting ref. values * fix(core): fallback to text/plain clipboard item for Webkit * fix(core): add check for non-browser env in helper Signed-off-by: Fred Carlsen * test(form): add e2e tests for copy paste Signed-off-by: Fred Carlsen * feat(form): add test ids to field actions Signed-off-by: Fred Carlsen * fix(form): remove blur handling from object Signed-off-by: Fred Carlsen * fix(form): trigger keypress on object wrapper Signed-off-by: Fred Carlsen * test(form): attempt to stabilise e2e tests for copy paste Signed-off-by: Fred Carlsen * chore: upgrade playwright deps Signed-off-by: Fred Carlsen Signed-off-by: Fred Carlsen Signed-off-by: Fred Carlsen * test(form): add component tests for copy paste Signed-off-by: Fred Carlsen * fix(test): make sure fixure awaits setup Signed-off-by: Fred Carlsen * fix(test): allow. copy pasting permissions in playwright-ct config Signed-off-by: Fred Carlsen * test(form): stabilise copy paste e2e tests Signed-off-by: Fred Carlsen * fix(test): fix clipboard.writeText mock Signed-off-by: Fred Carlsen * refactor(test): remove e2e copypaste test in favour of component test Signed-off-by: Fred Carlsen * fix(test): fix assertion texts Signed-off-by: Fred Carlsen * refactor(form): align updated toast msg with the copy toast Signed-off-by: Fred Carlsen * fix(test): await filling out string inputs Signed-off-by: Fred Carlsen * chore(test): enable trace/video on retries in CI Signed-off-by: Fred Carlsen * feat(playwright-ct): add debug fixture Signed-off-by: Fred Carlsen * fix(test): more work to stabilise object tests Signed-off-by: Fred Carlsen * fix(form): filter out epmty ref objects fixes edx-1532 Signed-off-by: Fred Carlsen * fix(form): ignore copy event on text selection fixes edx-1534 Signed-off-by: Fred Carlsen * refactor(form): move copy/paste into document field action fixes edx-1451 Signed-off-by: Fred Carlsen * fix(form): prevent pasting image/file into the opposite type fixes edx-1531 Signed-off-by: Fred Carlsen * fix(form): validate option.accept on paste fixes edx-1518 Signed-off-by: Fred Carlsen * fix(form): fix styled import Signed-off-by: Fred Carlsen * fix(core): added suite of tests + fixes for coercions fixes edx-1517 fixes edx-1525 Signed-off-by: Fred Carlsen * feat(core): translate copy-paste * feat(core): translate MIME type copy-paste validation messages * test(core): update `valueTransfer` test * fix(core): copy-paste unknown copy error translation * fix(form): transform error path to string with path utils fixes edx-1543 Signed-off-by: Fred Carlsen * fix(form): handle deeply nested paths in arrays Signed-off-by: Fred Carlsen * test(form): add failing test for deeply nested arrays Signed-off-by: Fred Carlsen * refactor(core): split out test schema Signed-off-by: Fred Carlsen * test(core): add test for copying documents with booleans Signed-off-by: Fred Carlsen * refactor(form): only copy and paste defined focus path fixes edx-1548 Signed-off-by: Fred Carlsen * chore(form): rename valueTransfer -> transferValue Signed-off-by: Fred Carlsen * fix(core): pass client options to remove notice Signed-off-by: Fred Carlsen * refactor(core): make reference type and image mime checks for copy/paste recursive * test(core): update tests for copy/paste Client must be mocked for ref. type checks * test(core): validate schema before test runs Signed-off-by: Fred Carlsen * fix(core): skip pasting on empty focus path Signed-off-by: Fred Carlsen * refactor(core): only check read-only on root level schema also allow empty fixes edx-1556 fixes edx-1555 Signed-off-by: Fred Carlsen * fix(core): fix type validation for primitive array target fixes edx-1554 Signed-off-by: Fred Carlsen * fix(form): fix asset schema compatibility check on root Signed-off-by: Fred Carlsen * fix(core): fix potential race condition when setting document meta touches edx-1553 Signed-off-by: Fred Carlsen * fix(core): make a reference weak if _strengthenOnPublish is set fixes edx-1558 Signed-off-by: Fred Carlsen * fix(core): retain relationship between marks and markDefs fixes edx-1555 Signed-off-by: Fred Carlsen * feat(core): validate pasted reference against filter fixes edx-1560 Signed-off-by: Fred Carlsen * refactor(core): clean up document pane event handler Signed-off-by: Fred Carlsen * refactor(form): add subtle transition on object focus Signed-off-by: Fred Carlsen * refactor(core): serialize clipboard into HTML for safari and firefox * refactor(core): rename and simplify `CopyActionResult` to `SanityClipboardItem` * refactor(core): remove unused `copyResult` * refactor(core): lift onCopy and onPaste into Provider * refactor(core): remove unexpected cases * fix(core): update tests * fix: add missing return * test: skip copy/paste tests for now * refactor: remove unused interface * test: update field to prevent collisions * test: update component test timeouts --------- Signed-off-by: Fred Carlsen Co-authored-by: Per-Kristian Nordnes Co-authored-by: Herman Wikner Co-authored-by: Rico Kahler --- dev/studio-e2e-testing/sanity.config.ts | 4 +- dev/test-studio/fieldActions/copyAction.ts | 22 - dev/test-studio/fieldActions/pasteAction.ts | 22 - dev/test-studio/sanity.config.ts | 8 +- dev/test-studio/schema/standard/arrays.tsx | 32 +- .../portableText/allTheBellsAndWhistles.ts | 22 +- package.json | 2 +- .../@sanity/types/src/schema/asserters.ts | 12 + packages/sanity/package.json | 4 +- packages/sanity/playwright-ct.config.ts | 33 +- .../tests/fixtures/copyPasteFixture.ts | 89 ++ .../tests/fixtures/debugFixture.ts | 31 + .../playwright-ct/tests/fixtures/index.ts | 10 + .../tests/fixtures/scrollToTopFixture.ts | 20 + .../inputs/PortableText/ObjectBlock.spec.tsx | 18 +- .../PortableText/copyPaste/CopyPaste.spec.tsx | 3 +- .../copyPaste/CopyPasteFields.spec.tsx | 272 ++++ .../copyPaste/CopyPasteFieldsStory.tsx | 121 ++ .../tests/formBuilder/utils/TestForm.tsx | 89 +- .../tests/formBuilder/utils/TestWrapper.tsx | 47 +- .../playwright-ct/tests/utils/testHelpers.tsx | 41 +- .../core/studio/copyPaste/CopyPasteContext.ts | 9 + packages/sanity/src/_singletons/index.ts | 1 + .../config/document/fieldActions/index.ts | 4 +- .../form/components/formField/FormField.tsx | 1 + .../formField/FormFieldBaseHeader.tsx | 8 +- .../components/formField/FormFieldSet.tsx | 19 +- .../form/field/actions/FieldActionMenu.tsx | 3 +- .../src/core/form/field/actions/copyAction.ts | 39 + .../src/core/form/field/actions/define.ts | 9 + .../core/form/field/actions/pasteAction.ts | 38 + packages/sanity/src/core/form/index.ts | 1 + .../CrossDatasetReferenceInput.tsx | 7 +- .../form/inputs/ObjectInput/ObjectInput.tsx | 48 +- .../inputs/ReferenceInput/ReferenceField.tsx | 9 + .../inputs/reference/StudioReferenceInput.tsx | 35 +- .../reference/resolveUserDefinedFilter.tsx | 32 + packages/sanity/src/core/hooks/index.ts | 1 + .../hooks/useGlobalCopyPasteElementHandler.ts | 81 ++ .../src/core/i18n/bundles/copy-paste.ts | 99 ++ .../sanity/src/core/i18n/localeNamespaces.ts | 8 + packages/sanity/src/core/i18n/locales.ts | 3 +- .../studio/copyPaste/CopyPasteProvider.tsx | 331 +++++ .../__telemetry__/copyPaste.telemetry.ts | 37 + .../studio/copyPaste/__test__/mockClient.ts | 53 + .../__test__/resolveSchemaFromPath.test.ts | 173 +++ .../copyPaste/__test__/schema/author.ts | 74 + .../__test__/schema/documents/book.ts | 18 + .../__test__/schema/documents/objects.ts | 10 + .../__test__/schema/documents/references.ts | 122 ++ .../copyPaste/__test__/schema/editor.tsx | 168 +++ .../copyPaste/__test__/schema/hotspot.ts | 52 + .../studio/copyPaste/__test__/schema/index.ts | 50 + .../copyPaste/__test__/schema/objects.ts | 196 +++ .../studio/copyPaste/__test__/schema/post.ts | 23 + .../__test__/schema/pteCustomerMarkers.ts | 39 + .../copyPaste/__test__/transferValue.test.ts | 1269 +++++++++++++++++ .../src/core/studio/copyPaste/constants.ts | 3 + .../copyPaste/documentMatchesGroqFilter.ts | 48 + .../sanity/src/core/studio/copyPaste/index.ts | 2 + .../copyPaste/resolveSchemaTypeForPath.ts | 84 ++ .../core/studio/copyPaste/transferValue.ts | 891 ++++++++++++ .../sanity/src/core/studio/copyPaste/types.ts | 63 + .../sanity/src/core/studio/copyPaste/utils.ts | 191 +++ packages/sanity/src/core/studio/index.ts | 1 + .../structure/panes/document/DocumentPane.tsx | 5 +- .../panes/document/DocumentPaneProvider.tsx | 11 + .../document-layout/DocumentLayout.tsx | 13 +- .../document/documentPanel/DocumentPanel.tsx | 2 + .../DocumentStatusPulse.tsx | 2 +- perf/tests/package.json | 2 +- playwright.config.ts | 34 + pnpm-lock.yaml | 63 +- test/e2e/tests/fixtures/copyPasteFixture.ts | 89 ++ 74 files changed, 5298 insertions(+), 178 deletions(-) delete mode 100644 dev/test-studio/fieldActions/copyAction.ts delete mode 100644 dev/test-studio/fieldActions/pasteAction.ts create mode 100644 packages/sanity/playwright-ct/tests/fixtures/copyPasteFixture.ts create mode 100644 packages/sanity/playwright-ct/tests/fixtures/debugFixture.ts create mode 100644 packages/sanity/playwright-ct/tests/fixtures/index.ts create mode 100644 packages/sanity/playwright-ct/tests/fixtures/scrollToTopFixture.ts create mode 100644 packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPasteFields.spec.tsx create mode 100644 packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPasteFieldsStory.tsx create mode 100644 packages/sanity/src/_singletons/core/studio/copyPaste/CopyPasteContext.ts create mode 100644 packages/sanity/src/core/form/field/actions/copyAction.ts create mode 100644 packages/sanity/src/core/form/field/actions/define.ts create mode 100644 packages/sanity/src/core/form/field/actions/pasteAction.ts create mode 100644 packages/sanity/src/core/form/studio/inputs/reference/resolveUserDefinedFilter.tsx create mode 100644 packages/sanity/src/core/hooks/useGlobalCopyPasteElementHandler.ts create mode 100644 packages/sanity/src/core/i18n/bundles/copy-paste.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/CopyPasteProvider.tsx create mode 100644 packages/sanity/src/core/studio/copyPaste/__telemetry__/copyPaste.telemetry.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/mockClient.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/resolveSchemaFromPath.test.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/schema/author.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/schema/documents/book.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/schema/documents/objects.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/schema/documents/references.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/schema/editor.tsx create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/schema/hotspot.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/schema/index.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/schema/objects.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/schema/post.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/schema/pteCustomerMarkers.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/__test__/transferValue.test.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/constants.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/documentMatchesGroqFilter.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/index.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/resolveSchemaTypeForPath.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/transferValue.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/types.ts create mode 100644 packages/sanity/src/core/studio/copyPaste/utils.ts create mode 100644 test/e2e/tests/fixtures/copyPasteFixture.ts diff --git a/dev/studio-e2e-testing/sanity.config.ts b/dev/studio-e2e-testing/sanity.config.ts index 74542540feb..a7828281666 100644 --- a/dev/studio-e2e-testing/sanity.config.ts +++ b/dev/studio-e2e-testing/sanity.config.ts @@ -7,8 +7,6 @@ import {muxInput} from 'sanity-plugin-mux-input' import {imageAssetSource} from 'sanity-test-studio/assetSources' import {resolveDocumentActions as documentActions} from 'sanity-test-studio/documentActions' import {assistFieldActionGroup} from 'sanity-test-studio/fieldActions/assistFieldActionGroup' -import {copyAction} from 'sanity-test-studio/fieldActions/copyAction' -import {pasteAction} from 'sanity-test-studio/fieldActions/pasteAction' import {resolveInitialValueTemplates} from 'sanity-test-studio/initialValueTemplates' import {customInspector} from 'sanity-test-studio/inspectors/custom' import {languageFilter} from 'sanity-test-studio/plugins/language-filter' @@ -53,7 +51,7 @@ export default defineConfig({ }, unstable_fieldActions: (prev, ctx) => { if (['fieldActionsTest', 'stringsTest'].includes(ctx.documentType)) { - return [...prev, assistFieldActionGroup, copyAction, pasteAction] + return [...prev, assistFieldActionGroup] } return prev diff --git a/dev/test-studio/fieldActions/copyAction.ts b/dev/test-studio/fieldActions/copyAction.ts deleted file mode 100644 index 10156b58d88..00000000000 --- a/dev/test-studio/fieldActions/copyAction.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {CopyIcon} from '@sanity/icons' -import {useCallback} from 'react' -import {defineDocumentFieldAction} from 'sanity' - -import {defineActionItem} from './define' - -export const copyAction = defineDocumentFieldAction({ - name: 'test/copy', - useAction({documentId, documentType, path}) { - const onAction = useCallback(() => { - // eslint-disable-next-line no-console - console.log('comment', {documentId, documentType, path}) - }, [documentId, documentType, path]) - - return defineActionItem({ - type: 'action', - icon: CopyIcon, - onAction, - title: 'Copy', - }) - }, -}) diff --git a/dev/test-studio/fieldActions/pasteAction.ts b/dev/test-studio/fieldActions/pasteAction.ts deleted file mode 100644 index 9607be1b464..00000000000 --- a/dev/test-studio/fieldActions/pasteAction.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {ClipboardIcon} from '@sanity/icons' -import {useCallback} from 'react' -import {defineDocumentFieldAction} from 'sanity' - -import {defineActionItem} from './define' - -export const pasteAction = defineDocumentFieldAction({ - name: 'test/paste', - useAction({documentId, documentType, path}) { - const onAction = useCallback(() => { - // eslint-disable-next-line no-console - console.log('paste', {documentId, documentType, path}) - }, [documentId, documentType, path]) - - return defineActionItem({ - type: 'action', - icon: ClipboardIcon, - onAction, - title: 'Paste', - }) - }, -}) diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 8d3030524ad..8b8dc305b3a 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -38,8 +38,6 @@ import { import {GoogleLogo, TailwindLogo, VercelLogo} from './components/workspaceLogos' import {resolveDocumentActions as documentActions} from './documentActions' import {assistFieldActionGroup} from './fieldActions/assistFieldActionGroup' -import {copyAction} from './fieldActions/copyAction' -import {pasteAction} from './fieldActions/pasteAction' import {resolveInitialValueTemplates} from './initialValueTemplates' import {customInspector} from './inspectors/custom' import {testStudioLocaleBundles} from './locales' @@ -89,11 +87,13 @@ const sharedSettings = definePlugin({ return prev }, unstable_fieldActions: (prev, ctx) => { + const defaultActions = [...prev] + if (['fieldActionsTest', 'stringsTest'].includes(ctx.documentType)) { - return [...prev, assistFieldActionGroup, copyAction, pasteAction] + return [...defaultActions, assistFieldActionGroup] } - return prev + return defaultActions }, newDocumentOptions, comments: { diff --git a/dev/test-studio/schema/standard/arrays.tsx b/dev/test-studio/schema/standard/arrays.tsx index b1ad7a75aea..18ef59d0ff4 100644 --- a/dev/test-studio/schema/standard/arrays.tsx +++ b/dev/test-studio/schema/standard/arrays.tsx @@ -1,5 +1,5 @@ import {ImageIcon, OlistIcon} from '@sanity/icons' -import {defineField, defineType} from 'sanity' +import {defineArrayMember, defineField, defineType} from 'sanity' export const topLevelArrayType = defineType({ name: 'topLevelArrayType', @@ -143,7 +143,7 @@ export default defineType({ }, ], }, - { + defineField({ name: 'arrayOfMultipleTypes', title: 'Array of multiple types', type: 'array', @@ -155,7 +155,7 @@ export default defineType({ { type: 'book', }, - { + defineArrayMember({ type: 'object', name: 'color', title: 'Color with a long title', @@ -174,10 +174,32 @@ export default defineType({ name: 'name', type: 'string', }, + defineField({ + name: 'nestedArray', + title: 'Nested array', + type: 'array', + of: [ + defineArrayMember({ + type: 'object', + name: 'color', + title: 'Color with a long title', + fields: [ + { + name: 'title', + type: 'string', + }, + { + name: 'name', + type: 'string', + }, + ], + }), + ], + }), ], - }, + }), ], - }, + }), { name: 'arrayOfMultipleTypesPopover', title: 'Array of multiple types (modal.type=popover)', diff --git a/dev/test-studio/schema/standard/portableText/allTheBellsAndWhistles.ts b/dev/test-studio/schema/standard/portableText/allTheBellsAndWhistles.ts index f350bf4fd23..c60b06f7076 100644 --- a/dev/test-studio/schema/standard/portableText/allTheBellsAndWhistles.ts +++ b/dev/test-studio/schema/standard/portableText/allTheBellsAndWhistles.ts @@ -225,11 +225,31 @@ export const ptAllTheBellsAndWhistlesType = defineType({ }), defineField({ title: 'Box Content', - name: 'body', + name: 'content', type: 'array', of: [{type: 'block'}], validation: (rule) => rule.required().error('Must have content'), }), + defineField({ + title: 'Nested object', + name: 'nestedObject', + type: 'object', + fields: [ + defineField({ + name: 'title', + title: 'Title', + type: 'string', + validation: (rule) => rule.required().warning('Should have a title'), + }), + defineField({ + title: 'Box Content', + name: 'body', + type: 'array', + of: [{type: 'block'}], + validation: (rule) => rule.required().error('Must have content'), + }), + ], + }), ], components: { preview: InfoBoxPreview as any, diff --git a/package.json b/package.json index bb97879bee6..977d107fb3f 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@bjoerge/mutiny": "^0.5.3", "@google-cloud/storage": "^7.11.0", "@jest/globals": "^29.7.0", - "@playwright/test": "1.41.2", + "@playwright/test": "1.44.1", "@repo/package.config": "workspace:*", "@repo/tsconfig": "workspace:*", "@sanity/client": "^6.21.0", diff --git a/packages/@sanity/types/src/schema/asserters.ts b/packages/@sanity/types/src/schema/asserters.ts index 83696b92e6d..82de4b54958 100644 --- a/packages/@sanity/types/src/schema/asserters.ts +++ b/packages/@sanity/types/src/schema/asserters.ts @@ -10,6 +10,8 @@ import { type BooleanSchemaType, type DeprecatedSchemaType, type DeprecationConfiguration, + type FileSchemaType, + type ImageSchemaType, type NumberSchemaType, type ObjectSchemaType, type ReferenceSchemaType, @@ -108,6 +110,16 @@ export function isReferenceSchemaType(type: unknown): type is ReferenceSchemaTyp return isRecord(type) && (type.name === 'reference' || isReferenceSchemaType(type.type)) } +/** @internal */ +export function isImageSchemaType(type: unknown): type is ImageSchemaType { + return isRecord(type) && (type.name === 'image' || isImageSchemaType(type.type)) +} + +/** @internal */ +export function isFileSchemaType(type: unknown): type is FileSchemaType { + return isRecord(type) && (type.name === 'file' || isFileSchemaType(type.type)) +} + /** @internal */ export function isDeprecatedSchemaType( type: TSchemaType, diff --git a/packages/sanity/package.json b/packages/sanity/package.json index ef6489321d5..1b12546c214 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -256,8 +256,8 @@ "devDependencies": { "@jest/expect": "^29.7.0", "@jest/globals": "^29.7.0", - "@playwright/experimental-ct-react": "1.41.2", - "@playwright/test": "1.41.2", + "@playwright/experimental-ct-react": "1.44.1", + "@playwright/test": "1.44.1", "@repo/package.config": "workspace:*", "@sanity/codegen": "3.49.0", "@sanity/generate-help-url": "^3.0.0", diff --git a/packages/sanity/playwright-ct.config.ts b/packages/sanity/playwright-ct.config.ts index ecf9d252f9f..2a56c987491 100644 --- a/packages/sanity/playwright-ct.config.ts +++ b/packages/sanity/playwright-ct.config.ts @@ -6,6 +6,7 @@ import {defineConfig, devices} from '@playwright/experimental-ct-react' const TESTS_PATH = path.join(__dirname, 'playwright-ct', 'tests') const HTML_REPORT_PATH = path.join(__dirname, 'playwright-ct', 'report') const ARTIFACT_OUTPUT_PATH = path.join(__dirname, 'playwright-ct', 'results') +const isCI = !!process.env.CI /** * See https://playwright.dev/docs/test-configuration. @@ -36,10 +37,10 @@ export default defineConfig({ ], /* Maximum time one test can run for. */ - timeout: 10 * 1000, + timeout: 30 * 1000, expect: { // Maximum time expect() should wait for the condition to be met. - timeout: 5 * 1000, + timeout: 10 * 1000, }, /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ @@ -47,7 +48,8 @@ export default defineConfig({ /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ actionTimeout: 40 * 1000, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + trace: isCI ? 'on-all-retries' : 'retain-on-failure', + video: isCI ? 'on-first-retry' : 'retain-on-failure', /* Port to use for Playwright component endpoint. */ ctPort: 3100, /* Configure Playwright vite config */ @@ -69,8 +71,29 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ - {name: 'chromium', use: {...devices['Desktop Chrome']}}, - {name: 'firefox', use: {...devices['Desktop Firefox']}}, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + permissions: ['clipboard-read', 'clipboard-write'], + contextOptions: { + // chromium-specific permissions + permissions: ['clipboard-read', 'clipboard-write'], + }, + }, + }, + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + launchOptions: { + firefoxUserPrefs: { + 'dom.events.asyncClipboard.readText': true, + 'dom.events.testing.asyncClipboard': true, + }, + }, + }, + }, {name: 'webkit', use: {...devices['Desktop Safari']}}, ], }) diff --git a/packages/sanity/playwright-ct/tests/fixtures/copyPasteFixture.ts b/packages/sanity/playwright-ct/tests/fixtures/copyPasteFixture.ts new file mode 100644 index 00000000000..edabe7523fb --- /dev/null +++ b/packages/sanity/playwright-ct/tests/fixtures/copyPasteFixture.ts @@ -0,0 +1,89 @@ +import {test as base} from '@playwright/experimental-ct-react' + +export const test = base.extend<{ + getClipboardItemByMimeTypeAsText: (mimeType: string) => Promise + setClipboardItems: (items: ClipboardItem[]) => Promise + getClipboardItems: () => Promise + getClipboardItemsAsText: () => Promise +}>({ + page: async ({page}, use) => { + const setupClipboardMocks = async () => { + await page.addInitScript(() => { + const mockClipboard = { + read: () => { + return Promise.resolve((window as any).__clipboardItems) + }, + write: (newItems: ClipboardItem[]) => { + ;(window as any).__clipboardItems = newItems + + return Promise.resolve() + }, + readText: () => { + const items = (window as any).__clipboardItems as ClipboardItem[] + const textItem = items.find((item) => item.types.includes('text/plain')) + return textItem + ? textItem.getType('text/plain').then((blob: Blob) => blob.text()) + : Promise.resolve('') + }, + writeText: (text: string) => { + const textBlob = new Blob([text], {type: 'text/plain'}) + ;(window as any).__clipboardItems = [new ClipboardItem({'text/plain': textBlob})] + return Promise.resolve() + }, + } + Object.defineProperty(Object.getPrototypeOf(navigator), 'clipboard', { + value: mockClipboard, + writable: false, + }) + ;(window as any).__clipboardItems = [] + }) + } + + await setupClipboardMocks() + + page.on('framenavigated', async () => { + await setupClipboardMocks() + }) + + await use(page) + }, + + setClipboardItems: async ({page}, use) => { + await use(async (items: ClipboardItem[]) => { + ;(window as any).__clipboardItems = items + }) + }, + + getClipboardItems: async ({page}, use) => { + await use(() => { + return page.evaluate(() => navigator.clipboard.read()) + }) + }, + + getClipboardItemsAsText: async ({page}, use) => { + await use(async () => { + return page.evaluate(async () => { + const items = await navigator.clipboard.read() + const textItem = items.find((item) => item.types.includes('text/plain')) + + return textItem + ? textItem.getType('text/plain').then((blob: Blob) => blob.text()) + : Promise.resolve('') + }) + }) + }, + + getClipboardItemByMimeTypeAsText: async ({page}, use) => { + await use(async (mimeType: string) => { + return page.evaluate(async (mime) => { + const items = await navigator.clipboard.read() + const textItem = items.find((item) => item.types.includes(mime)) + const content = textItem ? textItem.getType(mime).then((blob: Blob) => blob.text()) : null + + return content + }, mimeType) + }) + }, +}) + +export const {expect} = test diff --git a/packages/sanity/playwright-ct/tests/fixtures/debugFixture.ts b/packages/sanity/playwright-ct/tests/fixtures/debugFixture.ts new file mode 100644 index 00000000000..4f541b361a6 --- /dev/null +++ b/packages/sanity/playwright-ct/tests/fixtures/debugFixture.ts @@ -0,0 +1,31 @@ +import {test as base} from '@playwright/experimental-ct-react' + +export const test = base.extend<{ + logActiveElement: () => Promise<{ + tagName: string + id: string + className: string + name: string + attributes: Record + }> +}>({ + logActiveElement: async ({page}, use) => { + await use(async () => { + const activeElementInfo = await page.evaluate(() => { + const active = document.activeElement as HTMLElement + return { + tagName: active.tagName, + id: active.id, + className: active.className, + name: active.nodeName, + attributes: Object.fromEntries( + Array.from(active.attributes).map((attr) => [attr.name, attr.value]), + ), + } + }) + return Promise.resolve(activeElementInfo) + }) + }, +}) + +export {expect} from '@playwright/test' diff --git a/packages/sanity/playwright-ct/tests/fixtures/index.ts b/packages/sanity/playwright-ct/tests/fixtures/index.ts new file mode 100644 index 00000000000..528550c1f89 --- /dev/null +++ b/packages/sanity/playwright-ct/tests/fixtures/index.ts @@ -0,0 +1,10 @@ +import {test as base} from '@playwright/experimental-ct-react' +import {mergeTests} from '@playwright/test' + +import {test as copyPasteFixture} from './copyPasteFixture' +import {test as debugFixture} from './debugFixture' +import {test as scrollToTopFixture} from './scrollToTopFixture' + +export const test = mergeTests(base, copyPasteFixture, scrollToTopFixture, debugFixture) + +export {expect} from '@playwright/test' diff --git a/packages/sanity/playwright-ct/tests/fixtures/scrollToTopFixture.ts b/packages/sanity/playwright-ct/tests/fixtures/scrollToTopFixture.ts new file mode 100644 index 00000000000..dc7c7d0f3b2 --- /dev/null +++ b/packages/sanity/playwright-ct/tests/fixtures/scrollToTopFixture.ts @@ -0,0 +1,20 @@ +import {expect, type Locator, test as baseTest} from '@playwright/test' + +type ScrollToTop = (locator: Locator) => Promise + +export const test = baseTest.extend<{ + scrollToTop: ScrollToTop +}>({ + scrollToTop: async ({page}, use) => { + const scrollToTop: ScrollToTop = async (locator: Locator) => { + await locator.evaluate((element) => { + element.scrollIntoView({block: 'start', inline: 'nearest'}) + }) + + const boundingBox = await locator.boundingBox() + await expect(boundingBox?.y).toBeLessThanOrEqual(1) + } + + await use(scrollToTop) + }, +}) diff --git a/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/ObjectBlock.spec.tsx b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/ObjectBlock.spec.tsx index 350e65aba84..c1c42a264d4 100644 --- a/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/ObjectBlock.spec.tsx +++ b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/ObjectBlock.spec.tsx @@ -1,5 +1,4 @@ -import {expect, test} from '@playwright/experimental-ct-react' - +import {expect, test} from '../../../fixtures' import {testHelpers} from '../../../utils/testHelpers' import {ObjectBlockStory} from './ObjectBlockStory' @@ -140,19 +139,20 @@ test.describe('Portable Text Input', () => { await expect($closeButton.or($closeButtonSvg).first()).toBeFocused() // Tab to the input - await page.keyboard.press('Tab') - - const $dialogInput = await page.getByTestId('default-edit-object-dialog').locator('input') + await page.keyboard.press('Tab+Tab') // Assertion: Dialog should not be closed when you tab to input - await expect($dialog).not.toBeHidden() + await expect(page.getByTestId('default-edit-object-dialog')).not.toBeHidden() // Check that we have focus on the input - await expect($dialogInput).toBeFocused() + await expect(page.getByTestId('default-edit-object-dialog').locator('input')).toBeFocused() // Assertion: Focus should be locked - await page.keyboard.press('Tab+Tab') - await expect($dialogInput).toBeFocused() + await page.keyboard.press('Tab+Tab+Tab') + + await expect(page.getByTestId('default-edit-object-dialog')).not.toBeHidden() + + await expect(page.getByTestId('default-edit-object-dialog').locator('input')).toBeFocused() }) test('Blocks that appear in the menu bar should always display a title', async ({ diff --git a/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPaste.spec.tsx b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPaste.spec.tsx index 6447bcc8297..856ed8bee59 100644 --- a/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPaste.spec.tsx +++ b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPaste.spec.tsx @@ -1,9 +1,10 @@ /* eslint-disable max-nested-callbacks */ import path from 'node:path' -import {expect, test} from '@playwright/experimental-ct-react' +// import {expect, test} from '@playwright/experimental-ct-react' import {type Path, type SanityDocument} from '@sanity/types' +import {expect, test} from '../../../../fixtures' import {testHelpers} from '../../../../utils/testHelpers' import CopyPasteStory from './CopyPasteStory' import { diff --git a/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPasteFields.spec.tsx b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPasteFields.spec.tsx new file mode 100644 index 00000000000..63796160c80 --- /dev/null +++ b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPasteFields.spec.tsx @@ -0,0 +1,272 @@ +/* eslint-disable max-nested-callbacks */ + +// import {expect, test} from '@playwright/experimental-ct-react' +import {type Path, type SanityDocument} from '@sanity/types' + +import {expect, test} from '../../../../fixtures' +import CopyPasteFieldsStory from './CopyPasteFieldsStory' + +export type UpdateFn = () => {focusPath: Path; document: SanityDocument} + +const document: SanityDocument = { + _id: '123', + _type: 'test', + _createdAt: new Date().toISOString(), + _updatedAt: new Date().toISOString(), + _rev: '123', + arrayOfPrimitives: ['One', 'Two', true], + arrayOfMultipleTypes: [ + { + _key: '6724abb6eee4', + _type: 'color', + title: 'Alright, testing this. Testing this as well tresting to typing here ee e', + }, + ], +} + +test.describe('Copy and pasting fields', () => { + test.beforeEach(async ({page, browserName}) => { + test.skip(browserName === 'webkit', 'Currently not working in Webkit') + }) + + test.describe('Object input', () => { + // TODO: fix this test + test.skip(`Copy and paste via field actions`, async ({ + browserName, + getClipboardItemsAsText, + mount, + page, + }) => { + await mount() + + await expect(page.getByTestId(`field-objectWithColumns`)).toBeVisible() + + const $object = page.getByTestId('field-objectWithColumns').locator(`[tabindex="0"]`).first() + + await expect($object).toBeVisible() + + await page + .getByTestId('field-objectWithColumns.string1') + .locator('input') + .fill('A string to copy') + + await page + .getByTestId('field-objectWithColumns.string2') + .locator('input') + .fill('This is the second field') + + await expect( + page.getByTestId('field-objectWithColumns.string2').locator('input'), + ).toHaveValue('This is the second field') + + // https://github.com/microsoft/playwright/pull/30572 + // maybe part of 1.44 + // await page.keyboard.press('ControlOrMeta+C') + let $fieldActions = page + .getByTestId('field-actions-menu-objectWithColumns') + .getByTestId('field-actions-trigger') + + await $fieldActions.focus() + await expect($fieldActions).toBeFocused() + await $fieldActions.press('Enter') + + await expect(page.getByRole('menuitem', {name: 'Copy field'})).toBeVisible() + await page.getByRole('menuitem', {name: 'Copy field'}).press('Enter') + + await expect(page.getByText(`Field "Object with columns" copied`)).toBeVisible() + + // Check that the plain text version is set + await expect(await getClipboardItemsAsText()).toContain('A string to copy') + await expect(await getClipboardItemsAsText()).toContain('This is the second field') + + await page.getByTestId('field-objectWithColumns.string1').locator('input').focus() + await page.keyboard.press('Meta+A') + await page.keyboard.press('Delete') + + $fieldActions = page + .getByTestId('field-actions-menu-objectWithColumns') + .getByTestId('field-actions-trigger') + + await $fieldActions.focus() + + await expect($fieldActions).toBeVisible() + + await $fieldActions.press('Enter') + + await expect(page.getByRole('menuitem', {name: 'Paste field'})).toBeVisible() + await page.getByRole('menuitem', {name: 'Paste field'}).press('Enter') + + await expect(page.getByText(`Field "Object with columns" updated`)).toBeVisible() + + await expect( + page.getByTestId('field-objectWithColumns.string1').locator('input'), + ).toHaveValue('A string to copy') + }) + + // TODO: fix this test + test.skip(`Copy via keyboard shortcut`, async ({ + browserName, + getClipboardItemsAsText, + mount, + page, + }) => { + await mount() + + await expect(page.getByTestId(`field-objectWithColumns`)).toBeVisible() + + const $object = page.getByTestId('field-objectWithColumns').locator(`[tabindex="0"]`).first() + + await expect($object).toBeVisible() + + await page + .getByTestId('field-objectWithColumns.string1') + .locator('input') + .fill('A string to copy') + + await page + .getByTestId('field-objectWithColumns.string2') + .locator('input') + .fill('This is the second field') + + // https://github.com/microsoft/playwright/pull/30572 + // maybe part of 1.44 + // await page.keyboard.press('ControlOrMeta+C') + await $object.focus() + await expect($object).toBeFocused() + await $object.press('ControlOrMeta+C') + + await expect(page.getByText(`Field "Object with columns" copied`)).toBeVisible() + + // Check that the plain text version is set + await expect(await getClipboardItemsAsText()).toContain('A string to copy') + await expect(await getClipboardItemsAsText()).toContain('This is the second field') + + await $object.focus() + await expect($object).toBeFocused() + await $object.press('ControlOrMeta+V') + + await expect(page.getByText(`Field "Object with columns" updated`)).toBeVisible() + + await expect( + page.getByTestId('field-objectWithColumns.string1').locator('input'), + ).toHaveValue('A string to copy') + }) + }) + + test.describe('String input', () => { + // TODO: fix this test + test.skip(`Copy and pasting via field actions`, async ({ + browserName, + scrollToTop, + getClipboardItemsAsText, + mount, + page, + }) => { + await mount() + + await scrollToTop(page.getByTestId(`field-title`)) + await expect(page.getByTestId(`field-title`)).toBeVisible() + + await page.getByTestId('field-title').locator('input').fill('A string to copy') + await expect(page.getByTestId('field-title').locator('input')).toHaveValue('A string to copy') + + const fieldActionsId = 'field-actions-menu-title' + const fieldActionsTriggerId = 'field-actions-trigger' + + await scrollToTop(page.getByTestId(fieldActionsId)) + await page.getByTestId(fieldActionsId).getByTestId(fieldActionsTriggerId).press('Enter') + + // await scrollToTop(page.getByRole('menuitem', {name: 'Copy field'})) + await expect(page.getByRole('menuitem', {name: 'Copy field'})).toBeVisible() + await page.getByRole('menuitem', {name: 'Copy field'}).press('Enter') + + await expect(page.getByText(`Field "Title" copied`)).toBeVisible() + + // Check that the plain text version is set + await expect(await getClipboardItemsAsText()).toContain('A string to copy') + + await page.getByTestId('field-title').locator('input').fill('') + + // Trigger the field actions menu + await scrollToTop(page.getByTestId(fieldActionsId)) + await page.getByTestId(fieldActionsId).getByTestId(fieldActionsTriggerId).press('Enter') + + // Click on the "Paste field" option in the menu + await expect(page.getByRole('menuitem', {name: 'Paste field'})).toBeVisible() + await page.getByRole('menuitem', {name: 'Paste field'}).press('Enter') + + // Verify that the field content is updated with the pasted value + await expect(page.getByText(`Field "Title" updated`)).toBeVisible() + await expect(page.getByTestId('field-title').locator('input')).toHaveValue('A string to copy') + }) + }) + + test.describe('Array input', () => { + // TODO: fix this test + test.skip(`Copy and pasting via field actions`, async ({ + browserName, + getClipboardItemsAsText, + mount, + page, + }) => { + await mount() + + await expect(page.getByTestId(`field-arrayOfPrimitives`)).toBeVisible() + + // https://github.com/microsoft/playwright/pull/30572 + // maybe part of 1.44 + // await page.keyboard.press('ControlOrMeta+C') + await page + .getByTestId('field-actions-menu-arrayOfPrimitives') + .getByTestId('field-actions-trigger') + .press('Enter') + + await expect(page.getByRole('menuitem', {name: 'Copy field'})).toBeVisible() + await page.getByRole('menuitem', {name: 'Copy field'}).press('Enter') + + await expect(page.getByText(`Field "Array of primitives" copied`)).toBeVisible() + + // Check that the plain text version is set + await expect(await getClipboardItemsAsText()).toContain('One, Two') + + const $rowActionTrigger = page.locator('[id="arrayOfPrimitives[0]-menuButton"]') + + await $rowActionTrigger.focus() + await expect($rowActionTrigger).toBeFocused() + await $rowActionTrigger.press('Enter') + + const $removeButton = page.getByRole('menuitem', {name: 'Remove'}).first() + + await expect($removeButton).toBeVisible() + + await $removeButton.press('Enter') + + expect( + page.getByTestId(`field-arrayOfPrimitives`).getByTestId('string-input').first(), + ).not.toHaveValue('One') + expect( + page.getByTestId(`field-arrayOfPrimitives`).getByTestId('string-input').first(), + ).toHaveValue('Two') + + await page + .getByTestId('field-actions-menu-arrayOfPrimitives') + .getByTestId('field-actions-trigger') + .press('Enter') + + await expect(page.getByRole('menuitem', {name: 'Paste field'})).toBeVisible() + await page.getByRole('menuitem', {name: 'Paste field'}).press('Enter') + + await expect(page.getByText(`Field "Array of primitives" updated`)).toBeVisible() + + expect( + page.getByTestId(`field-arrayOfPrimitives`).getByTestId('string-input').first(), + ).toHaveValue('One') + + // $fieldActions = page + // .getByTestId('field-actions-menu-arrayOfPrimitives') + // .getByTestId('field-actions-trigger') + + // arrayOfPrimitives[0]-menuButton + }) + }) +}) diff --git a/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPasteFieldsStory.tsx b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPasteFieldsStory.tsx new file mode 100644 index 00000000000..2b28ac575f7 --- /dev/null +++ b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPasteFieldsStory.tsx @@ -0,0 +1,121 @@ +/* eslint-disable react/jsx-no-bind */ +import {type SanityDocument} from '@sanity/client' +import {defineField, defineType, type Path} from '@sanity/types' + +import {TestForm} from '../../../utils/TestForm' +import {TestWrapper} from '../../../utils/TestWrapper' + +const SCHEMA_TYPES = [ + defineType({ + type: 'document', + name: 'test', + title: 'Test', + fields: [ + defineField({ + type: 'string', + name: 'title', + title: 'Title', + }), + defineField({ + type: 'object', + name: 'objectWithColumns', + title: 'Object with columns', + options: { + columns: 4, + }, + fields: [ + { + type: 'string', + title: 'String 1', + description: 'this is a king kong description', + name: 'string1', + }, + { + type: 'string', + title: 'String 2', + name: 'string2', + }, + { + type: 'number', + title: 'Number 1', + name: 'number1', + }, + { + type: 'number', + title: 'Number 2', + name: 'number2', + }, + { + type: 'image', + title: 'Image 1', + name: 'image1', + }, + { + name: 'file', + type: 'file', + title: 'File', + }, + ], + }), + defineField({ + name: 'arrayOfPrimitives', + type: 'array', + of: [ + { + type: 'string', + title: 'A string', + }, + { + type: 'number', + title: 'A number', + }, + { + type: 'boolean', + title: 'A boolean', + }, + ], + }), + defineField({ + name: 'arrayOfMultipleTypes', + title: 'Array of multiple types', + type: 'array', + of: [ + { + type: 'image', + }, + { + type: 'object', + name: 'color', + title: 'Color with a long title', + fields: [ + { + name: 'title', + type: 'string', + }, + { + name: 'name', + type: 'string', + }, + ], + }, + ], + }), + ], + }), +] + +export function CopyPasteFieldsStory({ + focusPath, + document, +}: { + focusPath?: Path + document?: SanityDocument +}) { + return ( + + + + ) +} + +export default CopyPasteFieldsStory diff --git a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx index 5191df13bdb..60d134cfc03 100644 --- a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx +++ b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx @@ -4,22 +4,30 @@ import { type ValidationContext, type ValidationMarker, } from '@sanity/types' +import {BoundaryElementProvider, Box} from '@sanity/ui' import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import { createPatchChannel, + type DocumentFieldAction, EMPTY_ARRAY, FormBuilder, type FormBuilderProps, type FormNodePresence, getExpandOperations, type PatchEvent, + ScrollContainer, setAtPath, type StateTree, + useCopyPaste, useFormState, + useGlobalCopyPasteElementHandler, + useSource, useWorkspace, validateDocument, + VirtualizerScrollInstanceProvider, type Workspace, } from 'sanity' +import {css, styled} from 'styled-components' import {applyAll} from '../../../../src/core/form/patch/applyPatch' import {PresenceProvider} from '../../../../src/core/form/studio/contexts/Presence' @@ -43,6 +51,20 @@ interface TestFormProps { presence?: FormNodePresence[] } +const Scroller = styled(ScrollContainer)<{$disabled: boolean}>(({$disabled}) => { + if ($disabled) { + return {height: '100%'} + } + + return css` + height: 100%; + overflow: auto; + position: relative; + scroll-behavior: smooth; + outline: none; + ` +}) + export function TestForm(props: TestFormProps) { const { document: documentFromProps, @@ -53,15 +75,21 @@ export function TestForm(props: TestFormProps) { presence: presenceFromProps = EMPTY_ARRAY, } = props + const {setDocumentMeta} = useCopyPaste() + const wrapperRef = useRef(null) const [validation, setValidation] = useState([]) const [openPath, onSetOpenPath] = useState(openPathFromProps) const [fieldGroupState, onSetFieldGroupState] = useState>() const [collapsedPaths, onSetCollapsedPath] = useState>() const [collapsedFieldSets, onSetCollapsedFieldSets] = useState>() + const [documentScrollElement, setDocumentScrollElement] = useState(null) + const formContainerElement = useRef(null) + const documentId = '123' + const documentType = 'test' const [document, setDocument] = useState( documentFromProps || { - _id: '123', - _type: 'test', + _id: documentId, + _type: documentType, _createdAt: new Date().toISOString(), _updatedAt: new Date().toISOString(), _rev: '123', @@ -70,6 +98,12 @@ export function TestForm(props: TestFormProps) { const [focusPath, setFocusPath] = useState(() => focusPathFromProps || []) const patchChannel = useMemo(() => createPatchChannel(), []) + useGlobalCopyPasteElementHandler({ + element: wrapperRef.current, + focusPath, + value: document, + }) + useEffect(() => { if (documentFromProps) { setDocument(documentFromProps) @@ -96,6 +130,15 @@ export function TestForm(props: TestFormProps) { const workspace = useWorkspace() const schemaType = workspace.schema.get('test') + const { + document: { + // actions: documentActions, + // badges: documentBadges, + unstable_fieldActions: fieldActionsResolver, + // unstable_languageFilter: languageFilterResolver, + // inspectors: inspectorsResolver, + }, + } = useSource() if (!schemaType) { throw new Error('missing schema type') @@ -105,6 +148,11 @@ export function TestForm(props: TestFormProps) { throw new Error('schema type is not an object') } + const fieldActions: DocumentFieldAction[] = useMemo( + () => (schemaType ? fieldActionsResolver({documentId, documentType, schemaType}) : []), + [documentId, documentType, fieldActionsResolver, schemaType], + ) + useEffect(() => { validateStaticDocument(document, workspace, (result) => setValidation(result)) }, [document, workspace]) @@ -151,7 +199,7 @@ export function TestForm(props: TestFormProps) { }) } - const handleChange = useCallback((event: any) => patchRef.current(event), []) + const handleChange = useCallback((event: PatchEvent) => patchRef.current(event), []) const handleOnSetCollapsedPath = useCallback((path: Path, collapsed: boolean) => { onSetCollapsedPath((prevState) => setAtPath(prevState, path, collapsed)) @@ -186,10 +234,21 @@ export function TestForm(props: TestFormProps) { [formStateRef], ) + useEffect(() => { + setDocumentMeta({ + documentId, + documentType, + schemaType: schemaType, + onChange: handleChange, + }) + }, [schemaType, handleChange, setDocumentMeta]) + const formBuilderProps: FormBuilderProps = useMemo( () => ({ // eslint-disable-next-line camelcase __internal_patchChannel: patchChannel, + // eslint-disable-next-line camelcase + __internal_fieldActions: fieldActions, changed: false, changesOpen: false, collapsedFieldSets: undefined, @@ -216,6 +275,7 @@ export function TestForm(props: TestFormProps) { value: formState?.value as FormDocumentValue, }), [ + fieldActions, formState?.focused, formState?.focusPath, formState?.groups, @@ -239,9 +299,26 @@ export function TestForm(props: TestFormProps) { ], ) return ( - - - +
+ + + + + + + + + + + +
) } diff --git a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx index ae3ad6c2b51..69f98a55a1c 100644 --- a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx +++ b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestWrapper.tsx @@ -1,8 +1,11 @@ import {type SanityClient} from '@sanity/client' -import {Card, LayerProvider, studioTheme, ThemeProvider, ToastProvider} from '@sanity/ui' +import {Card, LayerProvider, ThemeProvider, ToastProvider} from '@sanity/ui' +import {buildTheme, type RootTheme} from '@sanity/ui/theme' import {type ReactNode, Suspense, useEffect, useState} from 'react' import { + ChangeConnectorRoot, ColorSchemeProvider, + CopyPasteProvider, defineConfig, ResourceCacheProvider, type SchemaTypeDefinition, @@ -13,6 +16,7 @@ import { WorkspaceProvider, } from 'sanity' import {Pane, PaneContent, PaneLayout} from 'sanity/structure' +import {styled} from 'styled-components' import {createMockSanityClient} from '../../../../test/mocks/mockSanityClient' import {getMockWorkspace} from '../../../../test/testUtils/getMockWorkspaceFromConfig' @@ -22,6 +26,15 @@ interface TestWrapperProps { betaFeatures?: WorkspaceOptions['beta'] schemaTypes: SchemaTypeDefinition[] } +const studioThemeConfig: RootTheme = buildTheme() + +const StyledChangeConnectorRoot = styled(ChangeConnectorRoot)` + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +` /** * @description This component is used to wrap all tests in the providers it needs to be able to run successfully. @@ -59,23 +72,31 @@ export const TestWrapper = (props: TestWrapperProps): JSX.Element | null => { return ( - + - - - - - - {children} - - - - - + + + + {}} + onSetFocus={() => {}} + > + + + + {children} + + + + + + + diff --git a/packages/sanity/playwright-ct/tests/utils/testHelpers.tsx b/packages/sanity/playwright-ct/tests/utils/testHelpers.tsx index fdab1f0bb17..45bff1a1e25 100644 --- a/packages/sanity/playwright-ct/tests/utils/testHelpers.tsx +++ b/packages/sanity/playwright-ct/tests/utils/testHelpers.tsx @@ -1,7 +1,7 @@ import {readFileSync} from 'node:fs' import path from 'node:path' -import {type Locator, type PlaywrightTestArgs} from '@playwright/test' +import {expect, type Locator, type PlaywrightTestArgs} from '@playwright/test' export const DEFAULT_TYPE_DELAY = 20 @@ -10,9 +10,10 @@ export function testHelpers({page}: {page: PlaywrightTestArgs['page']}) { const $overlay = $pteField.getByTestId('activate-overlay') if (await $overlay.isVisible()) { await $overlay.focus() - await page.keyboard.press('Space') + await $overlay.press('Space') } - await $overlay.waitFor({state: 'detached', timeout: 1000}) + + await expect($overlay).not.toBeVisible({timeout: 1500}) } return { /** @@ -288,6 +289,40 @@ export function testHelpers({page}: {page: PlaywrightTestArgs['page']}) { toggleHotkey: async (hotkey: string, modifierKey?: string) => { await page.keyboard.press(modifierKey ? `${modifierKey}+${hotkey}` : hotkey) }, + mockClipboard: async () => { + await page.evaluate(() => { + const clipboardData = {text: ''} + + // Mock the clipboard writeText method + navigator.clipboard.writeText = async (text) => { + clipboardData.text = text + return Promise.resolve() + } + + // Mock the clipboard readText method + navigator.clipboard.readText = async () => { + return Promise.resolve(clipboardData.text) + } + }) + }, + setClipboardText: async (text: string) => { + await page.evaluate((checkText) => { + navigator.clipboard.writeText(checkText) + }, text) + }, + + getClipboardText: async () => { + return await page.evaluate(() => { + return navigator.clipboard.readText() + }) + }, + hasClipboardText: async (text: string) => { + const value = await page.evaluate(() => { + return navigator.clipboard.readText() + }) + + return value === text + }, /** * Will wait for the documentState evaulate callback to be true before the docmueentState is returned * @param evaluteCallback - the callback that will be evaluated diff --git a/packages/sanity/src/_singletons/core/studio/copyPaste/CopyPasteContext.ts b/packages/sanity/src/_singletons/core/studio/copyPaste/CopyPasteContext.ts new file mode 100644 index 00000000000..a168ce4f1c9 --- /dev/null +++ b/packages/sanity/src/_singletons/core/studio/copyPaste/CopyPasteContext.ts @@ -0,0 +1,9 @@ +import {createContext} from 'react' + +import type {CopyPasteContextType} from '../../../../core/studio/copyPaste' + +/** + * @beta + * @hidden + */ +export const CopyPasteContext = createContext(null) diff --git a/packages/sanity/src/_singletons/index.ts b/packages/sanity/src/_singletons/index.ts index cf735122853..48ad93c8e62 100644 --- a/packages/sanity/src/_singletons/index.ts +++ b/packages/sanity/src/_singletons/index.ts @@ -37,6 +37,7 @@ export * from './core/studio/ColorSchemeValueContext' export * from './core/studio/components/navbar/free-trial/FreeTrialContext' export * from './core/studio/components/navbar/search/components/filters/filter/inputs/date/datePicker/calendar/CalendarContext' export * from './core/studio/components/navbar/search/SearchContext' +export * from './core/studio/copyPaste/CopyPasteContext' export * from './core/studio/NavbarContext' export * from './core/studio/router/RouterHistoryContext' export * from './core/studio/SourceContext' diff --git a/packages/sanity/src/core/config/document/fieldActions/index.ts b/packages/sanity/src/core/config/document/fieldActions/index.ts index 58a7abdbeeb..6d198280159 100644 --- a/packages/sanity/src/core/config/document/fieldActions/index.ts +++ b/packages/sanity/src/core/config/document/fieldActions/index.ts @@ -1,3 +1,5 @@ +import {copyAction} from '../../../form/field/actions/copyAction' +import {pasteAction} from '../../../form/field/actions/pasteAction' import {type DocumentFieldAction} from './types' export * from './define' @@ -5,4 +7,4 @@ export * from './reducer' export * from './types' /** @internal */ -export const initialDocumentFieldActions: DocumentFieldAction[] = [] +export const initialDocumentFieldActions: DocumentFieldAction[] = [copyAction, pasteAction] diff --git a/packages/sanity/src/core/form/components/formField/FormField.tsx b/packages/sanity/src/core/form/components/formField/FormField.tsx index 6c31327c98d..5a1e8251a57 100644 --- a/packages/sanity/src/core/form/components/formField/FormField.tsx +++ b/packages/sanity/src/core/form/components/formField/FormField.tsx @@ -85,6 +85,7 @@ export const FormField = memo(function FormField( fieldFocused={Boolean(focused)} fieldHovered={hovered} presence={presence} + inputId={inputId} content={ (false) // State for if an actions menu is open @@ -268,7 +270,11 @@ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { sizing="border" > {hasActions && ( - + )} diff --git a/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx b/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx index 00df96598c5..977034160b4 100644 --- a/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx +++ b/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx @@ -84,15 +84,30 @@ const Content = styled(Box)<{ * https://styled-components.com/docs/api#transient-props */ $borderLeft: boolean + $focused?: boolean theme: Theme }>((props) => { - const {$borderLeft, theme} = props + const {$borderLeft, $focused, theme} = props const {focusRing} = theme.sanity const {base} = theme.sanity.color return css` outline: none; border-left: ${$borderLeft ? '1px solid var(--card-border-color)' : undefined}; + transition: + border-color 0.2s ease-in-out, + box-shadow 0.2s ease-in-out; + + ${$borderLeft && + $focused && + `border-left: 1px solid var(--card-focus-ring-color); + box-shadow: inset 1px 0 0 var(--card-focus-ring-color);`} + + ${$borderLeft && + !$focused && + ` + box-shadow: inset 0 0 0 transparent; + `} &:focus { box-shadow: ${focusRingStyle({base, focusRing: {...focusRing, offset: 2}})}; @@ -183,6 +198,7 @@ export const FormFieldSet = forwardRef(function FormFieldSet( fieldFocused={Boolean(focused)} fieldHovered={hovered} presence={presence} + inputId={inputId} content={ @@ -225,6 +241,7 @@ export const FormFieldSet = forwardRef(function FormFieldSet( 0} + $focused={Boolean(focused)} hidden={collapsed} paddingLeft={level === 0 ? 0 : 3} onFocus={typeof tabIndex === 'number' && tabIndex > -1 ? handleFocus : undefined} diff --git a/packages/sanity/src/core/form/field/actions/FieldActionMenu.tsx b/packages/sanity/src/core/form/field/actions/FieldActionMenu.tsx index d9a2354d00e..575a3aeb74e 100644 --- a/packages/sanity/src/core/form/field/actions/FieldActionMenu.tsx +++ b/packages/sanity/src/core/form/field/actions/FieldActionMenu.tsx @@ -138,7 +138,8 @@ function RootFieldActionMenuGroup(props: { button={