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
16 changes: 16 additions & 0 deletions .changeset/31856-row-table-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/types': minor
'@object-ui/app-shell': patch
'@object-ui/cli': patch
---

feat(app-shell): a `row-table` page block lists the rows a page `api` binding answers, as a table

Clause-②: yes (widening)
Metadata-protocol: added

- **`@object-ui/app-shell`: the `row-table` block**, the sixth product-page block beside `hero`, `stat`, `usage-bar`, `checklist` and `tile-grid`, registered under the `app-shell` namespace with the bare `row-table` as its authorable key. `rows` is a list, or one whole `${…}` hole naming a list in the bound `data` (`rows: '${data.actors}'`); every row is drawn, with no cap. `columns` declares, in order, `{ path, label, format?, enum? }`: `path` is a dotted path into one row, `label` a string or an inline locale map, and `format` is `text` (the default, drawn as written), `datetime` (an instant in the viewer's display locale and time zone, through the `datetime` field cell's `formatDateTime`) or `enum` (the label `enum: { value: label }` names; a value the map does not name is drawn as written). A field the row lacks, `null` or empty draws `—`. An empty list draws `emptyLabel`, else the bundle's "No rows to display". A `rows` hole that resolves to anything but a list, or a column the block cannot draw, draws the binding's error panel, never the empty state.
- **`@object-ui/types`: its arm**, `RowTableBlockSchema` with `RowTableColumnSchema` (and the TS twins `RowTableBlockSchema`, `RowTableBlockProps`, `RowTableColumn`), a member of `ProductBlockComponentSchema`, so `objectui validate` claims the type. The arm is strict: an unknown `format`, an `enum` column with no label map, a label map on a column that is not `enum`, a `path` that is not a dotted path, an undeclared key and an empty `columns` are refused. The `dataSource` is the spec's `ElementApiDataSourceSchema`, by reference.
- **`@object-ui/cli`:** `known-schema-types.ts` regenerated with `row-table` and `app-shell:row-table`.

No `@objectstack/spec` change: the type parses through `PageComponentSchema.type`'s open string arm, and `component-type-unknown` claims only colon namespaces. The SDUI manifest does not move (`@object-ui/app-shell` is not one of its registry modules).
9 changes: 6 additions & 3 deletions content/docs/frontend/data-source.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,9 @@ the same URL make one request, even when the entry's read settled first:
Outside such a scope nothing is kept beyond the request in flight: a page that
mounts again reads again.

The console's product-page blocks — `hero`, `stat`, `usage-bar`, `checklist` and
`tile-grid` — accept this binding in their arms; see the `@object-ui/app-shell`
README.
The console's product-page blocks — `hero`, `stat`, `usage-bar`, `checklist`,
`tile-grid` and `row-table` — accept this binding in their arms; see the
`@object-ui/app-shell` README. `row-table` draws every row of a list in the
answer as a table: `rows: '${data.actors}'` names the list, and each column
declares a `path` into one row, a `label`, and a `format` of `text`, `datetime`
or `enum`.
2 changes: 1 addition & 1 deletion content/docs/ui/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ The API binding's rules:
- **Same-origin only.** `read.url` must open with a single `/`. An absolute URL of any scheme (`https:`, `mailto:`, `javascript:`, …), a protocol-relative `//host`, a backslash the browser reads as a slash, a space or control character, and a relative path such as `./home` are all refused when the page is parsed (`definePage()`, `os validate`, `os build`), with the reason.
- **No credentials in metadata.** The console reads the endpoint with its own signed-in fetch, so the read block has no `headers`, and a credential key such as `headers`, `credentials` or `token` is refused.
- **Reads only.** `read.method` is optional and accepts `GET` alone; there is no `write` block.
- **Rendered by the console.** The console reads the binding on every page element and binds the selected value as `data`: in the element's expression holes (`${data.used}`), in its `visibleWhen`, and in every element nested inside it. The console's generic `hero`, `stat`, `usage-bar`, `checklist` and `tile-grid` blocks are built to take it as their `dataSource`. While the read is in flight the element shows a loading panel, and a refused binding or a failed read shows an error panel instead.
- **Rendered by the console.** The console reads the binding on every page element and binds the selected value as `data`: in the element's expression holes (`${data.used}`), in its `visibleWhen`, and in every element nested inside it. The console's generic `hero`, `stat`, `usage-bar`, `checklist`, `tile-grid` and `row-table` blocks are built to take it as their `dataSource`; `row-table` lists the rows of an array in the answer as a table, with declared columns (`path`, `label`, and a `format` of `text`, `datetime` or `enum`). While the read is in flight the element shows a loading panel, and a refused binding or a failed read shows an error panel instead.

#### Saved views and the object binding

Expand Down
29 changes: 28 additions & 1 deletion ui/packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ See the full table in [ADR-0059](../../docs/adr/0059-action-params-shared-field-

## Product-page blocks

Five generic blocks for the pages a product composes as metadata (a product's
Six generic blocks for the pages a product composes as metadata (a product's
welcome page, a CRM's sales home), registered by this package and named for
what they draw. Each reads its props from the node's `properties`; copy is a
string or an inline translation map (`{ en, "zh-CN" }`), resolved in the
Expand All @@ -516,6 +516,7 @@ viewer's language.
| `usage-bar` | how much of a limit is used | `label`, `used`, `limit?` (absent or `0` = unlimited: no bar), `unit?` |
| `checklist` | steps that tick as they are done | `items: [{ label, done, action? }]`, `collapsedWhenDone?` (draw nothing once every step is done) |
| `tile-grid` | the console home's launcher tiles | `items: [{ label, href, icon?, caption? }]`, `addTile?: { label, href }` |
| `row-table` | the rows a bound list holds, as a table | `rows` (a list, or a `${…}` hole naming one), `columns: [{ path, label, format?, enum? }]`, `emptyLabel?` |

Any of them can carry the spec's API binding, and then read values from the
answer through `${…}` holes:
Expand All @@ -528,6 +529,32 @@ answer through `${…}` holes:
}
```

`row-table` draws every row, its columns in the order written. A column's
`path` is a dotted path into one row (`owner.name`), its `label` the heading,
and its `format` one of `text` (the default, drawn as written), `datetime` (an
instant in the viewer's display locale and time zone, the `datetime` field
cell's face) or `enum` (the label `enum: { value: label }` names; a value the
map does not name is drawn as written). A field the row lacks, `null` or empty
draws `—`; an empty list draws `emptyLabel` ("No rows to display" when
omitted). A `rows` hole that resolves to anything but a list, or a column the
block cannot draw, draws the binding's error panel instead of an empty table:

```json
{
"type": "row-table",
"dataSource": { "provider": "api", "read": { "url": "/api/v1/ai/actors" }, "path": "data" },
"properties": {
"rows": "${data.actors}",
"columns": [
{ "path": "name", "label": { "en": "Name", "zh-CN": "名称" } },
{ "path": "status", "label": "Status", "format": "enum", "enum": { "active": "Active", "paused": "Paused" } },
{ "path": "lastRun.at", "label": "Last run", "format": "datetime" }
],
"emptyLabel": { "en": "No agents yet" }
}
}
```

The read, its loading and error states, and the `data` root are
`SchemaRenderer`'s (see "Binding a page element to an API endpoint" in the data
source guide). `ConnectedShell` keeps one open's reads (`ElementApiOpenScope`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ const warnings = vi.hoisted(() => {

import '../productBlocks';

const HOME_BLOCKS = ['hero', 'stat', 'usage-bar', 'checklist', 'tile-grid'] as const;
// `row-table` (#31856) joined the five, registered the same way.
const HOME_BLOCKS = ['hero', 'stat', 'usage-bar', 'checklist', 'tile-grid', 'row-table'] as const;

const DEPRECATED = /Registering component "([^"]+)" without a namespace is deprecated/;

Expand All @@ -49,7 +50,7 @@ afterAll(() => {
});

describe('console home blocks register under `app-shell` (objectui#13301)', () => {
it('logs no deprecation line for the five blocks when the module loads', () => {
it('logs no deprecation line for the blocks when the module loads', () => {
const named = warnings.seen
.map((line) => DEPRECATED.exec(line)?.[1])
.filter((type): type is string => Boolean(type));
Expand Down Expand Up @@ -94,6 +95,7 @@ describe('a page authoring the bare key still draws each block (objectui#13301)'
['usage-bar', { type: 'usage-bar', properties: { label: 'Storage', used: 256, limit: 1024 } }, 'Storage'],
['checklist', { type: 'checklist', properties: { items: [{ label: 'Create an environment', done: false }] } }, 'Create an environment'],
['tile-grid', { type: 'tile-grid', properties: { items: [{ label: 'CRM', href: '/apps/crm' }] } }, 'CRM'],
['row-table', { type: 'row-table', properties: { rows: [{ name: 'Ada' }], columns: [{ path: 'name', label: 'Name' }] } }, 'Ada'],
];

it.each(pages)('`%s`', (_block, schema, text) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `row-table` (#31856): the rows a page `api` binding answers, as a table,
* rendered through the real `SchemaRenderer` with the binding read through the
* host fetch on `SchemaRendererContext.apiFetch`.
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ComponentRegistry, formatDateTime, setDisplayTimeZone } from '@object-ui/core';
import { I18nProvider } from '@object-ui/i18n';
import { SchemaRenderer, SchemaRendererContext } from '@object-ui/react';
import type { DeclaredNode } from '@object-ui/types';
import '../productBlocks';

const ACTORS = {
success: true,
data: {
actors: [
{ name: 'Ada', status: 'active', lastRun: { at: '2026-11-09T08:30:00Z' } },
{ name: 'Grace', status: 'paused', lastRun: null },
{ name: 'Linus', status: 'retired' },
],
none: [],
summary: { count: 3 },
},
};

const BINDING = { provider: 'api', read: { url: '/api/v1/ai/actors' }, path: 'data' } as const;

const COLUMNS = [
{ path: 'name', label: { en: 'Name', 'zh-CN': '名称' } },
{ path: 'status', label: 'Status', format: 'enum', enum: { active: { en: 'Active' }, paused: 'Paused' } },
{ path: 'lastRun.at', label: 'Last run', format: 'datetime' },
];

function renderTable(properties: Record<string, unknown>, bound = true) {
const apiFetch = vi.fn(async () => new Response(JSON.stringify(ACTORS), { status: 200 }));
const schema = { type: 'row-table', ...(bound ? { dataSource: BINDING } : {}), properties } as unknown as DeclaredNode;
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<MemoryRouter>
<SchemaRendererContext.Provider value={{ dataSource: null, apiFetch }}>
<SchemaRenderer schema={schema} />
</SchemaRendererContext.Provider>
</MemoryRouter>
</I18nProvider>,
);
return apiFetch;
}

/** The cell texts of one body row. */
function cells(row: number): string[] {
return within(screen.getByTestId(`row-table-row-${row}`))
.getAllByRole('cell')
.map((cell) => cell.textContent ?? '');
}

afterEach(() => {
cleanup();
setDisplayTimeZone(undefined);
});

describe('row-table (#31856)', () => {
it('registers with declared inputs and the API binding input', () => {
const config = ComponentRegistry.getConfig('row-table');
const names = (config?.inputs ?? []).map((input) => input.name);
expect(names).toEqual(['rows', 'columns', 'emptyLabel', 'dataSource']);
for (const input of config?.inputs ?? []) expect(input.description, input.name).toBeTruthy();
});

it('draws every row the binding answers, its columns in authored order', async () => {
const apiFetch = renderTable({ rows: '${data.actors}', columns: COLUMNS });
await waitFor(() => expect(screen.getByTestId('row-table-row-2')).toBeInTheDocument());
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(screen.getAllByRole('columnheader').map((head) => head.textContent)).toEqual(['Name', 'Status', 'Last run']);
expect(cells(0)[0]).toBe('Ada');
expect(cells(1)[0]).toBe('Grace');
expect(cells(2)[0]).toBe('Linus');
});

it('draws an enum value through its label map, and a value the map does not name as written', async () => {
renderTable({ rows: '${data.actors}', columns: COLUMNS });
await waitFor(() => expect(screen.getByTestId('row-table-row-2')).toBeInTheDocument());
expect(cells(0)[1]).toBe('Active');
expect(cells(1)[1]).toBe('Paused');
expect(cells(2)[1]).toBe('retired');
});

it('draws a datetime as an instant in the display time zone', async () => {
setDisplayTimeZone('Asia/Tokyo');
renderTable({ rows: '${data.actors}', columns: COLUMNS });
await waitFor(() => expect(screen.getByTestId('row-table-row-0')).toBeInTheDocument());
const shown = cells(0)[2];
// The one date-display path, in the declared zone: 08:30Z is 17:30 in Tokyo.
expect(shown).toBe(formatDateTime('2026-11-09T08:30:00Z', { locale: 'en', style: 'compact' }));
expect(shown).toMatch(/17:30|5:30/);
expect(shown).not.toMatch(/08:30|8:30/);
});

it('draws "—" for a field the row lacks or holds null', async () => {
renderTable({ rows: '${data.actors}', columns: COLUMNS });
await waitFor(() => expect(screen.getByTestId('row-table-row-2')).toBeInTheDocument());
expect(cells(1)[2]).toBe('—');
expect(cells(2)[2]).toBe('—');
});

it('draws the authored empty label for an empty list, else the bundle\'s', async () => {
renderTable({ rows: '${data.none}', columns: COLUMNS, emptyLabel: { en: 'No agents yet' } });
expect((await screen.findByTestId('row-table-empty')).textContent).toBe('No agents yet');
cleanup();
renderTable({ rows: [], columns: COLUMNS }, false);
expect(screen.getByTestId('row-table-empty').textContent).toBe('No rows to display');
});

it('refuses a hole that resolves to a non-list with the error panel, never the empty state', async () => {
renderTable({ rows: '${data.summary}', columns: COLUMNS, emptyLabel: 'No agents yet' });
const panel = await screen.findByTestId('row-table-datasource-error');
expect(panel).toHaveAttribute('role', 'alert');
expect(panel.textContent).toContain('rows resolved to an object');
expect(screen.queryByTestId('row-table-empty')).toBeNull();
});

it('refuses a column format it does not draw with the error panel', () => {
renderTable({ rows: [{ name: 'Ada' }], columns: [{ path: 'name', label: 'Name', format: 'currency' }] }, false);
const panel = screen.getByTestId('row-table-datasource-error');
expect(panel.textContent).toContain('columns[0].format "currency"');
expect(screen.queryByTestId('row-table-block')).toBeNull();
});
});
Loading
Loading