diff --git a/package.json b/package.json index c837d6d2161..98a2fad581e 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.20.0", diff --git a/packages/@sanity/portable-text-editor/package.json b/packages/@sanity/portable-text-editor/package.json index e0f70ae6f42..d0296ae8f73 100644 --- a/packages/@sanity/portable-text-editor/package.json +++ b/packages/@sanity/portable-text-editor/package.json @@ -70,7 +70,7 @@ }, "devDependencies": { "@jest/globals": "^29.7.0", - "@playwright/test": "1.41.2", + "@playwright/test": "1.44.1", "@portabletext/toolkit": "^2.0.15", "@repo/package.config": "workspace:*", "@sanity/diff-match-patch": "^3.1.1", diff --git a/packages/sanity/package.json b/packages/sanity/package.json index 2de4d389425..83a834cdc5a 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -255,8 +255,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.46.1", "@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..4532fd8812f 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. @@ -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..46d8bfc9b27 --- /dev/null +++ b/packages/sanity/playwright-ct/tests/formBuilder/inputs/PortableText/copyPaste/CopyPasteFields.spec.tsx @@ -0,0 +1,292 @@ +/* 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', () => { + test(`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') + + if (browserName === 'firefox') { + await expect(page.getByText(`Your browser doesn't support this action (yet)`)).toBeVisible() + + return + } + + 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') + }) + + test(`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') + + if (browserName === 'firefox') { + await expect(page.getByText(`Your browser doesn't support this action (yet)`)).toBeVisible() + + return + } + + 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', () => { + test(`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') + + if (browserName === 'firefox') { + await expect(page.getByText(`Your browser doesn't support this action (yet)`)).toBeVisible() + + return + } + + 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', () => { + test(`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') + + if (browserName === 'firefox') { + await expect(page.getByText(`Your browser doesn't support this action (yet)`)).toBeVisible() + + return + } + + 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 872012599b6..158a804b1f5 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' @@ -42,6 +50,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, @@ -51,15 +73,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([]) 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', @@ -68,6 +96,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) @@ -94,6 +128,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') @@ -103,6 +146,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]) @@ -149,7 +197,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)) @@ -184,10 +232,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, @@ -213,6 +272,7 @@ export function TestForm(props: TestFormProps) { value: formState?.value as FormDocumentValue, }), [ + fieldActions, formState?.focusPath, formState?.focused, formState?.groups, @@ -235,9 +295,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 0e3054d6e74..225ca23da7d 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, ResourceCacheProvider, type SchemaTypeDefinition, SourceProvider, @@ -11,10 +14,21 @@ 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' +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. * It provides a mock Sanity client and a mock workspace. @@ -53,23 +67,31 @@ export const TestWrapper = ({ 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 d14a18127b1..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 { /** 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 88e6d86adf0..31ef17b31e5 100644 --- a/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx +++ b/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx @@ -186,6 +186,7 @@ export const FormFieldSet = forwardRef(function FormFieldSet( fieldFocused={Boolean(focused)} fieldHovered={hovered} presence={presence} + inputId={inputId} content={ 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={