Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DataForm: migrate order action modal and introduce form validation #63895

Merged
merged 7 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions packages/dataviews/src/components/dataform/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import type { Dispatch, SetStateAction } from 'react';
/**
* WordPress dependencies
*/
import { TextControl } from '@wordpress/components';
import {
TextControl,
__experimentalNumberControl as NumberControl,
} from '@wordpress/components';
import { useCallback, useMemo } from '@wordpress/element';

/**
* Internal dependencies
*/
import type { Form, Field, NormalizedField } from '../../types';
import type { Form, Field, NormalizedField, FieldType } from '../../types';
import { normalizeFields } from '../../normalize-fields';

type DataFormProps< Item > = {
Expand Down Expand Up @@ -56,12 +59,41 @@ function DataFormTextControl< Item >( {
);
}

function DataFormNumberControl< Item >( {
data,
field,
onChange,
}: DataFormControlProps< Item > ) {
const { id, label, description } = field;
const value = field.getValue( { item: data } );

const onChangeControl = useCallback(
( newValue: string | undefined ) =>
onChange( ( prevItem: Item ) => ( {
...prevItem,
[ id ]: newValue,
} ) ),
[ id, onChange ]
);

return (
<NumberControl
label={ label }
help={ description }
value={ value }
onChange={ onChangeControl }
__next40pxDefaultSize
/>
);
}

const controls: {
[ key: string ]: < Item >(
[ key in FieldType ]: < Item >(
props: DataFormControlProps< Item >
) => JSX.Element;
} = {
text: DataFormTextControl,
integer: DataFormNumberControl,
};

function getControlForField< Item >( field: NormalizedField< Item > ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,21 @@ const fields = [
label: 'Title',
type: 'text' as const,
},
{
id: 'order',
label: 'Order',
type: 'integer' as const,
},
];

export const Default = () => {
const [ post, setPost ] = useState( {
title: 'Hello, World!',
order: 2,
} );

const form = {
visibleFields: [ 'title' ],
visibleFields: [ 'title', 'order' ],
};

return (
Expand Down
1 change: 1 addition & 0 deletions packages/dataviews/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { default as DataForm } from './components/dataform';
export { VIEW_LAYOUTS } from './layouts';
export { filterSortAndPaginate } from './filter-and-sort-data-view';
export type * from './types';
export { isItemValid } from './validation';
63 changes: 63 additions & 0 deletions packages/dataviews/src/test/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Internal dependencies
*/
import { isItemValid } from '../validation';
import type { Field } from '../types';

describe( 'validation', () => {
it( 'fields not visible in form are not validated', () => {
const item = { id: 1, valid_order: 2, invalid_order: 'd' };
const fields: Field< {} >[] = [
{
id: 'valid_order',
type: 'integer',
},
{
id: 'invalid_order',
type: 'integer',
},
];
const form = { visibleFields: [ 'valid_order' ] };
const result = isItemValid( item, fields, form );
expect( result ).toBe( true );
} );

it( 'integer field is valid if value is integer', () => {
const item = { id: 1, order: 2, title: 'hi' };
const fields: Field< {} >[] = [
{
type: 'integer',
id: 'order',
},
];
const form = { visibleFields: [ 'order' ] };
const result = isItemValid( item, fields, form );
expect( result ).toBe( true );
} );

it( 'integer field is invalid if value is not integer', () => {
const item = { id: 1, order: 'd' };
const fields: Field< {} >[] = [
{
id: 'order',
type: 'integer',
},
];
const form = { visibleFields: [ 'order' ] };
const result = isItemValid( item, fields, form );
expect( result ).toBe( false );
} );

it( 'integer field is invalid if value is empty', () => {
const item = { id: 1, order: '' };
const fields: Field< {} >[] = [
{
id: 'order',
type: 'integer',
},
];
const form = { visibleFields: [ 'order' ] };
const result = isItemValid( item, fields, form );
expect( result ).toBe( false );
} );
} );
7 changes: 6 additions & 1 deletion packages/dataviews/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export type Operator =

export type ItemRecord = Record< string, unknown >;

export type FieldType = 'text';
export type FieldType = 'text' | 'integer';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if these should be 'text' | 'integer' or 'string' or 'number' to stay as close as possible to JSON schemas.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went for integer because that's the type for menu_order according to the REST API https://developer.wordpress.org/rest-api/reference/pages/ (in terms of validation it's also more fine-grained and different than number).


/**
* A dataview field for a specific property of a data type.
Expand All @@ -65,6 +65,11 @@ export type Field< Item > = {
*/
label?: string;

/**
* A description of the field.
*/
description?: string;

/**
* Placeholder for the field.
*/
Expand Down
33 changes: 33 additions & 0 deletions packages/dataviews/src/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Internal dependencies
*/
import { normalizeFields } from './normalize-fields';
import type { Field, Form } from './types';

export function isItemValid< Item >(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method just ports the existing logic into the new place, there's no behavioral changes.

In follow-up PRs we should discuss all the validation details iteratively: how a form declares the required fields (or is it the field itself?), etc. Those conversations shouldn't block this PR from landing.

item: Item,
fields: Field< Item >[],
form: Form
): boolean {
const _fields = normalizeFields(
fields.filter( ( { id } ) => !! form.visibleFields?.includes( id ) )
);
return _fields.every( ( field ) => {
const value = field.getValue( { item } );

// TODO: this implicitely means the value is required.
if ( field.type === 'integer' && value === '' ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to formalize the field "types" better. I see us having a folder of field types and each field type would export a component to render the value, a component to render the control and a validation function. (Rather that having this logic within the global validation function)

return false;
}

if (
field.type === 'integer' &&
! Number.isInteger( Number( value ) )
) {
return false;
}

// Nothing to validate.
return true;
} );
}
49 changes: 27 additions & 22 deletions packages/editor/src/components/post-actions/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@ import { store as noticesStore } from '@wordpress/notices';
import { useMemo, useState } from '@wordpress/element';
import { privateApis as patternsPrivateApis } from '@wordpress/patterns';
import { parse } from '@wordpress/blocks';
import { DataForm } from '@wordpress/dataviews';
import { DataForm, isItemValid } from '@wordpress/dataviews';
import {
Button,
TextControl,
__experimentalText as Text,
__experimentalHStack as HStack,
__experimentalVStack as VStack,
__experimentalNumberControl as NumberControl,
} from '@wordpress/components';

/**
Expand All @@ -39,21 +38,31 @@ import { getItemTitle } from '../../dataviews/actions/utils';
const { PATTERN_TYPES, CreatePatternModalContents, useDuplicatePatternProps } =
unlock( patternsPrivateApis );

// TODO: this should be shared with other components (page-pages).
// TODO: this should be shared with other components (see post-fields in edit-site).
Copy link
Member Author

@oandregal oandregal Jul 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#63849 is an attempt to address this but it's still unclear how to do it.

const fields = [
{
type: 'text',
header: __( 'Title' ),
id: 'title',
label: __( 'Title' ),
placeholder: __( 'No title' ),
getValue: ( { item } ) => item.title,
},
{
type: 'integer',
id: 'menu_order',
label: __( 'Order' ),
description: __( 'Determines the order of pages.' ),
},
];

const form = {
const formDuplicateAction = {
visibleFields: [ 'title' ],
};

const formOrderAction = {
visibleFields: [ 'menu_order' ],
};

/**
* Check if a template is removable.
*
Expand Down Expand Up @@ -635,21 +644,20 @@ function useRenamePostAction( postType ) {
}

function ReorderModal( { items, closeModal, onActionPerformed } ) {
const [ item ] = items;
const [ item, setItem ] = useState( items[ 0 ] );
const orderInput = item.menu_order;
const { editEntityRecord, saveEditedEntityRecord } =
useDispatch( coreStore );
const { createSuccessNotice, createErrorNotice } =
useDispatch( noticesStore );
const [ orderInput, setOrderInput ] = useState( item.menu_order );

async function onOrder( event ) {
event.preventDefault();
if (
! Number.isInteger( Number( orderInput ) ) ||
orderInput?.trim?.() === ''
) {

if ( ! isItemValid( item, fields, formOrderAction ) ) {
return;
}

try {
await editEntityRecord( 'postType', item.type, item.id, {
menu_order: orderInput,
Expand All @@ -673,9 +681,7 @@ function ReorderModal( { items, closeModal, onActionPerformed } ) {
} );
}
}
const saveIsDisabled =
! Number.isInteger( Number( orderInput ) ) ||
orderInput?.trim?.() === '';
const isSaveDisabled = ! isItemValid( item, fields, formOrderAction );
return (
<form onSubmit={ onOrder }>
<VStack spacing="5">
Expand All @@ -684,12 +690,11 @@ function ReorderModal( { items, closeModal, onActionPerformed } ) {
'Determines the order of pages. Pages with the same order value are sorted alphabetically. Negative order values are supported.'
) }
</div>
<NumberControl
__next40pxDefaultSize
label={ __( 'Order' ) }
help={ __( 'Set the page order.' ) }
value={ orderInput }
onChange={ setOrderInput }
<DataForm
data={ item }
fields={ fields }
form={ formOrderAction }
onChange={ setItem }
/>
<HStack justify="right">
<Button
Expand All @@ -706,7 +711,7 @@ function ReorderModal( { items, closeModal, onActionPerformed } ) {
variant="primary"
type="submit"
accessibleWhenDisabled
disabled={ saveIsDisabled }
disabled={ isSaveDisabled }
__experimentalIsFocusable
>
{ __( 'Save' ) }
Expand Down Expand Up @@ -873,7 +878,7 @@ const useDuplicatePostAction = ( postType ) => {
<DataForm
data={ item }
fields={ fields }
form={ form }
form={ formDuplicateAction }
onChange={ setItem }
/>
<HStack spacing={ 2 } justify="end">
Expand Down
Loading