Skip to content
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
28 changes: 24 additions & 4 deletions apps/site/app/components/InteractiveDemo.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,15 +5,32 @@ import { SchemaRenderer, SchemaRendererContext, toRenderableSchema } from '@obje
import { SidebarProvider } from '@object-ui/components';
// Registers `page-header` & friends — see the module header (objectui#3787).
import './registerLayoutBlocks';
import { galleryDataSource } from './galleryDataSource';
import type { SchemaNode } from '@object-ui/core';
import { Tabs, Tab } from 'fumadocs-ui/components/tabs';
import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';

// Re-export SchemaNode type for use in MDX files
export type { SchemaNode } from '@object-ui/core';

/** Minimal provider so plugins can find SchemaRendererContext */
const defaultCtx = { dataSource: {} };
/**
* The provider the demos render under. `dataSource` is the docs gallery's
* stand-in fixture — the SAME module `SchemaThumbnail` supplies to the catalog
* gallery, rather than a second one — because a demo whose schema is
* object-bound has no other way to reach data: `dataSource` is not a schema
* key, it is what the registered renderer pulls off this context
* (`packages/plugin-view/src/index.tsx`).
*
* It was `{}` until objectui#5113, which is why the three `plugin-view`
* examples on `content/docs/plugins/plugin-view.mdx` could only be hand-drawn
* pictures of a view rather than the view itself.
*
* Importing the fixture pulls in NO plugin package: this host keeps its plugin
* registration lazy through `PluginLoader`, per page — the gallery's eager
* block-registration module stays out of here, and `galleryDataSource` imports
* nothing at all (objectui#4600/#4616).
*/
const defaultCtx = { dataSource: galleryDataSource };
function DemoProvider({ children }: { children: React.ReactNode }) {
const value = useMemo(() => defaultCtx, []);
return (
Expand DownExpand Up@@ -71,7 +88,10 @@ export function InteractiveDemo({
<DemoProvider>
<SidebarProvider className="min-h-0 w-full" defaultOpen={false}>
<div className="w-full">
<SchemaRenderer schema={toRenderableSchema(example.schema)} />
<SchemaRenderer
schema={toRenderableSchema(example.schema)}
dataSource={galleryDataSource}
/>
</div>
</SidebarProvider>
</DemoProvider>
Expand DownExpand Up@@ -116,7 +136,7 @@ export function InteractiveDemo({
<DemoProvider>
<SidebarProvider className="min-h-0 w-full" defaultOpen={false}>
<div className="w-full">
<SchemaRenderer schema={toRenderableSchema(schema)} />
<SchemaRenderer schema={toRenderableSchema(schema)} dataSource={galleryDataSource} />
</div>
</SidebarProvider>
</DemoProvider>
Expand Down
193 changes: 180 additions & 13 deletions apps/site/app/components/galleryDataSource.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,22 +4,42 @@
*/

/**
* The docs gallery's stand-in data source (objectui#4600).
* The docs gallery's stand-in data source (objectui#4600, extended by #5113).
*
* The catalog is a **presentation corpus**: it ships JSON, not a backend, and
* the gallery renders it with no application behind it. Most entries need
* nothing — their data is inline. Dataset-bound dashboard widgets are the
* exception: `DatasetWidget` routes through `dataSource.queryDataset`, and with
* no such function it renders "This data source does not support dataset
* queries." — which is what `plugin-dashboard/filtered-dashboard-dataset-
* widgets` showed in the gallery until this file existed.
*
* So this is the smallest thing that lets a dataset-bound widget draw: canned
* rows shaped from the query's own `dimensions` / `measures`, so any widget
* gets a two-bucket series (or a single value when it selects no dimension)
* regardless of which dataset it names. It is a DEMO fixture — it does not
* filter, aggregate or honour `runtimeFilter`, and it must never be mistaken
* for a data-source implementation. Real ones live in `@object-ui/data-*`.
* nothing — their data is inline. Two kinds of entry are the exception, and
* both route through the host's `dataSource`:
*
* - dataset-bound dashboard widgets — `DatasetWidget` calls
* `dataSource.queryDataset`, and with no such function it renders "This data
* source does not support dataset queries." (objectui#4600);
* - object-bound entries — `object-view` / `object-grid` / `object-form` call
* `getObjectSchema` and `find`, and `dataSource` is not a schema key: it is
* a prop the registered renderer pulls off `SchemaRendererContext`
* (`packages/plugin-view/src/index.tsx`). With nothing behind it, a
* `plugin-view` example can only be a hand-drawn picture of one — which is
* what the three `plugin-view` catalog entries were until objectui#5113.
*
* So this is the smallest thing that lets both draw. It is a DEMO fixture, not
* a data-source implementation — real ones live in `@object-ui/data-*`.
*
* ## What it honours, and what it does not
*
* Stated because a fixture that silently ignores a query parameter turns any
* example authoring that parameter into a lie on the page — the exact defect
* objectui#5113 exists to remove.
*
* - `queryDataset` — canned rows shaped from the query's own `dimensions` /
* `measures`. Does NOT filter, aggregate or honour `runtimeFilter`.
* - `find` — honours `$search` (case-insensitive substring over the object's
* string fields), `$orderby` (all of the shapes `QueryParams` declares) and
* the `$skip` / `$top` window. Does NOT honour `$filter` or `$expand`,
* which is why no catalog entry authors a `filter` on a view it renders
* through this fixture.
* - writes (`create` / `update` / `delete`) — applied to this module's own
* in-memory rows, so a record a reader creates in a demo drawer shows up in
* that page's list. They live as long as the tab does and reach nothing.
*
* It is gallery-only on purpose: `apps/site` is `private`, so nothing here is
* a published package surface.
Expand All@@ -34,6 +54,99 @@ interface GalleryDatasetQuery {
/** One canned row: dimension values plus measure values. */
type GalleryRow = Record<string, unknown>;

/** A record in the demo object below. `id` is what row clicks resolve. */
type GalleryRecord = Record<string, unknown> & { id: string };

/**
* The one object the gallery serves, in the shape `getObjectSchema` returns
* everywhere else in the repo — `{ label, fields: { <name>: { label, type } } }`
* — because that is what `ObjectGrid` / `ObjectForm` read to pick a cell
* renderer and a field widget.
*
* `users` and these field names are the ones `content/docs/plugins/
* plugin-view.mdx` teaches in its own prose examples, so a reader comparing the
* live example against the snippet above it sees one object, not two.
*/
const USERS_SCHEMA = {
name: 'users',
label: 'Users',
fields: {
name: { label: 'Name', type: 'text' },
email: { label: 'Email', type: 'email' },
role: {
label: 'Role',
type: 'select',
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Member', value: 'member' },
{ label: 'Viewer', value: 'viewer' },
],
},
department: { label: 'Department', type: 'text' },
status: {
label: 'Status',
type: 'select',
options: [
{ label: 'Active', value: 'active' },
{ label: 'Invited', value: 'invited' },
{ label: 'Suspended', value: 'suspended' },
],
},
created_at: { label: 'Created', type: 'date' },
},
} as const;

const USERS_ROWS: GalleryRecord[] = [
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'admin', department: 'Engineering', status: 'active', created_at: '2024-01-14' },
{ id: '2', name: 'Bob Chen', email: 'bob@example.com', role: 'member', department: 'Design', status: 'active', created_at: '2024-02-03' },
{ id: '3', name: 'Carla Gómez', email: 'carla@example.com', role: 'member', department: 'Sales', status: 'invited', created_at: '2024-03-21' },
{ id: '4', name: 'Dan Whitfield', email: 'dan@example.com', role: 'viewer', department: 'Support', status: 'suspended', created_at: '2024-04-09' },
{ id: '5', name: 'Emily Novak', email: 'emily@example.com', role: 'member', department: 'Engineering', status: 'active', created_at: '2024-05-30' },
];

/** Rows per object name. Unknown names resolve to an empty collection. */
const OBJECTS: Record<string, { schema: typeof USERS_SCHEMA; rows: GalleryRecord[] }> = {
users: { schema: USERS_SCHEMA, rows: USERS_ROWS },
};

/** `$orderby` in every shape `QueryParams` declares, as [field, direction]. */
function orderPairs(orderby: unknown): Array<[string, 'asc' | 'desc']> {
if (!orderby) return [];
if (typeof orderby === 'string') {
return orderby
.split(',')
.map((clause) => clause.trim())
.filter(Boolean)
.map((clause) => {
const [field, direction] = clause.split(/\s+/);
return [field, direction?.toLowerCase() === 'desc' ? 'desc' : 'asc'] as [string, 'asc' | 'desc'];
});
}
if (Array.isArray(orderby)) {
return orderby.flatMap((entry) =>
typeof entry === 'string'
? orderPairs(entry)
: entry && typeof entry === 'object' && 'field' in entry
? [[(entry as { field: string }).field, (entry as { order?: string }).order === 'desc' ? 'desc' : 'asc'] as [string, 'asc' | 'desc']]
: [],
);
}
if (typeof orderby === 'object') {
return Object.entries(orderby as Record<string, string>).map(
([field, direction]) => [field, direction === 'desc' ? 'desc' : 'asc'] as [string, 'asc' | 'desc'],
);
}
return [];
}

function compare(a: unknown, b: unknown): number {
if (a == null && b == null) return 0;
if (a == null) return -1;
if (b == null) return 1;
if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a).localeCompare(String(b));
}

export const galleryDataSource = {
async queryDataset(dataset: string, query: GalleryDatasetQuery) {
const dimensions = query?.dimensions ?? [];
Expand All@@ -49,4 +162,58 @@ export const galleryDataSource = {
// drill INTO, so it is deliberately omitted along with `dimensionFields`.
return { rows, fields: [] };
},

async getObjectSchema(objectName: string) {
return OBJECTS[objectName]?.schema ?? { name: objectName, label: objectName, fields: {} };
},

async find(objectName: string, params?: Record<string, unknown>) {
const rows = OBJECTS[objectName]?.rows ?? [];
const fields = OBJECTS[objectName]?.schema.fields ?? {};
let result = [...rows];

const search = typeof params?.$search === 'string' ? params.$search.trim().toLowerCase() : '';
if (search) {
const searchable = Array.isArray(params?.$searchFields)
? (params.$searchFields as string[])
: Object.keys(fields);
result = result.filter((row) =>
searchable.some((field) => String(row[field] ?? '').toLowerCase().includes(search)),
);
}

for (const [field, direction] of orderPairs(params?.$orderby).reverse()) {
result.sort((a, b) => (direction === 'desc' ? -1 : 1) * compare(a[field], b[field]));
}

const total = result.length;
const skip = typeof params?.$skip === 'number' ? params.$skip : 0;
const top = typeof params?.$top === 'number' ? params.$top : undefined;
return { data: result.slice(skip, top === undefined ? undefined : skip + top), total };
},

async findOne(objectName: string, id: string | number) {
return (OBJECTS[objectName]?.rows ?? []).find((row) => String(row.id) === String(id)) ?? null;
},

async create(objectName: string, data: Record<string, unknown>) {
const record: GalleryRecord = { ...data, id: `demo-${Date.now()}` };
OBJECTS[objectName]?.rows.unshift(record);
return record;
},

async update(objectName: string, id: string | number, data: Record<string, unknown>) {
const rows = OBJECTS[objectName]?.rows ?? [];
const index = rows.findIndex((row) => String(row.id) === String(id));
if (index === -1) return { ...data, id } as GalleryRecord;
rows[index] = { ...rows[index], ...data };
return rows[index];
},

async delete(objectName: string, id: string | number) {
const rows = OBJECTS[objectName]?.rows ?? [];
const index = rows.findIndex((row) => String(row.id) === String(id));
if (index !== -1) rows.splice(index, 1);
return true;
},
};
22 changes: 16 additions & 6 deletions content/docs/plugins/plugin-view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,17 +17,27 @@ npm install @object-ui/plugin-view

## Interactive Examples

### User Directory View
Each preview below **is** an `object-view` node drawn by this plugin — the JSON
in the Code tab is the whole example, and the rows on screen came out of a
`find()` call, not out of that JSON. The records are served by the docs site's
demo data source, because `dataSource` is not a schema key: it is the prop the
registered renderer pulls off `SchemaRendererProvider` context (see
[Schema API](#schema-api) below), so in your own app these same nodes read
whatever object your data source serves. That fixture answers search and sort
but not filters, which is the only reason `showFilters: false` is authored
here.

<SchemaExample id="plugin-view/grid-view-mode" />
### The list surface

### Record Detail View
<SchemaExample id="plugin-view/object-view-list" />

<SchemaExample id="plugin-view/detail-view-mode" />
### Saved views

### Form Create View
<SchemaExample id="plugin-view/object-view-named-views" />

<SchemaExample id="plugin-view/form-view-mode" />
### The record surface

<SchemaExample id="plugin-view/object-view-record-surface" />

## Features

Expand Down
60 changes: 60 additions & 0 deletions examples/schema-catalog/src/catalog-meta.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,36 @@
"verification"
]
},
"components-layout-card/profile-detail-card": {
"title": "Profile Detail Card",
"description": "A card that draws one record by hand — avatar header, label/value rows, edit and delete footer. For a record surface bound to an object, see the `plugin-view` examples.",
"tags": [
"card",
"detail",
"profile",
"avatar"
]
},
"components-layout-card/user-list-card": {
"title": "User List Card",
"description": "A card that draws a directory table by hand — header row, striped rows, status badges. For a table bound to an object, see the `plugin-view` / `plugin-grid` examples.",
"tags": [
"card",
"list",
"badge",
"layout"
]
},
"forms/create-user-form": {
"title": "Create User Form",
"description": "Two-column name fields, email, role select and a submit/cancel footer.",
"tags": [
"form",
"create",
"select",
"grid"
]
},
"plugin-dashboard/filtered-dashboard": {
"description": "Dashboard-level date + region filters driving multiple charts over different objects"
},
Expand All@@ -58,5 +88,35 @@
"plugin-dashboard/filtered-dashboard-target-widgets": {
"title": "Filtered Dashboard — Target Widgets Allow-list",
"description": "Legacy targetWidgets allow-list: only listed widgets get the default binding; an explicit filterBindings entry still wins"
},
"plugin-view/object-view-list": {
"title": "Object View — List Surface",
"description": "object-view rendering the users object: the columns declared in `table`, with search and sort served by the host's data source.",
"tags": [
"object-view",
"grid",
"list",
"objectql"
]
},
"plugin-view/object-view-named-views": {
"title": "Object View — Saved Views",
"description": "Two `listViews` entries over one object, each with its own label and column set; `defaultListView` picks which opens first.",
"tags": [
"object-view",
"listViews",
"tabs",
"objectql"
]
},
"plugin-view/object-view-record-surface": {
"title": "Object View — Record Surface",
"description": "Create, read and edit as one surface: `layout` decides where it opens, `form` decides what it contains, and a row click chooses the record.",
"tags": [
"object-view",
"form",
"drawer",
"crud"
]
}
}
Loading
Loading