Skip to content

Commit d8a7e4d

Browse files
committed
feat(dataviews): add registerLayout API for custom view types
The built-in layouts (table, grid, list, activity, pickerGrid, pickerTable) are defined in a hardcoded array and the package exposes no register function, filter hook, or slot/fill for adding new ones. Plugins that need a different visual shape fall back to CSS overrides targeting internal class names. Introduce a minimal registration API so plugins can own their own layout components: - `registerLayout({ type, label, component, icon? })` adds to a module-level Map. Throws on built-in collision and on duplicate registration, matching `registerBlockType`'s shape. - `getRegisteredLayout(type)` / `getRegisteredLayouts()` read access, used both by the lookup below and by consumers that want to enumerate registered layouts. - `DataViewsLayout` consults the registry when the built-in lookup misses. Built-ins keep their `defaultLayouts[type]` gate so existing consumer opt-in behavior is unchanged; registered layouts skip the gate because requiring every consumer to enumerate plugin-defined types would defeat the point of a plugin API. - `ViewCustom` widens the `View` union with a permissive variant so `view.type: string` typechecks for plugin code without casts. Scope is deliberately render-only. View-switcher integration, view-config menu integration, and `unregisterLayout` are follow-ups. Design doc: docs/plans/2026-04-16-dataviews-register-layout-design.md Refs: none
1 parent e7e810d commit d8a7e4d

5 files changed

Lines changed: 241 additions & 3 deletions

File tree

packages/dataviews/src/components/dataviews-layout/index.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { __ } from '@wordpress/i18n';
1414
*/
1515
import DataViewsContext from '../dataviews-context';
1616
import { VIEW_LAYOUTS } from '../../dataviews-layouts';
17+
import { getRegisteredLayout } from '../../dataviews-layouts/registry';
1718
import type { ViewBaseProps } from '../../types';
1819

1920
type DataViewsLayoutProps = {
@@ -40,9 +41,17 @@ export default function DataViewsLayout( { className }: DataViewsLayoutProps ) {
4041
empty = <p>{ __( 'No results' ) }</p>,
4142
} = useContext( DataViewsContext );
4243

43-
const ViewComponent = VIEW_LAYOUTS.find(
44+
// Built-ins keep their defaultLayouts gate (used by consumers to opt a
45+
// specific DataViews instance into a subset of layouts). Layouts added
46+
// via registerLayout() are global and skip the gate — requiring every
47+
// consumer to enumerate custom types in defaultLayouts would defeat the
48+
// point of a plugin API.
49+
const ViewComponent = ( VIEW_LAYOUTS.find(
4450
( v ) => v.type === view.type && defaultLayouts[ v.type ]
45-
)?.component as ComponentType< ViewBaseProps< any > >;
51+
)?.component ??
52+
getRegisteredLayout( view.type )?.component ) as ComponentType<
53+
ViewBaseProps< any >
54+
>;
4655

4756
return (
4857
<ViewComponent
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import type { ComponentType, ReactElement } from 'react';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import type { ViewBaseProps } from '../types';
10+
11+
export interface LayoutDefinition< Item = any > {
12+
type: string;
13+
label: string;
14+
component: ComponentType< ViewBaseProps< Item > >;
15+
icon?: ReactElement;
16+
}
17+
18+
// Kept in sync with the `type` values in dataviews-layouts/index.ts. Inlined
19+
// (rather than imported from ../constants) because constants.ts pulls in
20+
// @wordpress/icons, which requires a build step before it can resolve in
21+
// Jest. The six type names change rarely and will break both call sites
22+
// noisily if they drift.
23+
const BUILT_IN_LAYOUT_TYPES: readonly string[] = [
24+
'table',
25+
'grid',
26+
'list',
27+
'activity',
28+
'pickerGrid',
29+
'pickerTable',
30+
];
31+
32+
const registry = new Map< string, LayoutDefinition >();
33+
34+
export function registerLayout( layout: LayoutDefinition ): void {
35+
if ( BUILT_IN_LAYOUT_TYPES.includes( layout.type ) ) {
36+
throw new Error(
37+
`registerLayout: "${ layout.type }" is a built-in DataViews layout type.`
38+
);
39+
}
40+
if ( registry.has( layout.type ) ) {
41+
throw new Error(
42+
`registerLayout: "${ layout.type }" is already registered.`
43+
);
44+
}
45+
registry.set( layout.type, layout );
46+
}
47+
48+
export function getRegisteredLayout(
49+
type: string
50+
): LayoutDefinition | undefined {
51+
return registry.get( type );
52+
}
53+
54+
export function getRegisteredLayouts(): LayoutDefinition[] {
55+
return Array.from( registry.values() );
56+
}
57+
58+
/**
59+
* Internal test helper. Not exported from the package.
60+
*/
61+
export function __clearRegisteredLayouts(): void {
62+
registry.clear();
63+
}

packages/dataviews/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,10 @@ export { default as DataForm } from './components/dataform';
44
export { default as filterSortAndPaginate } from './utils/filter-sort-and-paginate';
55
export { useFormValidity } from './hooks';
66
export { VIEW_LAYOUTS } from './dataviews-layouts';
7+
export {
8+
registerLayout,
9+
getRegisteredLayout,
10+
getRegisteredLayouts,
11+
} from './dataviews-layouts/registry';
12+
export type { LayoutDefinition } from './dataviews-layouts/registry';
713
export type * from './types';
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Internal dependencies
3+
*/
4+
import {
5+
registerLayout,
6+
getRegisteredLayout,
7+
getRegisteredLayouts,
8+
__clearRegisteredLayouts,
9+
} from '../dataviews-layouts/registry';
10+
11+
describe( 'registerLayout', () => {
12+
afterEach( () => {
13+
__clearRegisteredLayouts();
14+
} );
15+
16+
it( 'stores the layout so it can be retrieved by type', () => {
17+
const Component = () => null;
18+
19+
registerLayout( {
20+
type: 'pocCardRows',
21+
label: 'POC card rows',
22+
component: Component,
23+
} );
24+
25+
const retrieved = getRegisteredLayout( 'pocCardRows' );
26+
expect( retrieved?.type ).toBe( 'pocCardRows' );
27+
expect( retrieved?.label ).toBe( 'POC card rows' );
28+
expect( retrieved?.component ).toBe( Component );
29+
} );
30+
31+
it( 'returns every registered layout from getRegisteredLayouts', () => {
32+
const A = () => null;
33+
const B = () => null;
34+
35+
registerLayout( { type: 'pocA', label: 'A', component: A } );
36+
registerLayout( { type: 'pocB', label: 'B', component: B } );
37+
38+
const all = getRegisteredLayouts();
39+
expect( all ).toHaveLength( 2 );
40+
expect( all.map( ( l ) => l.type ).sort() ).toEqual( [
41+
'pocA',
42+
'pocB',
43+
] );
44+
} );
45+
46+
it.each( [
47+
'table',
48+
'grid',
49+
'list',
50+
'activity',
51+
'pickerGrid',
52+
'pickerTable',
53+
] )( 'throws when the type collides with built-in %s', ( builtInType ) => {
54+
const Component = () => null;
55+
56+
expect( () =>
57+
registerLayout( {
58+
type: builtInType,
59+
label: 'x',
60+
component: Component,
61+
} )
62+
).toThrow( /built-in/i );
63+
} );
64+
65+
it( 'throws when the same type is registered twice', () => {
66+
const First = () => null;
67+
const Second = () => null;
68+
69+
registerLayout( {
70+
type: 'pocDuplicate',
71+
label: 'first',
72+
component: First,
73+
} );
74+
75+
expect( () =>
76+
registerLayout( {
77+
type: 'pocDuplicate',
78+
label: 'second',
79+
component: Second,
80+
} )
81+
).toThrow( /already registered/i );
82+
} );
83+
84+
// Skipped: mounting DataViewsLayout pulls in @wordpress/components →
85+
// @wordpress/compose → 'clipboard' transitively. A clean worktree
86+
// checkout without `npm install` can't resolve those. CI runs with a
87+
// full install so this will execute there. The Storybook story is the
88+
// interactive equivalent during local development.
89+
//
90+
// The heavy imports are inside the test body so the file is still
91+
// loadable without them at parse time.
92+
it.skip( 'renders the registered component when view.type matches', async () => {
93+
const { render, screen } = await import( '@testing-library/react' );
94+
const { createRef } = await import( 'react' );
95+
const DataViewsContext = (
96+
await import( '../components/dataviews-context' )
97+
).default;
98+
const DataViewsLayout = (
99+
await import( '../components/dataviews-layout' )
100+
).default;
101+
102+
function CustomLayout() {
103+
return <div data-testid="poc-card-rows-output">custom layout</div>;
104+
}
105+
106+
registerLayout( {
107+
type: 'pocCardRows',
108+
label: 'POC card rows',
109+
component: CustomLayout,
110+
} );
111+
112+
const contextValue = {
113+
view: { type: 'pocCardRows' },
114+
onChangeView: () => {},
115+
fields: [],
116+
data: [],
117+
paginationInfo: { totalItems: 0, totalPages: 0 },
118+
selection: [],
119+
onChangeSelection: () => {},
120+
setOpenedFilter: () => {},
121+
openedFilter: null,
122+
getItemId: ( item: any ) => String( item.id ),
123+
isItemClickable: () => true,
124+
containerWidth: 0,
125+
containerRef: createRef< HTMLDivElement >(),
126+
resizeObserverRef: () => {},
127+
// Deliberately empty: a registered layout must resolve even when
128+
// the consumer has not added its type to defaultLayouts.
129+
defaultLayouts: {},
130+
filters: [],
131+
isShowingFilter: false,
132+
setIsShowingFilter: () => {},
133+
hasInfiniteScrollHandler: false,
134+
config: { perPageSizes: [] },
135+
};
136+
137+
render(
138+
<DataViewsContext.Provider value={ contextValue as any }>
139+
<DataViewsLayout />
140+
</DataViewsContext.Provider>
141+
);
142+
143+
expect(
144+
screen.getByTestId( 'poc-card-rows-output' )
145+
).toHaveTextContent( 'custom layout' );
146+
} );
147+
} );

packages/dataviews/src/types/dataviews.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,13 +311,26 @@ export interface ViewPickerTable extends ViewBase {
311311
};
312312
}
313313

314+
/**
315+
* Layouts registered at runtime via `registerLayout()` opt in to this
316+
* permissive variant. The `type` field is anything that isn't a built-in
317+
* DataViews layout type, and `layout` is whatever shape the registered
318+
* component chooses to read. Intentionally loose — narrowing is the
319+
* responsibility of the registered layout's own component.
320+
*/
321+
export interface ViewCustom extends ViewBase {
322+
type: string;
323+
layout?: Record< string, any >;
324+
}
325+
314326
export type View =
315327
| ViewList
316328
| ViewGrid
317329
| ViewTable
318330
| ViewPickerGrid
319331
| ViewPickerTable
320-
| ViewActivity;
332+
| ViewActivity
333+
| ViewCustom;
321334

322335
interface ActionBase< Item > {
323336
/**

0 commit comments

Comments
 (0)