diff --git a/package-lock.json b/package-lock.json
index 80317039b1f09f..c93815597de63c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -54370,6 +54370,7 @@
"@wordpress/compose": "file:../compose",
"@wordpress/core-data": "file:../core-data",
"@wordpress/data": "file:../data",
+ "@wordpress/dataviews": "file:../dataviews",
"@wordpress/date": "file:../date",
"@wordpress/deprecated": "file:../deprecated",
"@wordpress/dom": "file:../dom",
@@ -69493,6 +69494,7 @@
"@wordpress/compose": "file:../compose",
"@wordpress/core-data": "file:../core-data",
"@wordpress/data": "file:../data",
+ "@wordpress/dataviews": "file:../dataviews",
"@wordpress/date": "file:../date",
"@wordpress/deprecated": "file:../deprecated",
"@wordpress/dom": "file:../dom",
diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index a26c854fad8a75..be0b434483599a 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### New features
+
+- Added a new `DataForm` component to render controls from a given configuration (fields, form), and data.
+
## 2.2.0 (2024-06-26)
## 2.1.0 (2024-06-15)
diff --git a/packages/dataviews/src/dataform.tsx b/packages/dataviews/src/dataform.tsx
new file mode 100644
index 00000000000000..e96e0e13dc0517
--- /dev/null
+++ b/packages/dataviews/src/dataform.tsx
@@ -0,0 +1,106 @@
+/**
+ * External dependencies
+ */
+import type { Dispatch, SetStateAction } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { TextControl } from '@wordpress/components';
+import { useCallback, useMemo } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import type { Form, Field, NormalizedField } from './types';
+import { normalizeFields } from './normalize-fields';
+
+type DataFormProps< Item > = {
+ data: Item;
+ fields: Field< Item >[];
+ form: Form;
+ onChange: Dispatch< SetStateAction< Item > >;
+};
+
+type DataFormControlProps< Item > = {
+ data: Item;
+ field: NormalizedField< Item >;
+ onChange: Dispatch< SetStateAction< Item > >;
+};
+
+function DataFormTextControl< Item >( {
+ data,
+ field,
+ onChange,
+}: DataFormControlProps< Item > ) {
+ const { id, header, placeholder } = field;
+ const value = field.getValue( { item: data } );
+
+ const onChangeControl = useCallback(
+ ( newValue: string ) =>
+ onChange( ( prevItem: Item ) => ( {
+ ...prevItem,
+ [ id ]: newValue,
+ } ) ),
+ [ id, onChange ]
+ );
+
+ return (
+
+ );
+}
+
+const controls: {
+ [ key: string ]: < Item >(
+ props: DataFormControlProps< Item >
+ ) => JSX.Element;
+} = {
+ text: DataFormTextControl,
+};
+
+function getControlForField< Item >( field: NormalizedField< Item > ) {
+ if ( ! field.type ) {
+ return null;
+ }
+
+ if ( ! Object.keys( controls ).includes( field.type ) ) {
+ return null;
+ }
+
+ return controls[ field.type ];
+}
+
+export default function DataForm< Item >( {
+ data,
+ fields,
+ form,
+ onChange,
+}: DataFormProps< Item > ) {
+ const visibleFields = useMemo(
+ () =>
+ normalizeFields(
+ fields.filter(
+ ( { id } ) => !! form.visibleFields?.includes( id )
+ )
+ ),
+ [ fields, form.visibleFields ]
+ );
+
+ return visibleFields.map( ( field ) => {
+ const DataFormControl = getControlForField( field );
+ return DataFormControl ? (
+
+ ) : null;
+ } );
+}
diff --git a/packages/dataviews/src/index.ts b/packages/dataviews/src/index.ts
index 31f44e5ed97502..f4750bbbd2cd5d 100644
--- a/packages/dataviews/src/index.ts
+++ b/packages/dataviews/src/index.ts
@@ -2,3 +2,4 @@ export { default as DataViews } from './dataviews';
export { VIEW_LAYOUTS } from './layouts';
export { filterSortAndPaginate } from './filter-and-sort-data-view';
export type * from './types';
+export { default as DataForm } from './dataform';
diff --git a/packages/dataviews/src/types.ts b/packages/dataviews/src/types.ts
index 964523c72f8a68..9e140f686d65f8 100644
--- a/packages/dataviews/src/types.ts
+++ b/packages/dataviews/src/types.ts
@@ -44,10 +44,17 @@ export type Operator =
export type ItemRecord = Record< string, unknown >;
+export type FieldType = 'text';
+
/**
* A dataview field for a specific property of a data type.
*/
export type Field< Item > = {
+ /**
+ * Type of the fields.
+ */
+ type?: FieldType;
+
/**
* The unique identifier of the field.
*/
@@ -58,6 +65,11 @@ export type Field< Item > = {
*/
header?: string;
+ /**
+ * Placeholder for the field.
+ */
+ placeholder?: string;
+
/**
* Callback used to render the field. Defaults to `field.getValue`.
*/
@@ -131,6 +143,13 @@ export type Fields< Item > = Field< Item >[];
export type Data< Item > = Item[];
+/**
+ * The form configuration.
+ */
+export type Form = {
+ visibleFields?: string[];
+};
+
/**
* The filters applied to the dataset.
*/
diff --git a/packages/editor/package.json b/packages/editor/package.json
index 932be1a1799fc1..bdda258d1453fd 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -42,6 +42,7 @@
"@wordpress/compose": "file:../compose",
"@wordpress/core-data": "file:../core-data",
"@wordpress/data": "file:../data",
+ "@wordpress/dataviews": "file:../dataviews",
"@wordpress/date": "file:../date",
"@wordpress/deprecated": "file:../deprecated",
"@wordpress/dom": "file:../dom",
diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js
index 535e474aafff1e..e38c64ddb3d7e6 100644
--- a/packages/editor/src/components/post-actions/actions.js
+++ b/packages/editor/src/components/post-actions/actions.js
@@ -11,7 +11,7 @@ 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 {
Button,
TextControl,
@@ -39,6 +39,21 @@ import { CreateTemplatePartModalContents } from '../create-template-part-modal';
const { PATTERN_TYPES, CreatePatternModalContents, useDuplicatePatternProps } =
unlock( patternsPrivateApis );
+// TODO: this should be shared with other components (page-pages).
+const fields = [
+ {
+ type: 'text',
+ header: __( 'Title' ),
+ id: 'title',
+ placeholder: __( 'No title' ),
+ getValue: ( { item } ) => item.title,
+ },
+];
+
+const form = {
+ visibleFields: [ 'title' ],
+};
+
/**
* Check if a template is removable.
*
@@ -649,16 +664,17 @@ const useDuplicatePostAction = ( postType ) => {
return status !== 'trash';
},
RenderModal: ( { items, closeModal, onActionPerformed } ) => {
- const [ item ] = items;
+ const [ item, setItem ] = useState( {
+ ...items[ 0 ],
+ title: sprintf(
+ /* translators: %s: Existing template title */
+ __( '%s (Copy)' ),
+ getItemTitle( items[ 0 ] )
+ ),
+ } );
+
const [ isCreatingPage, setIsCreatingPage ] =
useState( false );
- const [ title, setTitle ] = useState(
- sprintf(
- /* translators: %s: Existing item title */
- __( '%s (Copy)' ),
- getItemTitle( item )
- )
- );
const { saveEntityRecord } = useDispatch( coreStore );
const { createSuccessNotice, createErrorNotice } =
@@ -673,8 +689,8 @@ const useDuplicatePostAction = ( postType ) => {
const newItemOject = {
status: 'draft',
- title,
- slug: title || __( 'No title' ),
+ title: item.title,
+ slug: item.title || __( 'No title' ),
comment_status: item.comment_status,
content:
typeof item.content === 'string'
@@ -725,7 +741,7 @@ const useDuplicatePostAction = ( postType ) => {
// translators: %s: Title of the created template e.g: "Category".
__( '"%s" successfully created.' ),
decodeEntities(
- newItem.title?.rendered || title
+ newItem.title?.rendered || item.title
)
),
{
@@ -753,14 +769,15 @@ const useDuplicatePostAction = ( postType ) => {
closeModal();
}
}
+
return (