| name | react-relay-table |
| description | Use when creating a `*Nodes` component bound to a Relay fragment, wiring a page to one with `customizeColumns` / URL state / pagination, adding row selection and bulk actions, or setting up CSV export. Covers the query orchestrator + fragment split and `BAITable` conventions.
|
Relay Fragment Tables
Patterns extracted from BAIUserNodes, SessionNodes, VFolderNodes,
UserManagement.tsx, and AdminComputeSessionListPage.tsx. Relevant PRs:
FR-319 (#2932) intro NEO list, FR-465 (#3104) NEO prop, FR-448 (#3170) tab
counts, FR-466/883 (#3586) sort cycle, FR-966 (#3627) custom pagination slot,
FR-1315 (#4063) column visibility, FR-1804 (#4863) customizeColumns,
FR-1788 (#4820) BooleanTagWithFallBack.
See the create-relay-nodes-component skill for a generator that creates a
fresh *Nodes file skeleton; this skill documents the patterns that skeleton
should conform to and how the orchestrator page binds to it.
Activation Triggers
- Creating a new
*Nodes component bound to a GraphQL type
- Creating or modifying a page that embeds a
*Nodes table
- Adding/removing/reordering columns from a consumer via
customizeColumns
- Wiring table sort / filter / pagination to URL query params
- Column visibility settings (
tableSettings.columnOverrides)
- CSV export of table data
Also consult:
relay-patterns — general fragment architecture
react-url-state — the nuqs side of pagination / filter state
react-suspense-fetching — fetchKey / deferred variables
Gotchas
tablePaginationOption.current is 1-indexed, baiPaginationOption.offset is 0-indexed. useBAIPaginationOptionStateOnSearchParam converts via (current - 1) * pageSize — don't swap them.
@relay(plural: true) fragment needs an array of refs. Pass edges.map((e) => e.node) through filterOutNullAndUndefined, not the raw edge array.
@catch(to: RESULT) changes the return shape to { ok, value } | { ok: false, ... }. Consumers must check .ok before accessing .value.
customizeColumns(base) runs on every render. The passed function should be stable (React Compiler handles this under 'use memo') — don't do heavy work inside.
- Duplicate column
key silently breaks the column-visibility settings modal AND CSV export. Keys must be unique across base + customized columns.
fixed: true + required: true on the primary column means users can't hide it or scroll it off-screen. Apply only to the identifying column (email / name / id).
exportKey is required when dataIndex doesn't match the GraphQL field name. Without it, CSV export writes undefined for computed columns (e.g. project column exporting project_name).
availableXxxSorterKeys is the single source of truth. Adding a sortable column means updating the const array AND the column's sorter: isEnableSorter('key').
1. Two-file architecture: orchestrator vs *Nodes
Page/*Management component (orchestrator) `*Nodes` component (fragment)
├── useLazyLoadQuery + fetchKey + useDeferred ├── useFragment with @relay(plural: true)
├── nuqs URL state (order, filter, status) ├── Exports `UserNodeInList` type
├── useBAIPaginationOptionStateOnSearchParam ├── Owns `baseColumns`
├── Passes `usersFrgmt={edges.map(node)}` ├── Applies `customizeColumns?(base)`
├── Passes `customizeColumns={…}` ├── Renders <BAITable>
└── Passes pagination / selection props └── Emits `onChangeOrder`
The *Nodes component is dumb about fetching — it does not trigger refetches,
does not own the URL state, does not know about fetchKey. All of that lives on
the orchestrator. This is what makes customizeColumns composable.
2. Fragment Component Skeleton
import { BAITable, BAIColumnType, BAITableProps, filterOutNullAndUndefined }
from '..';
export type UserNodeInList = NonNullable<BAIUserNodesFragment$data[number]>;
const availableUserSorterKeys = [
'email', 'username', 'full_name', 'role', 'created_at',
] as const;
export const availableUserSorterValues = [
...availableUserSorterKeys,
...availableUserSorterKeys.map((k) => `-${k}` as const),
] as const;
const isEnableSorter = (key: string) =>
_.includes(availableUserSorterKeys, key);
interface BAIUserNodesProps extends Omit<
BAITableProps<UserNodeInList>,
'dataSource' | 'columns' | 'onChangeOrder'
> {
usersFrgmt: BAIUserNodesFragment$key;
customizeColumns?: (base: BAIColumnType<UserNodeInList>[]) =>
BAIColumnType<UserNodeInList>[];
disableSorter?: boolean;
onChangeOrder?: (order: (typeof availableUserSorterValues)[number] | null) =>
void;
}
const BAIUserNodes: React.FC<BAIUserNodesProps> = ({
usersFrgmt, customizeColumns, disableSorter, onChangeOrder, ...tableProps
}) => {
'use memo';
const { t } = useBAIi18n();
const users = useFragment(graphql`
fragment BAIUserNodesFragment on UserNode @relay(plural: true) {
id @required(action: NONE)
email @required(action: NONE)
username
// … fields the table might ever render
}
`, usersFrgmt);
const baseColumns = _.map(
filterOutEmpty<BAIColumnType<UserNodeInList>>([
{
key: 'email',
title: t('comp:UserNodes.Email'),
sorter: isEnableSorter('email'),
dataIndex: 'email',
fixed: true,
required: true,
render: (__, record) => <BAIText copyable>{record.email}</BAIText>,
},
]),
(column) => disableSorter ? _.omit(column, 'sorter') : column,
);
const allColumns = customizeColumns ? customizeColumns(baseColumns) : baseColumns;
return (
<BAITable
resizable
rowKey="id"
size="small"
dataSource={filterOutNullAndUndefined(users)}
columns={allColumns}
scroll={{ x: 'max-content' }}
onChangeOrder={(order) =>
onChangeOrder?.((order as (typeof availableUserSorterValues)[number]) || null)
}
{...tableProps}
/>
);
};
export default BAIUserNodes;
Key invariants
- Fragment is
@relay(plural: true) — usersFrgmt is an array of refs,
orchestrator passes edges.map((e) => e.node).
- All i18n keys under the
comp: namespace (comp:UserNodes.Email).
@required(action: NONE) on id and the human-key field (email/name).
fixed: true + required: true on the primary identifying column so
column settings UI can't hide it and it doesn't scroll horizontally.
- Sorter keys live in a
const as const array once, reused for prop typing.
- Order descending by default on
created_at if present: defaultSortOrder: 'descend'.
3. customizeColumns composition
Consumers override, not append. Array-based extraColumns is the old pattern
and should be migrated.
<BAIUserNodes extraColumns={[actionColumn]} />
<BAIUserNodes
usersFrgmt={…}
customizeColumns={(baseColumns) => [
{ ...baseColumns[0], render: renderEmailWithActions }, // wrap primary column
...baseColumns.slice(1), // keep the rest
]}
/>
customizeColumns={(base) =>
base
.filter((c) => c.key !== 'container_gids')
.map((c) => c.key === 'role' ? { ...c, width: 200 } : c)
}
When a consumer needs to inject an action column mid-table, use
baseColumns.slice(0, n) + new column + baseColumns.slice(n).
4. Orchestrator Wiring (full example)
const UserManagement: React.FC = () => {
'use memo';
const { t } = useTranslation();
const { token } = theme.useToken();
const [queryParams, setQueryParams] = useQueryStates({
filter: parseAsString.withDefault(''),
order: parseAsString,
status: parseAsStringLiteral(['active', 'inactive']).withDefault('active'),
});
const { baiPaginationOption, tablePaginationOption, setTablePaginationOption } =
useBAIPaginationOptionStateOnSearchParam({ current: 1, pageSize: 10 });
const [fetchKey, updateFetchKey] = useFetchKey();
const queryVariables = {
first: baiPaginationOption.limit,
offset: baiPaginationOption.offset,
filter: mergeFilterValues([queryParams.filter, statusFilter]),
order: queryParams.order || '-created_at',
};
const deferredQueryVariables = useDeferredValue(queryVariables);
const deferredFetchKey = useDeferredValue(fetchKey);
const { user_nodes } = useLazyLoadQuery<UserManagementQuery>(
graphql`
query UserManagementQuery(
$first: Int, $offset: Int, $filter: String, $order: String
) {
user_nodes(first: $first, offset: $offset, filter: $filter, order: $order) {
count
edges {
node {
id @required(action: THROW)
email @required(action: THROW)
...BAIUserNodesFragment
}
}
}
}
`,
deferredQueryVariables,
{
fetchKey: deferredFetchKey,
fetchPolicy:
deferredFetchKey === INITIAL_FETCH_KEY ? 'store-and-network' : 'network-only',
},
);
const [columnOverrides, setColumnOverrides] = useBAISettingUserState(
'table_column_overrides.UserManagement',
);
const { supportedFields, exportCSV } = useCSVExport('users');
return (
<BAIUserNodes
usersFrgmt={filterOutNullAndUndefined(_.map(user_nodes?.edges, 'node'))}
customizeColumns={(base) => [
{ ...base[0], render: renderEmailWithActions },
...base.slice(1),
]}
scroll={{ x: 'max-content' }}
pagination={{
pageSize: tablePaginationOption.pageSize,
total: user_nodes?.count || 0,
current: tablePaginationOption.current,
onChange: (current, pageSize) => setTablePaginationOption({ current, pageSize }),
}}
onChangeOrder={(next) => setQueryParams({ order: next })}
order={queryParams.order}
loading={
deferredQueryVariables !== queryVariables ||
deferredFetchKey !== fetchKey
}
tableSettings={{
columnOverrides,
onColumnOverridesChange: setColumnOverrides,
}}
exportSettings={!_.isEmpty(supportedFields) ? {
supportedFields,
onExport: async (keys) => { await exportCSV(keys, { status: [queryParams.status] }); },
} : undefined}
/>
);
};
Key points:
loading derives from deferred equality, not a manual useState(false).
- Default order lives in
queryVariables, not URL state — so ?order= stays
clean but the API still gets -created_at. Supports null → ascend → descend → null cycle.
fetchKey deferred separately so hitting refresh doesn't tear state twice.
- Column overrides persist in user settings via
useBAISettingUserState('table_column_overrides.<StableKey>').
5. Selection, Bulk Actions
const [selected, setSelected] = useState<UserNode[]>([]);
<BAIUserNodes
usersFrgmt={…}
rowSelection={{
type: 'checkbox',
selectedRowKeys: _.compact(selected.map((u) => u.node?.id)),
onChange: (keys) => {
const edges = _.compact(user_nodes?.edges);
setSelected(edges.filter((e) => e.node && keys.includes(e.node.id)));
},
}}
/>
{selected.length > 0 && (
<BAIFlex gap="xs">
<BAISelectionLabel count={selected.length} onClearSelection={() => setSelected([])} />
<BAIButton icon={<EditIcon />} onClick={…} />
</BAIFlex>
)}
6. Filter UX: BAIPropertyFilter
Use BAIPropertyFilter with filterOutEmpty to compose feature-flagged rules:
<BAIPropertyFilter
filterProperties={filterOutEmpty([
{ key: 'email', propertyLabel: t('general.E-Mail'), type: 'string' },
bailClient.supports('user-node-query-project-filter') && {
key: 'project_name', propertyLabel: t('general.Project'), type: 'string',
},
{ key: 'role', propertyLabel: t('credential.Role'), type: 'string',
strictSelection: true, defaultOperator: '==',
options: [{ label: 'superadmin', value: 'superadmin' }] },
])}
value={queryParams.filter || undefined}
onChange={(v) => setQueryParams({ filter: v || '' })}
/>
- Pass
undefined (not '') into value so the filter pill doesn't render for an empty filter.
- Use
mergeFilterValues([queryParams.filter, statusFilter, typeFilter]) to
compose URL filter with page-derived fragments (e.g. status tab).
7. Refresh Button
<BAIFetchKeyButton
loading={deferredFetchKey !== fetchKey}
value={fetchKey}
onChange={updateFetchKey}
/>
Its loading binds to the deferred/non-deferred comparison, so spinning state
matches the actual query.
8. Column Visibility / Export
tableSettings.columnOverrides + exportSettings plug into the column-settings
modal baked into BAITable (FR-1315, FR-1443). Persist overrides per-page
under a stable key:
useBAISettingUserState('table_column_overrides.AdminComputeSessionListPage')
useBAISettingUserState('table_column_overrides.UserManagement')
For CSV, declare exportKey on a column when its GraphQL field name differs
from dataIndex:
{ key: 'id', title: 'ID', exportKey: 'uuid', render: … }
{ key: 'project', title: t('comp:UserNodes.Project'), exportKey: 'project_name', render: … }
Related Skills
create-relay-nodes-component — generator that scaffolds a fresh *Nodes file
relay-patterns — general Relay fragment architecture
react-url-state — URL side of filter / order / pagination state
react-suspense-fetching — fetchKey + useDeferredValue on the orchestrator
react-async-actions — bulk-action buttons and row-level mutations
relay-infinite-scroll-select — select-based variant (not table-based)
9. Verification Checklist