diff --git a/.changeset/curly-donkeys-relax.md b/.changeset/curly-donkeys-relax.md new file mode 100644 index 00000000..93fbcf51 --- /dev/null +++ b/.changeset/curly-donkeys-relax.md @@ -0,0 +1,5 @@ +--- +'@opensaas/stack-ui': patch +--- + +List-page relationship cells now render their label via the shared label seam (`getItemLabel`), honouring a related list's `ui.labelField` instead of an inline `name → title → label → id` guess that had drifted from the item form. diff --git a/packages/ui/src/components/ListView.tsx b/packages/ui/src/components/ListView.tsx index e92802cf..df150f31 100644 --- a/packages/ui/src/components/ListView.tsx +++ b/packages/ui/src/components/ListView.tsx @@ -1,7 +1,29 @@ import Link from 'next/link.js' import { ListViewClient } from './ListViewClient.js' import { formatListName } from '../lib/utils.js' -import { type AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core' +import { + type AccessContext, + getDbKey, + getItemLabel, + getUrlKey, + OpenSaasConfig, +} from '@opensaas/stack-core' + +/** + * Resolve a fetched relationship value (the full related record, or `null`) + * into the `{ id, label }` shape `ListViewClient` renders — computing the + * label via the shared label seam (`getItemLabel`) so list-page cells never + * drift from the item form's relationship-option labels. + */ +function toRelationshipLabel( + value: unknown, + relatedListConfig: OpenSaasConfig['lists'][string] | undefined, +): { id: string; label: string } | null { + if (!value || typeof value !== 'object' || !relatedListConfig) return null + const row = value as Record + if (!('id' in row)) return null + return { id: String(row.id), label: getItemLabel(relatedListConfig, row) } +} /** * Default sort for the list table, mirroring Keystone's `ui.listView.initialSort`. @@ -124,9 +146,6 @@ export async function ListView({ console.error(`Failed to fetch ${listKey}:`, error) } - // Serialize items for client component (convert Dates, etc to JSON-safe format) - const serializedItems = JSON.parse(JSON.stringify(items)) - // Extract only the relationship refs needed by client (don't send entire config) const relationshipRefs: Record = {} Object.entries(listConfig.fields).forEach(([fieldName, field]) => { @@ -140,6 +159,27 @@ export async function ListView({ } }) + // Resolve each relationship value into { id, label } via the shared label + // seam before crossing the server/client boundary — ListConfig objects + // carry functions and can't be passed as props to the client component. + const itemsWithResolvedLabels = items.map((item) => { + const resolved: Record = { ...item } + for (const [fieldName, ref] of Object.entries(relationshipRefs)) { + const [relatedListKey] = ref.split('.') + const relatedListConfig = config.lists[relatedListKey] + const rawValue = item[fieldName] + resolved[fieldName] = Array.isArray(rawValue) + ? rawValue + .map((row) => toRelationshipLabel(row, relatedListConfig)) + .filter((row): row is { id: string; label: string } => row !== null) + : toRelationshipLabel(rawValue, relatedListConfig) + } + return resolved + }) + + // Serialize items for client component (convert Dates, etc to JSON-safe format) + const serializedItems = JSON.parse(JSON.stringify(itemsWithResolvedLabels)) + return (
{/* Header */} diff --git a/packages/ui/src/components/ListViewClient.tsx b/packages/ui/src/components/ListViewClient.tsx index 9cb67d27..5edd4d7f 100644 --- a/packages/ui/src/components/ListViewClient.tsx +++ b/packages/ui/src/components/ListViewClient.tsx @@ -17,6 +17,19 @@ import { Button } from '../primitives/button.js' import { Card } from '../primitives/card.js' import { getUrlKey } from '@opensaas/stack-core' +/** + * A relationship value after the server has resolved its label via the + * shared label seam (`getItemLabel`) — see `ListView.tsx`. + */ +interface RelationshipLabelValue { + id: string + label: string +} + +function isRelationshipLabelValue(value: unknown): value is RelationshipLabelValue { + return !!value && typeof value === 'object' && 'id' in value && 'label' in value +} + export interface ListViewClientProps { items: Array> fieldTypes: Record @@ -117,59 +130,62 @@ export function ListViewClient({ } /** - * Render a relationship field as a clickable link or links + * Render a relationship field as a clickable link or links. + * + * `value` has already been resolved server-side (`ListView.tsx`) into + * `{ id, label }` pairs via the shared label seam (`getItemLabel`), so no + * label derivation happens here. */ const renderRelationshipCell = (value: unknown, fieldName: string) => { - const ref = relationshipRefs[fieldName] - if (!ref) { - return getFieldDisplayValue(value, 'relationship') - } - - // Parse ref to get related list name - const [relatedListKey] = ref.split('.') - const relatedUrlKey = getUrlKey(relatedListKey) - - if (!value || typeof value !== 'object') { + if (!value) { return - } + const ref = relationshipRefs[fieldName] + const relatedUrlKey = ref ? getUrlKey(ref.split('.')[0]) : undefined + // Handle array of relationships (many: true) if (Array.isArray(value)) { - if (value.length === 0) return - + const relatedItems = value.filter(isRelationshipLabelValue) + if (relatedItems.length === 0) return - return ( - {value.map((item, idx) => { - if (!item || typeof item !== 'object') return null - const displayValue = getFieldDisplayValue(item, 'relationship') - const itemId = 'id' in item ? item.id : null - const key = itemId || idx - return ( - - {idx > 0 && , } + {relatedItems.map((item, idx) => ( + + {idx > 0 && , } + {relatedUrlKey ? ( e.stopPropagation()} > - {displayValue} + {item.label} - - ) - })} + ) : ( + item.label + )} + + ))} ) } // Handle single relationship - const itemId = 'id' in value ? value.id : null - const displayValue = getFieldDisplayValue(value, 'relationship') + if (!isRelationshipLabelValue(value)) { + return - + } + + if (!relatedUrlKey) { + return value.label + } + return ( e.stopPropagation()} > - {displayValue} + {value.label} ) } diff --git a/packages/ui/src/components/standalone/ListTable.tsx b/packages/ui/src/components/standalone/ListTable.tsx index f50dfa19..cc892a53 100644 --- a/packages/ui/src/components/standalone/ListTable.tsx +++ b/packages/ui/src/components/standalone/ListTable.tsx @@ -13,6 +13,37 @@ import { TableRow, } from '../../primitives/table.js' +/** + * `ListTable` is a standalone component embedded by consumers with raw + * Prisma-shaped items and no list config, so it can't resolve labels via the + * shared label seam (`getItemLabel`) the way `ListView`/`ListViewClient` do. + * This local fallback (name → title → label → id) is intentionally private + * to this component so it can't drift against another copy elsewhere. + */ +function getRelationshipDisplayValue(value: unknown): string { + if (!value || typeof value !== 'object') { + return '-' + } + + if (Array.isArray(value)) { + if (value.length === 0) return '-' + return value.map((item) => getRelationshipDisplayValue(item)).join(', ') + } + + let displayValue: unknown + if ('name' in value) { + displayValue = value.name + } else if ('title' in value) { + displayValue = value.title + } else if ('label' in value) { + displayValue = value.label + } else if ('id' in value) { + displayValue = value.id + } + + return displayValue ? String(displayValue) : '-' +} + export interface ListTableProps { items: Array> fieldTypes: Record @@ -64,12 +95,12 @@ export function ListTable({ */ const renderRelationshipCell = (value: unknown, fieldName: string) => { if (!relationshipRefs) { - return getFieldDisplayValue(value, 'relationship') + return getRelationshipDisplayValue(value) } const ref = relationshipRefs[fieldName] if (!ref) { - return getFieldDisplayValue(value, 'relationship') + return getRelationshipDisplayValue(value) } // Parse ref to get related list name @@ -87,7 +118,7 @@ export function ListTable({ {value.map((item, idx) => { if (!item || typeof item !== 'object') return null - const displayValue = getFieldDisplayValue(item, 'relationship') + const displayValue = getRelationshipDisplayValue(item) const itemId = 'id' in item ? item.id : null const key = itemId || idx return ( @@ -109,7 +140,7 @@ export function ListTable({ // Handle single relationship const itemId = 'id' in value ? value.id : null - const displayValue = getFieldDisplayValue(value, 'relationship') + const displayValue = getRelationshipDisplayValue(value) return ( getRelationshipDisplayValue(item)).join(', ') - } - - // Handle single relationship object - let displayValue: unknown - if ('name' in value) { - displayValue = value.name - } else if ('title' in value) { - displayValue = value.title - } else if ('label' in value) { - displayValue = value.label - } else if ('id' in value) { - displayValue = value.id - } - - return displayValue ? String(displayValue) : '-' -} diff --git a/packages/ui/tests/components/ListView.test.tsx b/packages/ui/tests/components/ListView.test.tsx new file mode 100644 index 00000000..d7b10eb0 --- /dev/null +++ b/packages/ui/tests/components/ListView.test.tsx @@ -0,0 +1,212 @@ +import { describe, it, expect, vi } from 'vitest' +import * as React from 'react' +import type { AccessContext, OpenSaasConfig } from '@opensaas/stack-core' +import { list } from '@opensaas/stack-core' +import { text, relationship } from '@opensaas/stack-core/fields' +import { ListView } from '../../src/components/ListView.js' +import { ListViewClient, type ListViewClientProps } from '../../src/components/ListViewClient.js' + +// Mock Next.js navigation/link — ListViewClient (rendered inside ListView's +// tree) calls useRouter(), and ListView itself renders a Link. +vi.mock('next/navigation.js', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +vi.mock('next/link.js', () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})) + +interface DelegateStub { + findMany?: (args: unknown) => Promise>> + count?: (args: unknown) => Promise +} + +function makeContext(delegates: Record): AccessContext { + const context = { + db: delegates, + session: null, + storage: {}, + plugins: {}, + _isSudo: false, + _resolveOutputCounter: { depth: 0 }, + } + return context as unknown as AccessContext +} + +/** + * ListView returns `
{header}
`. + * Drill into it to recover the props passed to `ListViewClient` so we can + * assert on the resolved relationship values without rendering to the DOM. + */ +function findListViewClientProps(tree: React.ReactElement): ListViewClientProps { + const outer = tree as React.ReactElement<{ children: React.ReactNode }> + const children = React.Children.toArray(outer.props.children) + const client = children.find( + (child): child is React.ReactElement => + React.isValidElement(child) && child.type === ListViewClient, + ) + if (!client) throw new Error('ListViewClient not found in ListView output') + return client.props +} + +describe('ListView relationship label resolution (shared label seam)', () => { + it('resolves a relationship value to { id, label } via getItemLabel, defaulting to "name"', async () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite', url: 'file:./test.db' }, + lists: { + User: list({ fields: { name: text() } }), + Post: list({ + fields: { + title: text(), + author: relationship({ ref: 'User.posts' }), + }, + }), + }, + } + + const context = makeContext({ + post: { + findMany: vi.fn(async () => [ + { id: '1', title: 'Post 1', author: { id: 'user-1', name: 'Ada Lovelace' } }, + ]), + count: vi.fn(async () => 1), + }, + }) + + const tree = await ListView({ context, config, listKey: 'Post', basePath: '/admin' }) + const props = findListViewClientProps(tree) + + expect(props.items[0].author).toEqual({ id: 'user-1', label: 'Ada Lovelace' }) + }) + + it('honours a configured ui.labelField on the related list', async () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite', url: 'file:./test.db' }, + lists: { + User: list({ + fields: { name: text(), email: text() }, + ui: { labelField: 'email' }, + }), + Post: list({ + fields: { + title: text(), + author: relationship({ ref: 'User.posts' }), + }, + }), + }, + } + + const context = makeContext({ + post: { + findMany: vi.fn(async () => [ + { + id: '1', + title: 'Post 1', + author: { id: 'user-1', name: 'Ada Lovelace', email: 'ada@example.com' }, + }, + ]), + count: vi.fn(async () => 1), + }, + }) + + const tree = await ListView({ context, config, listKey: 'Post', basePath: '/admin' }) + const props = findListViewClientProps(tree) + + expect(props.items[0].author).toEqual({ id: 'user-1', label: 'ada@example.com' }) + }) + + it('resolves a many relationship to an array of { id, label } pairs', async () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite', url: 'file:./test.db' }, + lists: { + Tag: list({ fields: { name: text() } }), + Post: list({ + fields: { + title: text(), + tags: relationship({ ref: 'Tag', many: true }), + }, + }), + }, + } + + const context = makeContext({ + post: { + findMany: vi.fn(async () => [ + { + id: '1', + title: 'Post 1', + tags: [ + { id: 'tag-1', name: 'JavaScript' }, + { id: 'tag-2', name: 'TypeScript' }, + ], + }, + ]), + count: vi.fn(async () => 1), + }, + }) + + const tree = await ListView({ context, config, listKey: 'Post', basePath: '/admin' }) + const props = findListViewClientProps(tree) + + expect(props.items[0].tags).toEqual([ + { id: 'tag-1', label: 'JavaScript' }, + { id: 'tag-2', label: 'TypeScript' }, + ]) + }) + + it('falls back to id when the related row is missing the label field (e.g. stripped by access control)', async () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite', url: 'file:./test.db' }, + lists: { + User: list({ fields: { name: text() } }), + Post: list({ + fields: { + title: text(), + author: relationship({ ref: 'User.posts' }), + }, + }), + }, + } + + const context = makeContext({ + post: { + findMany: vi.fn(async () => [{ id: '1', title: 'Post 1', author: { id: 'user-1' } }]), + count: vi.fn(async () => 1), + }, + }) + + const tree = await ListView({ context, config, listKey: 'Post', basePath: '/admin' }) + const props = findListViewClientProps(tree) + + expect(props.items[0].author).toEqual({ id: 'user-1', label: 'user-1' }) + }) + + it('resolves an empty/null relationship to null, preserving the dash rendered downstream', async () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite', url: 'file:./test.db' }, + lists: { + User: list({ fields: { name: text() } }), + Post: list({ + fields: { + title: text(), + author: relationship({ ref: 'User.posts' }), + }, + }), + }, + } + + const context = makeContext({ + post: { + findMany: vi.fn(async () => [{ id: '1', title: 'Post 1', author: null }]), + count: vi.fn(async () => 1), + }, + }) + + const tree = await ListView({ context, config, listKey: 'Post', basePath: '/admin' }) + const props = findListViewClientProps(tree) + + expect(props.items[0].author).toBeNull() + }) +}) diff --git a/packages/ui/tests/components/ListViewClient.test.tsx b/packages/ui/tests/components/ListViewClient.test.tsx index 9823aca6..2685a5ef 100644 --- a/packages/ui/tests/components/ListViewClient.test.tsx +++ b/packages/ui/tests/components/ListViewClient.test.tsx @@ -350,12 +350,15 @@ describe('ListViewClient', () => { }) describe('relationships', () => { + // Relationship values arrive pre-resolved to { id, label } — the server + // component (ListView.tsx) computes `label` via the shared label seam + // (getItemLabel) before crossing the server/client boundary. it('should render relationship as link when relationshipRefs provided', () => { const items = [ { id: '1', title: 'Post 1', - author: { id: 'user-1', name: 'John Doe' }, + author: { id: 'user-1', label: 'John Doe' }, }, ] @@ -380,8 +383,8 @@ describe('ListViewClient', () => { id: '1', title: 'Post 1', tags: [ - { id: 'tag-1', name: 'JavaScript' }, - { id: 'tag-2', name: 'TypeScript' }, + { id: 'tag-1', label: 'JavaScript' }, + { id: 'tag-2', label: 'TypeScript' }, ], }, ]