Merge pull request #1167 from nowcommunity/fix/79-table-data-cy-selectors

Add stable table data-cy selectors
This commit is contained in:
Kari Lintulaakso
2026-05-25 11:34:51 +03:00
committed by GitHub
4 changed files with 132 additions and 39 deletions
@@ -6,6 +6,18 @@ import { applyDefaultSpeciesOrdering, DetailTabTable, hasActiveSortingInSearch }
const tableViewMock = jest.fn<(props: Record<string, unknown>) => JSX.Element>()
const useMaterialReactTableMock = jest.fn<(options: Record<string, unknown>) => Record<string, unknown>>()
type CapturedMrtOptions = {
muiTableProps?: {
'data-cy'?: string
}
muiTableHeadCellProps?: (args: { column: { id: string } }) => {
'data-cy'?: string
}
muiTableBodyCellProps?: (args: { cell: { column: { id: string } } }) => {
'data-cy'?: string
}
}
jest.mock('@/components/TableView/TableView', () => ({
TableView: (props: Record<string, unknown>) => tableViewMock(props),
}))
@@ -64,6 +76,7 @@ describe('DetailTabTable', () => {
columns={[{ accessorKey: 'name', header: 'Name' }]}
enableSorting={false}
enableColumnActions={false}
tableName="Editable table"
/>
)
@@ -75,6 +88,11 @@ describe('DetailTabTable', () => {
enablePagination: true,
})
)
const options = useMaterialReactTableMock.mock.calls[0]?.[0] as CapturedMrtOptions | undefined
expect(options?.muiTableProps?.['data-cy']).toEqual('Editable-table-table')
expect(options?.muiTableHeadCellProps?.({ column: { id: 'name' } })['data-cy']).toEqual('table-header-name')
expect(options?.muiTableBodyCellProps?.({ cell: { column: { id: 'name' } } })['data-cy']).toEqual('table-cell-name')
})
})
@@ -20,6 +20,18 @@ const defaultEditPagination: MRT_PaginationState = { pageIndex: 0, pageSize: 15
const speciesDefaultSortFields = ['order_name', 'family_name', 'genus_name', 'species_name'] as const
const toDataCyPart = (value: unknown): string => {
const normalized = String(value)
.trim()
.replace(/[^A-Za-z0-9_-]+/g, '-')
return normalized || 'unknown'
}
const withDataCy = <TProps extends object>(props: TProps, dataCy: string): TProps & { 'data-cy': string } => ({
...props,
'data-cy': dataCy,
})
const getNestedValue = (row: MRT_RowData, path: string): unknown => {
return path.split('.').reduce<unknown>((acc, segment) => {
if (typeof acc !== 'object' || acc === null || !(segment in acc)) {
@@ -213,6 +225,9 @@ const DetailTabEditableTable = <T extends MRT_RowData>({
const table = useMaterialReactTable({
columns,
data,
muiTableProps: withDataCy({ sx: undefined }, `${toDataCyPart(tableName)}-table`),
muiTableHeadCellProps: ({ column }) => withDataCy({ sx: undefined }, `table-header-${toDataCyPart(column.id)}`),
muiTableBodyCellProps: ({ cell }) => withDataCy({ sx: undefined }, `table-cell-${toDataCyPart(cell.column.id)}`),
enableTopToolbar: false,
enableColumnActions,
enableSorting,
+60 -39
View File
@@ -64,6 +64,18 @@ const toTableCellTitle = (value: unknown): string | undefined => {
return
}
const toDataCyPart = (value: unknown): string => {
const normalized = String(value)
.trim()
.replace(/[^A-Za-z0-9_-]+/g, '-')
return normalized || 'unknown'
}
const withDataCy = <TProps extends object>(props: TProps, dataCy: string): TProps & { 'data-cy': string } => ({
...props,
'data-cy': dataCy,
})
const sanitizeColumnFilters = (filters: MRT_ColumnFiltersState): MRT_ColumnFiltersState => {
return filters.filter(filter => !isEmptyFilterValue(filter.value))
}
@@ -438,41 +450,52 @@ export const TableView = <T extends MRT_RowData>({
const table = useMaterialReactTable({
columns: preparedColumns,
data: data || [],
muiTableProps: {
sx: {
tableLayout: 'fixed',
},
},
muiTableHeadCellProps: {
sx: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
'& .MuiTableSortLabel-root': {
// Reserve space for the sort icon so toggling sorting doesn't shift column widths.
position: 'relative',
paddingRight: '1.25em',
},
'& .MuiTableSortLabel-icon': {
// Keep the icon from affecting layout when it appears.
position: 'absolute',
right: 0,
margin: 0,
muiTableProps: withDataCy(
{
sx: {
tableLayout: 'fixed',
},
},
},
muiTableBodyCellProps: ({ cell }) => ({
...(cell.column.id === 'mrt-row-actions' ? {} : { title: toTableCellTitle(cell.getValue()) }),
sx: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
'&.row-actions-cell': {
overflow: 'visible',
textOverflow: 'clip',
`${toDataCyPart(title)}-table`
),
muiTableHeadCellProps: ({ column }) =>
withDataCy(
{
sx: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
'& .MuiTableSortLabel-root': {
// Reserve space for the sort icon so toggling sorting doesn't shift column widths.
position: 'relative',
paddingRight: '1.25em',
},
'& .MuiTableSortLabel-icon': {
// Keep the icon from affecting layout when it appears.
position: 'absolute',
right: 0,
margin: 0,
},
},
},
},
}),
`table-header-${toDataCyPart(column.id)}`
),
muiTableBodyCellProps: ({ cell }) =>
withDataCy(
{
...(cell.column.id === 'mrt-row-actions' ? {} : { title: toTableCellTitle(cell.getValue()) }),
sx: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
'&.row-actions-cell': {
overflow: 'visible',
textOverflow: 'clip',
},
},
},
`table-cell-${toDataCyPart(cell.column.id)}`
),
muiTableBodyRowProps: clickableRows || selectorFn ? muiTableBodyRowProps : undefined,
muiTableContainerProps: tableContainerMaxHeight
? {
@@ -565,13 +588,11 @@ export const TableView = <T extends MRT_RowData>({
'mrt-row-actions': {
size: 36,
header: '',
muiTableHeadCellProps: {
align: 'right',
},
muiTableBodyCellProps: {
align: 'right',
className: 'row-actions-cell',
},
muiTableHeadCellProps: withDataCy({ align: 'right' as const }, 'table-header-row-actions'),
muiTableBodyCellProps: withDataCy(
{ align: 'right' as const, className: 'row-actions-cell' },
'table-cell-row-actions'
),
},
},
positionActionsColumn: 'last',
@@ -12,6 +12,22 @@ import { NotificationContextProvider } from '@/components/Notification'
type CapturedMrtOptions = {
enableColumnFilterModes?: boolean
columnFilterModeOptions?: string[]
muiTableProps?: {
'data-cy'?: string
}
muiTableHeadCellProps?: (args: { column: { id: string } }) => {
'data-cy'?: string
}
muiTableBodyCellProps?: (args: { cell: { column: { id: string }; getValue: () => unknown } }) => {
'data-cy'?: string
title?: string
}
displayColumnDefOptions?: {
'mrt-row-actions'?: {
muiTableHeadCellProps?: { 'data-cy'?: string }
muiTableBodyCellProps?: { 'data-cy'?: string }
}
}
columns: Array<{
accessorKey?: string
enableColumnFilterModes?: boolean
@@ -94,4 +110,27 @@ describe('TableView filter modes', () => {
expect(options.initialState?.columnFilterFns?.title).toEqual('equals')
expect(options.initialState?.columnFilterFns?.year).toBeUndefined()
})
it('adds stable data-cy selectors to reusable table elements', () => {
renderTable([
{ accessorKey: 'rid', header: 'Id' },
{ accessorKey: 'title', header: 'Title' },
])
const options = getLastMaterialReactTableOptions<CapturedMrtOptions>()
expect(options).toBeTruthy()
if (!options) return
expect(options.muiTableProps?.['data-cy']).toEqual('Test-table')
expect(options.muiTableHeadCellProps?.({ column: { id: 'title' } })['data-cy']).toEqual('table-header-title')
expect(
options.muiTableBodyCellProps?.({ cell: { column: { id: 'title' }, getValue: () => 'Example' } })['data-cy']
).toEqual('table-cell-title')
expect(options.displayColumnDefOptions?.['mrt-row-actions']?.muiTableHeadCellProps?.['data-cy']).toEqual(
'table-header-row-actions'
)
expect(options.displayColumnDefOptions?.['mrt-row-actions']?.muiTableBodyCellProps?.['data-cy']).toEqual(
'table-cell-row-actions'
)
})
})