diff --git a/.changeset/tame-otters-listen.md b/.changeset/tame-otters-listen.md new file mode 100644 index 00000000..f9cf7aa1 --- /dev/null +++ b/.changeset/tame-otters-listen.md @@ -0,0 +1,17 @@ +--- +'@opensaas/stack-core': minor +--- + +Add a label seam for the admin UI: `getLabelFieldName(listConfig)` resolves the field that represents a list's rows as a single label (`ui.labelField` → `name` → `title` → `id`), and `getItemLabel(listConfig, item)` reads that field off a row, falling back to `id` when it's missing. Both are exported from the root entry point. + +```typescript +import { getLabelFieldName, getItemLabel } from '@opensaas/stack-core' + +Post: list({ + fields: { title: text() }, + ui: { labelField: 'title' }, +}) + +getLabelFieldName(listConfig) // 'title' +getItemLabel(listConfig, item) // item.title, or item.id if title is missing +``` diff --git a/CONTEXT.md b/CONTEXT.md index 4006074f..289edf6f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -81,3 +81,13 @@ _Avoid_: storage backend, storage adapter, uploader **Provider registry**: The lookup from a configured provider `type` to the constructor that builds it, which the storage runtime consults instead of a closed `switch`. A host registers the (optional) provider packages it uses, so non-`local` and custom providers are constructable without the runtime depending on every provider's SDK. _Avoid_: provider switch, provider map, plugin registry + +### Admin UI + +**Label field**: +The field a list uses to represent its rows as a single value — resolved by `getLabelFieldName` from `ui.labelField` (when configured and pointing at a declared, non-relationship field) or the fallback order `name` → `title` → `id`. The single source of truth so the field chosen for projection can never drift from the field used for rendering. +_Avoid_: display field, title field, name field (the fallback happens to check a field called `name`, but the concept isn't tied to that key) + +**Item label**: +The rendered text for one row, produced by `getItemLabel` reading the Label field off that row and falling back to `id` when the field is missing (e.g. stripped by field-level access). Used anywhere the admin UI shows a row as a reference — relationship cells, dropdown options, page headings. +_Avoid_: display value, row label diff --git a/packages/core/src/config/label.ts b/packages/core/src/config/label.ts new file mode 100644 index 00000000..6e7192cf --- /dev/null +++ b/packages/core/src/config/label.ts @@ -0,0 +1,58 @@ +import type { ListConfig } from './types.js' + +/** + * Resolve the field name that represents a list's rows as a single label — + * the projection half of the label seam. `getItemLabel` reads exactly the + * field this returns, so the field chosen for projection can never drift + * from the field used for rendering. + * + * Fallback order: configured `ui.labelField` → `name` → `title` → `id` + * (first field that exists on the list). + * + * @throws if `ui.labelField` is set but does not reference a declared, + * non-relationship field on the list. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo +export function getLabelFieldName(listConfig: ListConfig): string { + const configured = listConfig.ui?.labelField + + if (configured !== undefined) { + const field = listConfig.fields[configured] + if (!field) { + throw new Error( + `ui.labelField "${configured}" does not reference a field declared on this list.`, + ) + } + if (field.type === 'relationship') { + throw new Error( + `ui.labelField "${configured}" must reference a scalar field, not a relationship.`, + ) + } + return configured + } + + if ('name' in listConfig.fields) return 'name' + if ('title' in listConfig.fields) return 'title' + return 'id' +} + +/** + * Resolve the display label for a single row — the render half of the label + * seam. Reads the field `getLabelFieldName` resolves, falling back to `id` + * when that field is missing from the row (e.g. stripped by field-level + * access). + */ +export function getItemLabel( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo + listConfig: ListConfig, + item: Record, +): string { + const fieldName = getLabelFieldName(listConfig) + const value = Object.prototype.hasOwnProperty.call(item, fieldName) ? item[fieldName] : undefined + + if (value === undefined || value === null) { + return String(item.id) + } + + return String(value) +} diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 49188adc..7aeacdcf 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -1711,11 +1711,11 @@ export type ListConfig = { /** * List-level UI configuration for the admin interface. * - * Mirrors Keystone's `ui` block on a list. Only the list-view defaults - * (column selection/order and default sort) are supported today; other - * Keystone concerns (`label`, `labelField`, `description`) are intentionally - * deferred as they cover different concerns (navigation text and - * relationship-picker labels rather than list-view defaults). + * Mirrors Keystone's `ui` block on a list. List-view defaults (column + * selection/order and default sort) and the label field are supported + * today; other Keystone concerns (`label`, `description`) are intentionally + * deferred as they cover navigation text rather than list-view or + * row-labelling defaults. */ export type ListUIConfig = { /** @@ -1723,6 +1723,20 @@ export type ListUIConfig = { * Keystone's `ui.listView`. */ listView?: ListViewUIConfig + /** + * The field used to represent a row as a single label — in relationship + * cells, dropdown options, and page headings. Must reference a declared, + * non-relationship field on this list. + * + * When omitted, resolves via `getLabelFieldName`'s fallback order: `name` + * → `title` → `id` (first field that exists on the list). + * + * @example + * ```typescript + * ui: { labelField: 'email' } + * ``` + */ + labelField?: string } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1da05a56..3f864e66 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -43,6 +43,11 @@ export type { // Naming utilities (documented public helpers; used for URLs and db keys) export { getDbKey, getUrlKey, getListKeyFromUrl } from './lib/case-utils.js' +// Label seam — resolves the field that represents a row as a single label +// (projection) and reads it off a row (render). Used by the admin UI for +// relationship cells, dropdown options, and page headings. +export { getLabelFieldName, getItemLabel } from './config/label.js' + // Validation error surfaced by write operations export { ValidationError } from './hooks/index.js' diff --git a/packages/core/tests/label.test.ts b/packages/core/tests/label.test.ts new file mode 100644 index 00000000..5449d30b --- /dev/null +++ b/packages/core/tests/label.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest' +import { getLabelFieldName, getItemLabel } from '../src/config/label.js' +import type { ListConfig, TypeInfo } from '../src/config/types.js' + +function makeListConfig( + fields: ListConfig['fields'], + labelField?: string, +): ListConfig { + return { + fields, + ui: labelField !== undefined ? { labelField } : undefined, + } +} + +describe('label seam', () => { + describe('getLabelFieldName', () => { + it('uses the configured ui.labelField when it references a scalar field', () => { + const listConfig = makeListConfig( + { name: { type: 'text' }, email: { type: 'text' } }, + 'email', + ) + expect(getLabelFieldName(listConfig)).toBe('email') + }) + + it('falls back to "name" when no ui.labelField is configured', () => { + const listConfig = makeListConfig({ name: { type: 'text' }, title: { type: 'text' } }) + expect(getLabelFieldName(listConfig)).toBe('name') + }) + + it('falls back to "title" when "name" is not declared', () => { + const listConfig = makeListConfig({ title: { type: 'text' }, body: { type: 'text' } }) + expect(getLabelFieldName(listConfig)).toBe('title') + }) + + it('falls back to "id" when neither "name" nor "title" is declared', () => { + const listConfig = makeListConfig({ body: { type: 'text' } }) + expect(getLabelFieldName(listConfig)).toBe('id') + }) + + it('throws when ui.labelField references a field not declared on the list', () => { + const listConfig = makeListConfig({ name: { type: 'text' } }, 'nickname') + expect(() => getLabelFieldName(listConfig)).toThrow( + /ui.labelField "nickname" does not reference a field declared on this list/, + ) + }) + + it('throws when ui.labelField references a relationship field', () => { + const listConfig = makeListConfig( + { name: { type: 'text' }, author: { type: 'relationship' } }, + 'author', + ) + expect(() => getLabelFieldName(listConfig)).toThrow( + /ui.labelField "author" must reference a scalar field, not a relationship/, + ) + }) + }) + + describe('getItemLabel', () => { + it('reads the resolved label field off the row', () => { + const listConfig = makeListConfig({ name: { type: 'text' } }) + expect(getItemLabel(listConfig, { id: '1', name: 'Ada Lovelace' })).toBe('Ada Lovelace') + }) + + it('falls back to id when the resolved field is missing from the row', () => { + const listConfig = makeListConfig({ name: { type: 'text' } }) + // e.g. stripped by field-level access + expect(getItemLabel(listConfig, { id: 'row-1' })).toBe('row-1') + }) + + it('falls back to id when the resolved field is null on the row', () => { + const listConfig = makeListConfig({ name: { type: 'text' } }) + expect(getItemLabel(listConfig, { id: 'row-2', name: null })).toBe('row-2') + }) + + it('respects a configured ui.labelField', () => { + const listConfig = makeListConfig( + { name: { type: 'text' }, email: { type: 'text' } }, + 'email', + ) + expect(getItemLabel(listConfig, { id: '1', name: 'Ada', email: 'ada@example.com' })).toBe( + 'ada@example.com', + ) + }) + }) + + describe('projection⇄render agreement', () => { + const cases: Array<{ + description: string + labelField?: string + fields: ListConfig['fields'] + row: Record + }> = [ + { + description: 'configured labelField, field present on row', + labelField: 'email', + fields: { name: { type: 'text' }, email: { type: 'text' } }, + row: { id: '1', name: 'Ada', email: 'ada@example.com' }, + }, + { + description: 'no labelField, name present', + fields: { name: { type: 'text' }, title: { type: 'text' } }, + row: { id: '2', name: 'Alan', title: 'Dr' }, + }, + { + description: 'no labelField, only title present', + fields: { title: { type: 'text' } }, + row: { id: '3', title: 'Untitled Post' }, + }, + { + description: 'no labelField, no name/title, falls back to id', + fields: { body: { type: 'text' } }, + row: { id: '4', body: 'hello' }, + }, + { + description: 'configured labelField, field stripped from row by access control', + labelField: 'email', + fields: { name: { type: 'text' }, email: { type: 'text' } }, + row: { id: '5', name: 'Grace' }, + }, + ] + + it.each(cases)( + 'the field getLabelFieldName resolves is the field getItemLabel reads: $description', + ({ labelField, fields, row }) => { + const listConfig = makeListConfig(fields, labelField) + const resolvedFieldName = getLabelFieldName(listConfig) + const expectedLabel = Object.prototype.hasOwnProperty.call(row, resolvedFieldName) + ? (row[resolvedFieldName] ?? row.id) + : row.id + + expect(getItemLabel(listConfig, row)).toBe(String(expectedLabel)) + }, + ) + }) +})