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
17 changes: 17 additions & 0 deletions .changeset/tame-otters-listen.md
Original file line number Diff line number Diff line change
@@ -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
```
10 changes: 10 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
58 changes: 58 additions & 0 deletions packages/core/src/config/label.ts
Original file line number Diff line number Diff line change
@@ -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<any>): 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<any>,
item: Record<string, unknown>,
): 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)
}
24 changes: 19 additions & 5 deletions packages/core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1711,18 +1711,32 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
/**
* 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 = {
/**
* Default list-view (table) configuration for this list, mirroring
* 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
}

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
135 changes: 135 additions & 0 deletions packages/core/tests/label.test.ts
Original file line number Diff line number Diff line change
@@ -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<TypeInfo>['fields'],
labelField?: string,
): ListConfig<TypeInfo> {
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<TypeInfo>['fields']
row: Record<string, unknown>
}> = [
{
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))
},
)
})
})
Loading