From de059079d0d7283026f431cf3ab3f256dc894b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 21 Apr 2026 11:55:40 +0200 Subject: [PATCH] fix: render enum column cell labels from options prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataGridEnumColumn and DataGridEnumListColumn previously ignored the `options` prop when rendering cells — only the filter UI used it, so cells showed raw enum values. Cell renderers now receive `enumOptions` and `enumName` via ColumnRenderProps and resolve labels from the options record, falling back to the raw value. The bindx-ui variant additionally falls back to EnumOptionsFormatter context when `options` is absent but the field carries an `enumName` (exported via `enumScalar`). Extract `createColumnStaticRender` from `createColumn` so enum columns — which need a narrower generic signature than `ColumnComponent` can express — can attach the static render via `Object.assign`. --- packages/bindx-dataview/src/columns.tsx | 30 ++- packages/bindx-dataview/src/createColumn.ts | 36 ++- packages/bindx-dataview/src/index.ts | 1 + packages/bindx-react/src/index.ts | 1 + packages/bindx-ui/src/datagrid/columns.tsx | 89 ++++--- packages/bindx/src/index.ts | 2 +- .../dataGridEnumColumnOptions.test.tsx | 165 ++++++++++++ .../dataview/dataGridEnumColumnUi.test.tsx | 251 ++++++++++++++++++ .../react/dataview/dataGridFiltering.test.tsx | 4 +- 9 files changed, 527 insertions(+), 52 deletions(-) create mode 100644 tests/react/dataview/dataGridEnumColumnOptions.test.tsx create mode 100644 tests/react/dataview/dataGridEnumColumnUi.test.tsx diff --git a/packages/bindx-dataview/src/columns.tsx b/packages/bindx-dataview/src/columns.tsx index b378d8c..2ef4c46 100644 --- a/packages/bindx-dataview/src/columns.tsx +++ b/packages/bindx-dataview/src/columns.tsx @@ -13,7 +13,7 @@ import React, { ReactNode } from 'react' import type { FieldRef, HasOneRef, HasManyRef, FilterHandler, FilterArtifact, EntityAccessor, EnumFilterArtifact, EnumListFilterArtifact, SelectionMeta } from '@contember/bindx' import { SelectionScope } from '@contember/bindx' import { FIELD_REF_META, createCollectorProxy } from '@contember/bindx-react' -import { createColumn, type ColumnRenderProps } from './createColumn.js' +import { createColumn, createColumnStaticRender, type ColumnRenderProps } from './createColumn.js' import { accessField } from './columnTypes.js' import { createRelationColumn, hasOneCellConfig, hasManyCellConfig, type RelationColumnProps } from './createRelationColumn.jsx' import { @@ -104,9 +104,19 @@ function renderDateTimeDefault({ value }: ColumnRenderProps): Rea return date.toLocaleString() } -function renderEnumListDefault({ value }: ColumnRenderProps): React.ReactNode { +function renderEnumDefault({ value, enumOptions }: ColumnRenderProps): React.ReactNode { + if (value == null) return '' + return enumOptions?.[value] ?? value +} + +function renderEnumListDefault({ value, enumOptions }: ColumnRenderProps): React.ReactNode { if (!Array.isArray(value)) return '' - return value.join(', ') + return value.map((v, i) => ( + + {i > 0 ? ', ' : null} + {enumOptions?.[v] ?? v} + + )) } // ============================================================================ @@ -164,13 +174,15 @@ export const DataGridBooleanColumn = createColumn(booleanColumnDef, { renderCell: renderBooleanDefault, }) -export const DataGridEnumColumn = createColumn>(enumColumnDef, { - renderCell: renderScalarDefault, -}) as (props: DataGridEnumColumnProps) => ReactNode +export const DataGridEnumColumn = Object.assign( + (_props: DataGridEnumColumnProps): null => null, + { staticRender: createColumnStaticRender(enumColumnDef, { renderCell: renderEnumDefault }) }, +) -export const DataGridEnumListColumn = createColumn>(enumListColumnDef, { - renderCell: renderEnumListDefault, -}) as (props: DataGridEnumListColumnProps) => ReactNode +export const DataGridEnumListColumn = Object.assign( + (_props: DataGridEnumListColumnProps): null => null, + { staticRender: createColumnStaticRender(enumListColumnDef, { renderCell: renderEnumListDefault }) }, +) export const DataGridUuidColumn = createColumn(uuidColumnDef, { renderCell: renderScalarDefault, diff --git a/packages/bindx-dataview/src/createColumn.ts b/packages/bindx-dataview/src/createColumn.ts index 03bfe70..9cc6583 100644 --- a/packages/bindx-dataview/src/createColumn.ts +++ b/packages/bindx-dataview/src/createColumn.ts @@ -21,6 +21,8 @@ export interface ColumnRenderProps { readonly accessor: EntityAccessor readonly fieldRef: FieldRef | null readonly fieldName: string | null + readonly enumOptions: Readonly> | undefined + readonly enumName: string | undefined } export interface FilterRenderProps { @@ -58,15 +60,18 @@ export interface ColumnComponent { staticRender: (props: Record) => React.ReactNode } -export function createColumn( +/** + * Build the static-render function for a column type definition. Used both by + * {@link createColumn} and by column components that need a narrower generic + * signature than `ColumnComponent` can express (e.g. enum columns constrained + * to `T extends string`) — those define their own component and attach this + * static render via `Object.assign`. + */ +export function createColumnStaticRender( columnType: ColumnTypeDef, config: CreateColumnConfig, -): ColumnComponent { - function Column(_props: ColumnComponentProps & TExtraProps): null { - return null - } - - Column.staticRender = (props: Record): React.ReactNode => { +): (props: Record) => React.ReactNode { + return (props: Record): React.ReactNode => { const fieldRef = props['field'] as FieldRef | undefined const fieldName = fieldRef ? extractFieldName(fieldRef) : null const header = props['header'] as React.ReactNode | undefined @@ -74,9 +79,9 @@ export function createColumn) => React.ReactNode) | undefined const rawOptions = props['options'] as readonly string[] | Readonly> | undefined - const enumOptions = Array.isArray(rawOptions) - ? Object.fromEntries(rawOptions.map(v => [v, v])) as Readonly> - : rawOptions + const enumOptions: Readonly> | undefined = Array.isArray(rawOptions) + ? Object.fromEntries((rawOptions as readonly string[]).map(v => [v, v])) + : rawOptions as Readonly> | undefined const enumName = extractEnumName(fieldRef) const renderCell = children @@ -95,6 +100,8 @@ export function createColumn( + columnType: ColumnTypeDef, + config: CreateColumnConfig, +): ColumnComponent { + function Column(_props: ColumnComponentProps & TExtraProps): null { + return null + } + Column.staticRender = createColumnStaticRender(columnType, config) return Column as ColumnComponent } diff --git a/packages/bindx-dataview/src/index.ts b/packages/bindx-dataview/src/index.ts index 6c65e57..6bddba3 100644 --- a/packages/bindx-dataview/src/index.ts +++ b/packages/bindx-dataview/src/index.ts @@ -40,6 +40,7 @@ export { // createColumn factory (Layer 2: UI wrapping) export { createColumn, + createColumnStaticRender, type ColumnRenderProps, type FilterRenderProps, type CreateColumnConfig, diff --git a/packages/bindx-react/src/index.ts b/packages/bindx-react/src/index.ts index a21e1eb..db00130 100644 --- a/packages/bindx-react/src/index.ts +++ b/packages/bindx-react/src/index.ts @@ -140,6 +140,7 @@ export type { export { // Schema utilities scalar, + enumScalar, hasOne, hasMany, defineSchema, diff --git a/packages/bindx-ui/src/datagrid/columns.tsx b/packages/bindx-ui/src/datagrid/columns.tsx index caea9c7..16853bb 100644 --- a/packages/bindx-ui/src/datagrid/columns.tsx +++ b/packages/bindx-ui/src/datagrid/columns.tsx @@ -12,6 +12,7 @@ import React, { type ReactElement, type ReactNode } from 'react' import type { EntityAccessor, EntityDef, EnumFilterArtifact, EnumListFilterArtifact, FieldRef } from '@contember/bindx' import { createColumn, + createColumnStaticRender, createRelationColumn, hasOneCellConfig, hasManyCellConfig, @@ -99,9 +100,33 @@ function renderDateTimeDefault({ value }: ColumnRenderProps): Rea return date.toLocaleString() } -function renderEnumListDefault({ value }: ColumnRenderProps): React.ReactNode { +function EnumCellLabel({ value, enumOptions, enumName }: { + value: string + enumOptions: Readonly> | undefined + enumName: string | undefined +}): ReactNode { + const formatter = useEnumOptionsFormatter() + if (enumOptions?.[value] != null) return enumOptions[value] + if (enumName) { + const resolved = formatter(enumName) + if (resolved[value] != null) return resolved[value] + } + return value +} + +function renderEnumDefault({ value, enumOptions, enumName }: ColumnRenderProps): ReactNode { + if (value == null) return '' + return +} + +function renderEnumListDefault({ value, enumOptions, enumName }: ColumnRenderProps): ReactNode { if (!Array.isArray(value)) return '' - return value.join(', ') + return value.map((v, i) => ( + + {i > 0 ? ', ' : null} + + + )) } function ColumnEnumFilterControls(): ReactElement { @@ -152,41 +177,45 @@ export const DataGridBooleanColumn = createColumn(booleanColumnDef, { renderFilter: () => , }) -const _DataGridEnumColumn = createColumn(enumColumnDef, { - renderCell: renderScalarDefault, - renderFilter: () => , -}) - type ExtractEnum = F extends FieldRef ? Exclude & string : string -export const DataGridEnumColumn = >(props: { - field: F - header?: ReactNode - sortable?: boolean - filter?: boolean - children?: (value: ExtractEnum | null, accessor: EntityAccessor) => ReactNode - options?: { [K in ExtractEnum]?: ReactNode } -}): ReactNode => null -DataGridEnumColumn.staticRender = _DataGridEnumColumn.staticRender - -const _DataGridEnumListColumn = createColumn(enumListColumnDef, { - renderCell: renderEnumListDefault, - renderFilter: () => , -}) +export const DataGridEnumColumn = Object.assign( + >(_props: { + field: F + header?: ReactNode + sortable?: boolean + filter?: boolean + children?: (value: ExtractEnum | null, accessor: EntityAccessor) => ReactNode + options?: { [K in ExtractEnum]?: ReactNode } + }): ReactNode => null, + { + staticRender: createColumnStaticRender(enumColumnDef, { + renderCell: renderEnumDefault, + renderFilter: () => , + }), + }, +) type ExtractEnumList = F extends FieldRef ? T extends readonly (infer U)[] | null ? U & string : string : string -export const DataGridEnumListColumn = >(props: { - field: F - header?: ReactNode - sortable?: boolean - filter?: boolean - children?: (value: ExtractEnumList[] | null, accessor: EntityAccessor) => ReactNode - options?: { [K in ExtractEnumList]?: ReactNode } -}): ReactNode => null -DataGridEnumListColumn.staticRender = _DataGridEnumListColumn.staticRender +export const DataGridEnumListColumn = Object.assign( + >(_props: { + field: F + header?: ReactNode + sortable?: boolean + filter?: boolean + children?: (value: ExtractEnumList[] | null, accessor: EntityAccessor) => ReactNode + options?: { [K in ExtractEnumList]?: ReactNode } + }): ReactNode => null, + { + staticRender: createColumnStaticRender(enumListColumnDef, { + renderCell: renderEnumListDefault, + renderFilter: () => , + }), + }, +) export const DataGridUuidColumn = createColumn(uuidColumnDef, { renderCell: renderScalarDefault, diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 0751552..64112f7 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -126,7 +126,7 @@ export type { // ============================================================================ // Schema utilities -export { scalar, hasOne, hasMany, defineSchema, entityDef, roleEntityDef, SchemaRegistry, ContemberSchema, SchemaLoader } from './schema/index.js' +export { scalar, enumScalar, hasOne, hasMany, defineSchema, entityDef, roleEntityDef, SchemaRegistry, ContemberSchema, SchemaLoader } from './schema/index.js' // Role types export type { diff --git a/tests/react/dataview/dataGridEnumColumnOptions.test.tsx b/tests/react/dataview/dataGridEnumColumnOptions.test.tsx new file mode 100644 index 0000000..099709d --- /dev/null +++ b/tests/react/dataview/dataGridEnumColumnOptions.test.tsx @@ -0,0 +1,165 @@ +/** + * Tests for DataGridEnumColumn / DataGridEnumListColumn — `options` prop + * as a label record should be used when rendering cells, not only filters. + */ +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + MockAdapter, + defineSchema, + entityDef, + scalar, +} from '@contember/bindx-react' +import { + DataGrid, + DataGridEnumColumn, + DataGridEnumListColumn, +} from '@contember/bindx-dataview' +import type { FieldRef } from '@contember/bindx' +import { TestTable, queryByTestId, getCellText } from './helpers.js' + +afterEach(() => { + cleanup() +}) + +interface Article { + id: string + status: string + tags: readonly string[] +} + +interface TestSchema { + Article: Article +} + +const localSchema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + status: scalar(), + tags: scalar(), + }, + }, + }, +}) + +const schema = { + Article: entityDef
('Article'), +} as const + +function createMockData(): Record>> { + return { + Article: { + 'a1': { id: 'a1', status: 'draft', tags: ['news', 'featured'] }, + 'a2': { id: 'a2', status: 'published', tags: ['featured'] }, + 'a3': { id: 'a3', status: 'archived', tags: [] }, + }, + } +} + +describe('DataGridEnumColumn options → cell labels', () => { + test('renders label from options record instead of raw enum value', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const statusLabels = { + draft: 'Koncept', + published: 'Publikováno', + archived: 'Archivováno', + } + + const { container } = render( + + + {it => ( + <> + + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + expect(getCellText(container, 0, 'status')).toBe('Koncept') + expect(getCellText(container, 1, 'status')).toBe('Publikováno') + expect(getCellText(container, 2, 'status')).toBe('Archivováno') + }) + + test('falls back to raw value when options is an array (no label mapping)', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + const { container } = render( + + + {it => ( + <> + + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + expect(getCellText(container, 0, 'status')).toBe('draft') + expect(getCellText(container, 1, 'status')).toBe('published') + }) +}) + +describe('DataGridEnumListColumn options → cell labels', () => { + test('renders labels for list values from options record', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const tagLabels = { + news: 'Novinky', + featured: 'Doporučené', + } + + const { container } = render( + + + {it => ( + <> + } + header="Tags" + options={tagLabels} + /> + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + const row0 = getCellText(container, 0, 'tags') + expect(row0).toContain('Novinky') + expect(row0).toContain('Doporučené') + expect(row0).not.toContain('news') + expect(row0).not.toContain('featured') + + expect(getCellText(container, 1, 'tags')).toBe('Doporučené') + }) +}) diff --git a/tests/react/dataview/dataGridEnumColumnUi.test.tsx b/tests/react/dataview/dataGridEnumColumnUi.test.tsx new file mode 100644 index 0000000..a8a40ac --- /dev/null +++ b/tests/react/dataview/dataGridEnumColumnUi.test.tsx @@ -0,0 +1,251 @@ +/** + * Tests for the styled bindx-ui DataGridEnumColumn — `options` record should + * render as cell label, with fallback to the EnumOptionsFormatter context when + * `options` is absent but the field carries an `enumName`. + */ +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + MockAdapter, + defineSchema, + entityDef, + scalar, + enumScalar, +} from '@contember/bindx-react' +import { DataGrid } from '@contember/bindx-dataview' +import { + DataGridEnumColumn, + DataGridEnumListColumn, + EnumOptionsFormatterProvider, +} from '@contember/bindx-ui' +import type { FieldRef } from '@contember/bindx' +import { TestTable, queryByTestId, getCellText } from './helpers.js' + +afterEach(() => { + cleanup() +}) + +interface Article { + id: string + status: string + tags: readonly string[] +} + +interface TestSchema { + Article: Article +} + +const localSchema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + status: scalar(), + tags: scalar(), + }, + }, + }, +}) + +const schema = { + Article: entityDef
('Article'), +} as const + +function createMockData(): Record>> { + return { + Article: { + 'a1': { id: 'a1', status: 'draft', tags: ['news', 'featured'] }, + 'a2': { id: 'a2', status: 'published', tags: ['featured'] }, + }, + } +} + +describe('bindx-ui DataGridEnumColumn', () => { + test('renders labels from options record in cell', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + const { container } = render( + + + {it => ( + <> + + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + expect(getCellText(container, 0, 'status')).toBe('Koncept') + expect(getCellText(container, 1, 'status')).toBe('Publikováno') + }) + + test('children override wins over options', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + const { container } = render( + + + {it => ( + <> + + {(value) => !{value}!} + + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + expect(getCellText(container, 0, 'status')).toBe('!draft!') + expect(getCellText(container, 1, 'status')).toBe('!published!') + }) +}) + +describe('bindx-ui DataGridEnumColumn — EnumOptionsFormatter fallback', () => { + interface EnumArticle { + id: string + status: 'draft' | 'published' + } + + const enumLocalSchema = defineSchema<{ Article: EnumArticle }>({ + entities: { + Article: { + fields: { + id: scalar(), + status: enumScalar('ArticleStatus', ['draft', 'published']), + }, + }, + }, + }) + + const enumEntity = entityDef('Article') + + test('uses EnumOptionsFormatterProvider when options prop is absent', async () => { + const adapter = new MockAdapter({ + Article: { + 'a1': { id: 'a1', status: 'draft' }, + 'a2': { id: 'a2', status: 'published' }, + }, + }, { delay: 0 }) + + const formatter = (enumName: string): Record => { + if (enumName === 'ArticleStatus') { + return { draft: 'Koncept (ctx)', published: 'Publikováno (ctx)' } + } + return {} + } + + const { container } = render( + + + + {it => ( + <> + + + + )} + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + expect(getCellText(container, 0, 'status')).toBe('Koncept (ctx)') + expect(getCellText(container, 1, 'status')).toBe('Publikováno (ctx)') + }) + + test('explicit options prop wins over EnumOptionsFormatterProvider', async () => { + const adapter = new MockAdapter({ + Article: { + 'a1': { id: 'a1', status: 'draft' }, + }, + }, { delay: 0 }) + + const formatter = (): Record => ({ draft: 'FROM-CTX' }) + + const { container } = render( + + + + {it => ( + <> + + + + )} + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + expect(getCellText(container, 0, 'status')).toBe('FROM-PROP') + }) +}) + +describe('bindx-ui DataGridEnumListColumn', () => { + test('renders labels from options record for each value', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + const { container } = render( + + + {it => ( + <> + } + header="Tags" + options={{ news: 'Novinky', featured: 'Doporučené' }} + /> + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + const row0 = getCellText(container, 0, 'tags') + expect(row0).toContain('Novinky') + expect(row0).toContain('Doporučené') + expect(row0).not.toContain('news') + + expect(getCellText(container, 1, 'tags')).toBe('Doporučené') + }) +}) diff --git a/tests/react/dataview/dataGridFiltering.test.tsx b/tests/react/dataview/dataGridFiltering.test.tsx index 1063b59..8261f4c 100644 --- a/tests/react/dataview/dataGridFiltering.test.tsx +++ b/tests/react/dataview/dataGridFiltering.test.tsx @@ -310,8 +310,8 @@ describe('DataGrid column types', () => { expect(queryByTestId(container, 'datagrid-loading')).toBeNull() }) - expect(getByTestId(container, 'datagrid-row-0-col-status').textContent).toBe('published') - expect(getByTestId(container, 'datagrid-row-1-col-status').textContent).toBe('draft') + expect(getByTestId(container, 'datagrid-row-0-col-status').textContent).toBe('Published') + expect(getByTestId(container, 'datagrid-row-1-col-status').textContent).toBe('Draft') }) test('custom cell renderer works on number column', async () => {