|
| 1 | +# DataViews: `registerLayout` API (POC) |
| 2 | + |
| 3 | +**Status:** Proof of concept |
| 4 | +**Package:** `@wordpress/dataviews` |
| 5 | +**Date:** 2026-04-16 |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +Today the set of DataViews layouts (`table`, `grid`, `list`, `activity`, `pickerGrid`, `pickerTable`) is a closed, hardcoded array at |
| 10 | +[`packages/dataviews/src/components/dataviews-layouts/index.ts`](../../packages/dataviews/src/components/dataviews-layouts/index.ts). The package exposes no |
| 11 | +`register*` function, no filter hook, and no slot/fill for layouts. Plugins wanting to render data in a shape the built-ins don't |
| 12 | +support fall back to CSS overrides and prop-shape hacks against internal class names. |
| 13 | + |
| 14 | +A real example of that fallback pattern is the ciab-admin "Replace custom offline payments UI with DataForm" change, which needed to: |
| 15 | + |
| 16 | +- Hide `thead` with a screen-reader-only CSS trick so column headers wouldn't render. |
| 17 | +- Set `view.layout.styles.{title,actions}.width` to fake a "primary content fills / actions shrink-wrap" row shape. |
| 18 | +- Neuter the view-switcher, search, sort, column-reorder, and pagination via stub props and `search={ false }`. |
| 19 | +- Override `.dataviews-view-table td`, `.dataviews-view-table__row:first-child`, and `.components-card__body` in route-level SCSS. |
| 20 | + |
| 21 | +Every one of those except the last is something the layout component itself should decide. A registration API lets plugins ship |
| 22 | +their own layout component and opt out of the built-ins' shape entirely, instead of papering over it with CSS. |
| 23 | + |
| 24 | +## Goal |
| 25 | + |
| 26 | +Enable a plugin to register a DataViews layout type such that `view.type === '<customType>'` routes to the plugin's component. |
| 27 | + |
| 28 | +**Render-only POC.** The custom layout renders when selected. Nothing else integrates: no view-switcher entry, no view-config menu, |
| 29 | +no type validation, no persistence affordances. Those are deliberate follow-ups. |
| 30 | + |
| 31 | +## Non-goals |
| 32 | + |
| 33 | +- `unregisterLayout` / cleanup on plugin deactivation — not needed for a proof of concept. |
| 34 | +- View-switcher icon integration. |
| 35 | +- View-config menu integration (density picker, column visibility, etc.). |
| 36 | +- A `@wordpress/data` store for the registry. A plain module-level `Map` is sufficient; no subscriptions are required. |
| 37 | +- Dynamic TypeScript narrowing per custom layout type. We widen `View` with one permissive `ViewCustom` variant. |
| 38 | + |
| 39 | +## Public API |
| 40 | + |
| 41 | +A new module at `packages/dataviews/src/components/dataviews-layouts/registry.ts`: |
| 42 | + |
| 43 | +```ts |
| 44 | +import type { ComponentType, ReactElement } from 'react'; |
| 45 | +import type { ViewBaseProps } from '../types'; |
| 46 | + |
| 47 | +export interface LayoutDefinition< Item = any > { |
| 48 | + type: string; |
| 49 | + label: string; |
| 50 | + component: ComponentType< ViewBaseProps< Item > >; |
| 51 | + icon?: ReactElement; |
| 52 | +} |
| 53 | + |
| 54 | +export function registerLayout( layout: LayoutDefinition ): void; |
| 55 | +export function getRegisteredLayout( type: string ): LayoutDefinition | undefined; |
| 56 | +export function getRegisteredLayouts(): LayoutDefinition[]; |
| 57 | +``` |
| 58 | + |
| 59 | +Re-exported from `packages/dataviews/src/index.ts` alongside the existing `VIEW_LAYOUTS` export. |
| 60 | + |
| 61 | +### Semantics |
| 62 | + |
| 63 | +- `registerLayout` throws if `type` collides with a built-in (`table`, `grid`, `list`, `activity`, `pickerGrid`, `pickerTable`) or |
| 64 | + with a previously-registered type. This matches `registerBlockType`'s duplicate-registration behavior. |
| 65 | +- Registry state is module-level: a single `Map<string, LayoutDefinition>` shared across the process. Same lifetime model as |
| 66 | + `@wordpress/blocks`' block type registry. |
| 67 | +- An internal `__clearRegisteredLayouts()` is exported for test teardown only, not re-exported from the package index. |
| 68 | + |
| 69 | +## Implementation |
| 70 | + |
| 71 | +Four files change: |
| 72 | + |
| 73 | +### 1. `packages/dataviews/src/components/dataviews-layouts/registry.ts` — new, ~40 lines |
| 74 | + |
| 75 | +Implements `registerLayout`, `getRegisteredLayout`, `getRegisteredLayouts`, `__clearRegisteredLayouts`. Module-level `Map`. |
| 76 | + |
| 77 | +### 2. `packages/dataviews/src/components/dataviews-layout/index.tsx` — lookup change |
| 78 | + |
| 79 | +Current lookup (line 43): |
| 80 | + |
| 81 | +```ts |
| 82 | +const ViewComponent = VIEW_LAYOUTS.find( |
| 83 | + ( v ) => v.type === view.type && defaultLayouts[ v.type ] |
| 84 | +)?.component; |
| 85 | +``` |
| 86 | + |
| 87 | +Becomes: |
| 88 | + |
| 89 | +```ts |
| 90 | +const ViewComponent = |
| 91 | + VIEW_LAYOUTS.find( |
| 92 | + ( v ) => v.type === view.type && defaultLayouts[ v.type ] |
| 93 | + )?.component |
| 94 | + ?? getRegisteredLayout( view.type )?.component; |
| 95 | +``` |
| 96 | + |
| 97 | +Built-ins keep their `defaultLayouts[ v.type ]` gate — changing that would alter existing behavior for consumers that rely on it. |
| 98 | +Registered layouts skip the gate: a plugin registers globally, so requiring every consumer to add the custom type to |
| 99 | +`defaultLayouts` would defeat the point. |
| 100 | + |
| 101 | +### 3. `packages/dataviews/src/types/dataviews.ts` — widen `View` |
| 102 | + |
| 103 | +Add one permissive variant: |
| 104 | + |
| 105 | +```ts |
| 106 | +export interface ViewCustom extends ViewBase { |
| 107 | + type: string; // anything not matching a built-in |
| 108 | + layout?: Record< string, any >; |
| 109 | +} |
| 110 | + |
| 111 | +export type View = |
| 112 | + | ViewList |
| 113 | + | ViewGrid |
| 114 | + | ViewTable |
| 115 | + | ViewPickerGrid |
| 116 | + | ViewPickerTable |
| 117 | + | ViewActivity |
| 118 | + | ViewCustom; |
| 119 | +``` |
| 120 | + |
| 121 | +Plugin code can typecheck against `ViewCustom` without casts. Existing typed call sites over built-ins are unaffected. |
| 122 | + |
| 123 | +### 4. `packages/dataviews/src/index.ts` — exports |
| 124 | + |
| 125 | +Add `registerLayout`, `getRegisteredLayout`, `getRegisteredLayouts`, and the `LayoutDefinition` type. |
| 126 | + |
| 127 | +## Demo |
| 128 | + |
| 129 | +### Storybook story |
| 130 | + |
| 131 | +New file `packages/dataviews/src/dataviews/stories/register-layout-poc.story.tsx`. Registers a `pocCardRows` layout — a |
| 132 | +flex row per item, primary field on the left, secondary fields on the right, no column headers, no borders, styling owned by the |
| 133 | +layout's component. The fixture resembles the offline-payments-style use case that motivated the API so readers can see the |
| 134 | +1:1 replacement. |
| 135 | + |
| 136 | +Accessible labels (`aria-labelledby` on each row) are baked into the layout component so the story doubles as a correct example — |
| 137 | +layouts that omit headers still need to announce column context to screen readers. |
| 138 | + |
| 139 | +The story uses the existing Free Composition entry point (`<DataViews.Layout />`) to render layout-only output. |
| 140 | + |
| 141 | +### Unit tests |
| 142 | + |
| 143 | +New file `packages/dataviews/src/dataviews-layouts/test/registry.ts` with four cases: |
| 144 | + |
| 145 | +1. `registerLayout` then `getRegisteredLayout` returns the definition. |
| 146 | +2. `getRegisteredLayouts` returns all registered definitions. |
| 147 | +3. `registerLayout` throws on built-in collision and on duplicate registration. |
| 148 | +4. `<DataViewsLayout>` with `view.type === 'pocCardRows'` renders the registered component (verifies the lookup wire-up). |
| 149 | + |
| 150 | +Suite calls `__clearRegisteredLayouts()` in `afterEach` to isolate tests. |
| 151 | + |
| 152 | +## Validation |
| 153 | + |
| 154 | +Against the ciab-admin offline-payments migration, a `pocCardRows`-style registered layout removes: |
| 155 | + |
| 156 | +- The `thead { clip: rect(0 0 0 0) … }` CSS hack. |
| 157 | +- The `view.layout.styles.{title,actions}.width` column-width hack. |
| 158 | +- `enableMoving: false` (column-reorder UI isn't rendered at all). |
| 159 | +- All `.dataviews-view-table *` targeting in route-level SCSS. |
| 160 | + |
| 161 | +What still stays because it's outside this POC's scope: stub `onChangeView`, `paginationInfo`, `isLoading`; DataForm-as-card-chrome. |
| 162 | + |
| 163 | +## Future work (not in this POC) |
| 164 | + |
| 165 | +- View-switcher and view-config-menu integration for registered layouts. |
| 166 | +- `unregisterLayout`, with subscriber notification so live-mounted DataViews instances react. |
| 167 | +- Filter hook on `VIEW_LAYOUTS` for layered composition (multiple plugins adding layouts). |
| 168 | +- TypeScript variance so each registered layout can narrow `view.layout` to its own shape. |
| 169 | +- Documentation in the DataViews handbook. |
0 commit comments