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
5 changes: 5 additions & 0 deletions .changeset/curly-donkeys-relax.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 44 additions & 4 deletions packages/ui/src/components/ListView.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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`.
Expand Down Expand Up @@ -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<string, string> = {}
Object.entries(listConfig.fields).forEach(([fieldName, field]) => {
Expand All @@ -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<string, unknown> = { ...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 (
<div className="p-8">
{/* Header */}
Expand Down
74 changes: 45 additions & 29 deletions packages/ui/src/components/ListViewClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>
fieldTypes: Record<string, string>
Expand Down Expand Up @@ -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 <span className="text-muted-foreground">-</span>
}

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 <span className="text-muted-foreground">-</span>
const relatedItems = value.filter(isRelationshipLabelValue)
if (relatedItems.length === 0) return <span className="text-muted-foreground">-</span>
return (
<span className="flex flex-wrap gap-1">
{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 (
<React.Fragment key={key}>
{idx > 0 && <span className="text-muted-foreground">, </span>}
{relatedItems.map((item, idx) => (
<React.Fragment key={item.id}>
{idx > 0 && <span className="text-muted-foreground">, </span>}
{relatedUrlKey ? (
<Link
href={`${basePath}/${relatedUrlKey}/${itemId}`}
href={`${basePath}/${relatedUrlKey}/${item.id}`}
className="text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{displayValue}
{item.label}
</Link>
</React.Fragment>
)
})}
) : (
item.label
)}
</React.Fragment>
))}
</span>
)
}

// Handle single relationship
const itemId = 'id' in value ? value.id : null
const displayValue = getFieldDisplayValue(value, 'relationship')
if (!isRelationshipLabelValue(value)) {
return <span className="text-muted-foreground">-</span>
}

if (!relatedUrlKey) {
return value.label
}

return (
<Link
href={`${basePath}/${relatedUrlKey}/${itemId}`}
href={`${basePath}/${relatedUrlKey}/${value.id}`}
className="text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{displayValue}
{value.label}
</Link>
)
}
Expand Down
39 changes: 35 additions & 4 deletions packages/ui/src/components/standalone/ListTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>
fieldTypes: Record<string, string>
Expand Down Expand Up @@ -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
Expand All @@ -87,7 +118,7 @@ export function ListTable({
<span className="flex flex-wrap gap-1">
{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 (
Expand All @@ -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 (
<Link
href={`${basePath}/${relatedUrlKey}/${itemId}`}
Expand Down
38 changes: 5 additions & 33 deletions packages/ui/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export function formatFieldName(name: string): string {
}

/**
* Get the display value for a field
* Get the display value for a scalar field.
*
* Relationship fields are not handled here — their label is resolved via the
* shared label seam (`getItemLabel`) by the component that has access to the
* related list's config (see `ListView.tsx`), not derived from the raw value.
*/
export function getFieldDisplayValue(value: unknown, fieldType: string): string {
if (value === null || value === undefined) {
Expand All @@ -43,39 +47,7 @@ export function getFieldDisplayValue(value: unknown, fieldType: string): string
return new Date(value as string | number | Date).toLocaleString()
case 'password':
return '••••••••'
case 'relationship':
return getRelationshipDisplayValue(value)
default:
return String(value)
}
}

/**
* Get display value for a relationship field
* Tries to display: name → title → label → id
*/
function getRelationshipDisplayValue(value: unknown): string {
if (!value || typeof value !== 'object') {
return '-'
}

// Handle array of relationships (many: true)
if (Array.isArray(value)) {
if (value.length === 0) return '-'
return value.map((item) => 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) : '-'
}
Loading
Loading