diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 9880bbe5c92a84..f71283f97d3c1f 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -11,6 +11,7 @@ - Introduce a new `array` DataForm Edit control that supports multi-selection. [#71136](https://github.com/WordPress/gutenberg/pull/71136) - Add `enableMoving` option to the `table` layout to allow or disallow column moving left and right. [#71120](https://github.com/WordPress/gutenberg/pull/71120) - Add infinite scroll support across all layout types (grid, list, table). Enable infinite scroll by providing an `infiniteScrollHandler` function in the `paginationInfo` prop and toggling the feature in the view configuration. ([#70955](https://github.com/WordPress/gutenberg/pull/70955)) +- Add support for modal in DataForm panel layouts. [#71212](https://github.com/WordPress/gutenberg/pull/71212) ### Enhancements diff --git a/packages/dataviews/src/components/dataform/stories/index.story.tsx b/packages/dataviews/src/components/dataform/stories/index.story.tsx index e749cbebfd8cba..e2ce8f25e00b83 100644 --- a/packages/dataviews/src/components/dataform/stories/index.story.tsx +++ b/packages/dataviews/src/components/dataform/stories/index.story.tsx @@ -30,6 +30,9 @@ type SamplePost = { filesize?: number; dimensions?: string; tags?: string[]; + address1?: string; + address2?: string; + city?: string; }; const meta = { @@ -47,6 +50,11 @@ const meta = { description: 'Chooses the label position of the layout.', options: [ 'default', 'top', 'side', 'none' ], }, + openAs: { + control: { type: 'select' }, + description: 'Chooses the type of panel to use.', + options: [ 'default', 'dropdown', 'modal' ], + }, }, }; export default meta; @@ -158,14 +166,31 @@ const fields = [ { value: 'travel', label: 'Travel' }, ], }, + { + id: 'address1', + label: 'Address 1', + type: 'text' as const, + }, + { + id: 'address2', + label: 'Address 2', + type: 'text' as const, + }, + { + id: 'city', + label: 'City', + type: 'text' as const, + }, ] as Field< SamplePost >[]; export const Default = ( { type, labelPosition, + openAs, }: { type: 'default' | 'regular' | 'panel' | 'card'; labelPosition: 'default' | 'top' | 'side' | 'none'; + openAs: 'default' | 'dropdown' | 'modal'; } ) => { const [ post, setPost ] = useState( { title: 'Hello, World!', @@ -188,6 +213,7 @@ export const Default = ( { layout: { type, labelPosition, + openAs, }, fields: [ 'title', @@ -206,7 +232,7 @@ export const Default = ( { 'tags', ], } ), - [ type, labelPosition ] + [ type, labelPosition, openAs ] ) as Form; return ( @@ -227,9 +253,11 @@ export const Default = ( { const CombinedFieldsComponent = ( { type, labelPosition, + openAs, }: { type: 'default' | 'regular' | 'panel' | 'card'; labelPosition: 'default' | 'top' | 'side' | 'none'; + openAs: 'default' | 'dropdown' | 'modal'; } ) => { const [ post, setPost ] = useState< SamplePost >( { title: 'Hello, World!', @@ -242,6 +270,9 @@ const CombinedFieldsComponent = ( { filesize: 1024, dimensions: '1920x1080', tags: [ 'photography' ], + address1: '123 Main St', + address2: 'Apt 4B', + city: 'New York', } ); const form = useMemo( @@ -249,6 +280,7 @@ const CombinedFieldsComponent = ( { layout: { type, labelPosition, + openAs, }, fields: [ 'title', @@ -262,9 +294,14 @@ const CombinedFieldsComponent = ( { 'filesize', 'dimensions', 'tags', + { + id: 'address1', + label: 'Combined Address', + children: [ 'address1', 'address2', 'city' ], + }, ], } ), - [ type, labelPosition ] + [ type, labelPosition, openAs ] ) as Form; return ( @@ -725,7 +762,11 @@ const LayoutMixedComponent = () => { fields: [ { id: 'title', - layout: { type: 'panel', labelPosition: 'top' }, + layout: { + type: 'panel', + labelPosition: 'top', + openAs: 'dropdown', + }, }, 'status', { id: 'order', layout: { type: 'card' } }, diff --git a/packages/dataviews/src/dataforms-layouts/panel/dropdown.tsx b/packages/dataviews/src/dataforms-layouts/panel/dropdown.tsx new file mode 100644 index 00000000000000..1367cdb93105de --- /dev/null +++ b/packages/dataviews/src/dataforms-layouts/panel/dropdown.tsx @@ -0,0 +1,160 @@ +/** + * WordPress dependencies + */ +import { + __experimentalVStack as VStack, + __experimentalHStack as HStack, + __experimentalHeading as Heading, + __experimentalSpacer as Spacer, + Dropdown, + Button, +} from '@wordpress/components'; +import { sprintf, __, _x } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; +import { closeSmall } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import type { Form, FormField, NormalizedField } from '../../types'; +import { DataFormLayout } from '../data-form-layout'; +import { isCombinedField } from '../is-combined-field'; +import { DEFAULT_LAYOUT } from '../../normalize-form-fields'; + +function DropdownHeader( { + title, + onClose, +}: { + title?: string; + onClose: () => void; +} ) { + return ( + + + { title && ( + + { title } + + ) } + + { onClose && ( + + ) } + renderContent={ ( { onClose } ) => ( + <> + + + { ( FieldLayout, nestedField ) => ( + + ) } + + + ) } + /> + ); +} + +export default PanelDropdown; diff --git a/packages/dataviews/src/dataforms-layouts/panel/index.tsx b/packages/dataviews/src/dataforms-layouts/panel/index.tsx index f9c99e26c6ee83..e6a27570bf8b1f 100644 --- a/packages/dataviews/src/dataforms-layouts/panel/index.tsx +++ b/packages/dataviews/src/dataforms-layouts/panel/index.tsx @@ -9,166 +9,22 @@ import clsx from 'clsx'; import { __experimentalVStack as VStack, __experimentalHStack as HStack, - __experimentalHeading as Heading, - __experimentalSpacer as Spacer, - Dropdown, - Button, } from '@wordpress/components'; -import { sprintf, __, _x } from '@wordpress/i18n'; -import { useState, useMemo, useContext } from '@wordpress/element'; -import { closeSmall } from '@wordpress/icons'; +import { useState, useContext } from '@wordpress/element'; /** * Internal dependencies */ import type { - Form, - FormField, FieldLayoutProps, NormalizedPanelLayout, - NormalizedField, SimpleFormField, } from '../../types'; import DataFormContext from '../../components/dataform-context'; -import { DataFormLayout } from '../data-form-layout'; import { isCombinedField } from '../is-combined-field'; -import { DEFAULT_LAYOUT, normalizeLayout } from '../../normalize-form-fields'; - -function DropdownHeader( { - title, - onClose, -}: { - title?: string; - onClose: () => void; -} ) { - return ( - - - { title && ( - - { title } - - ) } - - { onClose && ( - - ) } - renderContent={ ( { onClose } ) => ( - <> - - - { ( FieldLayout, nestedField ) => ( - - ) } - - - ) } - /> - ); -} +import { normalizeLayout } from '../../normalize-form-fields'; +import PanelDropdown from './dropdown'; +import PanelModal from './modal'; export default function FormPanelField< Item >( { data, @@ -192,6 +48,7 @@ export default function FormPanelField< Item >( { typeof simpleChildren[ 0 ] === 'string' ? simpleChildren[ 0 ] : simpleChildren[ 0 ].id; + return _field.id === firstChildFieldId; } @@ -222,6 +79,26 @@ export default function FormPanelField< Item >( { ? field.label : fieldDefinition?.label; + const renderedControl = + layout.openAs === 'modal' ? ( + + ) : ( + + ); + if ( labelPosition === 'top' ) { return ( @@ -232,14 +109,7 @@ export default function FormPanelField< Item >( { { fieldLabel }
- + { renderedControl }
); @@ -248,14 +118,7 @@ export default function FormPanelField< Item >( { if ( labelPosition === 'none' ) { return (
- + { renderedControl }
); } @@ -268,14 +131,7 @@ export default function FormPanelField< Item >( { >
{ fieldLabel }
- + { renderedControl }
); diff --git a/packages/dataviews/src/dataforms-layouts/panel/modal.tsx b/packages/dataviews/src/dataforms-layouts/panel/modal.tsx new file mode 100644 index 00000000000000..d0dfe14fb99806 --- /dev/null +++ b/packages/dataviews/src/dataforms-layouts/panel/modal.tsx @@ -0,0 +1,165 @@ +/** + * WordPress dependencies + */ +import { + __experimentalHStack as HStack, + __experimentalSpacer as Spacer, + Button, + Modal, +} from '@wordpress/components'; +import { __, sprintf, _x } from '@wordpress/i18n'; +import { useState, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { Form, FormField, NormalizedField } from '../../types'; +import { DataFormLayout } from '../data-form-layout'; +import { isCombinedField } from '../is-combined-field'; +import { DEFAULT_LAYOUT } from '../../normalize-form-fields'; + +function ModalContent< Item >( { + data, + form, + fieldLabel, + onChange, + onClose, +}: { + data: Item; + form: Form; + fieldLabel: string; + onChange: ( data: Partial< Item > ) => void; + onClose: () => void; +} ) { + const [ changes, setChanges ] = useState< Partial< Item > >( {} ); + + const onApply = () => { + onChange( changes ); + onClose(); + }; + + const handleOnChange = ( value: Partial< Item > ) => { + setChanges( ( prev ) => ( { ...prev, ...value } ) ); + }; + + // Merge original data with local changes for display + const displayData = { ...data, ...changes }; + + return ( + + + { ( FieldLayout, nestedField ) => ( + + ) } + + + + + + + + ); +} + +function PanelModal< Item >( { + fieldDefinition, + labelPosition, + data, + onChange, + field, +}: { + fieldDefinition: NormalizedField< Item >; + labelPosition: 'side' | 'top' | 'none'; + data: Item; + onChange: ( value: any ) => void; + field: FormField; +} ) { + const [ isOpen, setIsOpen ] = useState( false ); + + const fieldLabel = isCombinedField( field ) + ? field.label + : fieldDefinition?.label; + + const form: Form = useMemo( + (): Form => ( { + layout: DEFAULT_LAYOUT, + fields: isCombinedField( field ) + ? field.children + : // If not explicit children return the field id itself. + [ { id: field.id } ], + } ), + [ field ] + ); + + return ( + <> + + { isOpen && ( + setIsOpen( false ) } + /> + ) } + + ); +} + +export default PanelModal; diff --git a/packages/dataviews/src/dataforms-layouts/panel/style.scss b/packages/dataviews/src/dataforms-layouts/panel/style.scss index c43ef030af268a..63ea2f67e10c7b 100644 --- a/packages/dataviews/src/dataforms-layouts/panel/style.scss +++ b/packages/dataviews/src/dataforms-layouts/panel/style.scss @@ -52,6 +52,10 @@ margin-bottom: $grid-unit-20; } +.dataforms-layouts-panel__modal-footer { + margin-top: $grid-unit-20; +} + .components-popover.components-dropdown__content.dataforms-layouts-panel__field-dropdown { z-index: z-index(".components-popover.components-dropdown__content.dataforms-layouts-panel__field-dropdown"); } diff --git a/packages/dataviews/src/normalize-form-fields.ts b/packages/dataviews/src/normalize-form-fields.ts index 9af7332c275b43..0338ddafb4eba7 100644 --- a/packages/dataviews/src/normalize-form-fields.ts +++ b/packages/dataviews/src/normalize-form-fields.ts @@ -38,6 +38,7 @@ export function normalizeLayout( layout?: Layout ): NormalizedLayout { normalizedLayout = { type: 'panel', labelPosition: layout?.labelPosition ?? 'side', + openAs: layout?.openAs ?? 'dropdown', } satisfies NormalizedPanelLayout; } else if ( layout?.type === 'card' ) { if ( layout.withHeader === false ) { diff --git a/packages/dataviews/src/test/dataform.tsx b/packages/dataviews/src/test/dataform.tsx index af5c6f326b2109..027c4bb6b37864 100644 --- a/packages/dataviews/src/test/dataform.tsx +++ b/packages/dataviews/src/test/dataform.tsx @@ -218,6 +218,175 @@ describe( 'DataForm component', () => { } } ); + it( 'should use dropdown panel type by default', async () => { + render( + + ); + + const user = await userEvent.setup(); + const titleButton = fieldsSelector.title.view(); + await user.click( titleButton ); + + // Should show dropdown content (not modal) + expect( + screen.getByRole( 'textbox', { name: /title/i } ) + ).toBeInTheDocument(); + // Should not have modal dialog + expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument(); + // Should not have modal buttons (Cancel/Apply) + expect( + screen.queryByRole( 'button', { name: /cancel/i } ) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole( 'button', { name: /apply/i } ) + ).not.toBeInTheDocument(); + } ); + + it( 'should use dropdown panel type when explicitly set', async () => { + const formWithDropdownPanel = { + ...form, + layout: { + type: 'panel', + labelPosition: 'side', + openAs: 'dropdown', + } as const, + }; + + render( + + ); + + const user = await userEvent.setup(); + const titleButton = fieldsSelector.title.view(); + await user.click( titleButton ); + + // Should show dropdown content + expect( + screen.getByRole( 'textbox', { name: /title/i } ) + ).toBeInTheDocument(); + } ); + + it( 'should use modal panel type when set', async () => { + const formWithModalPanel = { + ...form, + layout: { + type: 'panel', + labelPosition: 'side', + openAs: 'modal', + } as const, + }; + + render( + + ); + + const user = await userEvent.setup(); + const titleButton = fieldsSelector.title.view(); + await user.click( titleButton ); + + // Should show modal content + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + expect( + screen.getByRole( 'textbox', { name: /title/i } ) + ).toBeInTheDocument(); + } ); + + it( 'should close modal when cancel button is clicked', async () => { + const formWithModalPanel = { + ...form, + layout: { + type: 'panel', + labelPosition: 'side', + openAs: 'modal', + } as const, + }; + + render( + + ); + + const user = await userEvent.setup(); + const titleButton = fieldsSelector.title.view(); + await user.click( titleButton ); + + // Modal should be open + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + + // Click cancel button + const cancelButton = screen.getByRole( 'button', { + name: /cancel/i, + } ); + await user.click( cancelButton ); + + // Modal should be closed + expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument(); + } ); + + it( 'should apply changes and close modal when apply button is clicked', async () => { + const onChange = jest.fn(); + const formWithModalPanel = { + ...form, + layout: { + type: 'panel', + labelPosition: 'side', + openAs: 'modal', + } as const, + }; + + render( + + ); + + const user = await userEvent.setup(); + const titleButton = fieldsSelector.title.view(); + await user.click( titleButton ); + + // Modal should be open + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + + // Type in the input + const titleInput = screen.getByRole( 'textbox', { + name: /title/i, + } ); + await user.clear( titleInput ); + await user.type( titleInput, 'New Title' ); + + // Click apply button + const applyButton = screen.getByRole( 'button', { + name: /apply/i, + } ); + await user.click( applyButton ); + + // Modal should be closed and onChange should be called + expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument(); + expect( onChange ).toHaveBeenCalledWith( { title: 'New Title' } ); + } ); + it( 'should call onChange with the correct value for each typed character', async () => { const onChange = jest.fn(); render( diff --git a/packages/dataviews/src/test/normalize-form-fields.ts b/packages/dataviews/src/test/normalize-form-fields.ts index d75f8f26137cbe..3511fbf2ebe427 100644 --- a/packages/dataviews/src/test/normalize-form-fields.ts +++ b/packages/dataviews/src/test/normalize-form-fields.ts @@ -100,7 +100,11 @@ describe( 'normalizeFormFields', () => { expect( result ).toEqual( [ { id: 'field1', - layout: { type: 'panel', labelPosition: 'side' }, + layout: { + type: 'panel', + labelPosition: 'side', + openAs: 'dropdown', + }, }, ] ); } ); @@ -114,7 +118,11 @@ describe( 'normalizeFormFields', () => { expect( result ).toEqual( [ { id: 'field1', - layout: { type: 'panel', labelPosition: 'top' }, + layout: { + type: 'panel', + labelPosition: 'top', + openAs: 'dropdown', + }, }, ] ); } ); @@ -195,7 +203,11 @@ describe( 'normalizeFormFields', () => { }, { id: 'field2', - layout: { type: 'panel', labelPosition: 'side' }, + layout: { + type: 'panel', + labelPosition: 'side', + openAs: 'dropdown', + }, }, ] ); } ); diff --git a/packages/dataviews/src/types.ts b/packages/dataviews/src/types.ts index d44e126e3ed178..86b6b86322224c 100644 --- a/packages/dataviews/src/types.ts +++ b/packages/dataviews/src/types.ts @@ -689,10 +689,12 @@ export type NormalizedRegularLayout = { export type PanelLayout = { type: 'panel'; labelPosition?: LabelPosition; + openAs?: 'dropdown' | 'modal'; }; export type NormalizedPanelLayout = { type: 'panel'; labelPosition: LabelPosition; + openAs: 'dropdown' | 'modal'; }; export type CardLayout =