.claude/skills/create-relay-nodes-component/SKILL.md
Generate Relay-based Nodes components with BAITable integration following established patterns (BAIUserNodes, SessionNodes, BAISchedulingHistoryNodes, BAIRouteNodes). Automatically creates component file with GraphQL fragment, type definitions, column configurations, and customization patterns. Minimal user input required - just provide GraphQL type name and the skill generates a complete starting template.
npx skillsauth add lablup/backend.ai-webui create-relay-nodes-componentInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill generates reusable Relay-based Nodes components that:
customizeColumns patterndisableSorter toggle and table features out of the boxActivate this skill when users ask to:
1. GraphQL Type Name (Required)
UserNode, ComputeSessionNode, SessionSchedulingHistory2. Component Location (Optional - has smart defaults)
*Node types: packages/backend.ai-ui/src/components/packages/backend.ai-ui/src/components/fragments/The skill automatically determines:
UserNode → BAIUserNodesUserNode → UserusersFrgmtUse AskUserQuestion to get the GraphQL type name:
{
questions: [
{
question: "What is the GraphQL type name for this component?",
header: "GraphQL Type",
options: [
{
label: "UserNode",
description: "For User entity list"
},
{
label: "ComputeSessionNode",
description: "For Session entity list"
},
{
label: "SessionSchedulingHistory",
description: "For connection-type entities"
},
{
label: "Other",
description: "Specify custom GraphQL type name"
}
],
multiSelect: false
}
]
}
If user selects "Other", prompt for the custom type name.
Ask about location only if needed:
{
questions: [
{
question: "Where should the component be created?",
header: "Location",
options: [
{
label: "packages/backend.ai-ui/src/components/ (Recommended for Node types)",
description: "Default location for *Node components"
},
{
label: "packages/backend.ai-ui/src/components/fragments/",
description: "For *Connection or fragment-specific components"
},
{
label: "react/src/components/",
description: "For React-specific (non-shared) components"
}
],
multiSelect: false
}
]
}
Based on GraphQL type, auto-generate:
// Example transformations:
// UserNode → BAIUserNodes, User, usersFrgmt
// ComputeSessionNode → BAIComputeSessionNodes, Session, sessionsFrgmt
// Route → BAIRouteNodes, Route, routesFrgmt
function generateComponentDetails(graphqlType: string) {
// Remove "Node" or "Connection" suffix
const cleanName = graphqlType
.replace(/Node$/, '')
.replace(/Connection$/, '');
// Generate component name with BAI prefix and Nodes suffix
const componentName = `BAI${cleanName}Nodes`;
// Extract entity name (e.g., "User" from "UserNode")
const entityName = cleanName.replace(/^.*(?=[A-Z])/, '');
// Generate fragment prop name (lowercase + Frgmt suffix)
const entityLowercase = entityName.toLowerCase();
const fragmentProp = `${entityLowercase}${entityLowercase.endsWith('s') ? '' : 's'}Frgmt`;
return {
componentName,
entityName,
fragmentProp,
graphqlType
};
}
Create complete TypeScript file with this structure:
CRITICAL PATTERNS (must follow):
Column keys must be camelCase — 'createdAt', 'status', NOT 'CREATED_AT'.
The query orchestrator uses convertToOrderBy() from react/src/helper/index.tsx
to convert camelCase to { field: 'CREATED_AT', direction: 'ASC' } for Strawberry queries.
Use filterOutEmpty + _.map with disableSorter — not satisfies.
This enables runtime sorter toggling.
Never hardcode pagination={false} — let the consumer control pagination via ...tableProps.
Callback props for domain-specific interactions — use onClickXxx callbacks
instead of embedding navigation/modal logic. The consumer wires these up.
Use 'use memo' directive at the top of the component body for React Compiler optimization.
import {
{ComponentName}Fragment$data,
{ComponentName}Fragment$key,
} from '../../__generated__/{ComponentName}Fragment.graphql';
import { filterOutEmpty, filterOutNullAndUndefined } from '../../helper';
import {
BAIColumnsType,
BAIColumnType,
BAITable,
BAITableProps,
} from '../Table';
import * as _ from 'lodash-es';
import { useTranslation } from 'react-i18next';
import { graphql, useFragment } from 'react-relay';
// =============================================================================
// Type Definitions
// =============================================================================
export type {Entity}InList = NonNullable<{ComponentName}Fragment$data[number]>;
// Sorter keys must be camelCase — convertToOrderBy() handles UPPER_SNAKE_CASE conversion
const available{Entity}SorterKeys = [
'createdAt',
// TODO: Add more sortable fields in camelCase
] as const;
export const available{Entity}SorterValues = [
...available{Entity}SorterKeys,
...available{Entity}SorterKeys.map((key) => `-${key}` as const),
] as const;
const isEnableSorter = (key: string) => {
return _.includes(available{Entity}SorterKeys, key);
};
// =============================================================================
// Props Interface
// =============================================================================
export interface {ComponentName}Props
extends Omit<
BAITableProps<{Entity}InList>,
'dataSource' | 'columns' | 'onChangeOrder'
> {
{fragmentProp}: {ComponentName}Fragment$key;
customizeColumns?: (
baseColumns: BAIColumnsType<{Entity}InList>,
) => BAIColumnsType<{Entity}InList>;
disableSorter?: boolean;
onChangeOrder?: (
order: (typeof available{Entity}SorterValues)[number] | null,
) => void;
// TODO: Add domain-specific callback props (e.g., onClickSessionId, onClickErrorData)
}
// =============================================================================
// Component
// =============================================================================
const {ComponentName} = ({
{fragmentProp},
customizeColumns,
disableSorter,
onChangeOrder,
...tableProps
}: {ComponentName}Props) => {
'use memo';
const { t } = useTranslation();
// TODO: Customize fragment fields based on your GraphQL schema
const data = useFragment<{ComponentName}Fragment$key>(
graphql`
fragment {ComponentName}Fragment on {GraphQLType} @relay(plural: true) {
id @required(action: NONE)
# TODO: Add fields you need from the GraphQL type
}
`,
{fragmentProp},
);
// =============================================================================
// Column Definitions — keys must be camelCase
// =============================================================================
const baseColumns = _.map(
filterOutEmpty<BAIColumnType<{Entity}InList>>([
{
key: 'id',
title: 'ID',
dataIndex: 'id',
fixed: 'left',
},
// TODO: Add more columns with camelCase keys
// {
// key: 'createdAt',
// title: t('comp:{ComponentName}.CreatedAt'),
// dataIndex: 'createdAt',
// sorter: isEnableSorter('createdAt'),
// render: (value) => dayjs(value).format('ll LT'),
// },
]),
(column) => {
return disableSorter ? _.omit(column, 'sorter') : column;
},
);
const allColumns = customizeColumns
? customizeColumns(baseColumns)
: baseColumns;
return (
<BAITable
rowKey={'id'}
dataSource={filterOutNullAndUndefined(data)}
columns={allColumns}
scroll={{ x: 'max-content' }}
onChangeOrder={(order) => {
onChangeOrder?.(
(order as (typeof available{Entity}SorterValues)[number]) || null,
);
}}
{...tableProps}
/>
);
};
export default {ComponentName};
When integrating into a page, follow this pattern for pagination/order that avoids full-page suspense on pagination changes:
// In the query orchestrator (page component):
import { useDeferredValue, useState } from 'react';
import { convertToOrderBy } from '../helper';
// Simple state — no need for useBAIPaginationOptionState unless URL persistence is needed
const [routePagination, setRoutePagination] = useState({ current: 1, pageSize: 10 });
const [routeOrder, setRouteOrder] = useState<string | null>(null);
// useDeferredValue keeps previous UI visible while new data loads
const deferredPagination = useDeferredValue(routePagination);
const deferredOrder = useDeferredValue(routeOrder);
// In query variables:
const { data } = useLazyLoadQuery(query, {
// ... other vars
routeFirst: deferredPagination.pageSize,
routeOffset: (deferredPagination.current - 1) * deferredPagination.pageSize,
// convertToOrderBy converts camelCase 'createdAt' → { field: 'CREATED_AT', direction: 'ASC' }
routeOrderBy: convertToOrderBy(deferredOrder) ?? undefined,
});
// In JSX — pagination.onChange wires directly to state setter:
<BAIRouteNodes
routesFrgmt={...}
order={routeOrder}
onChangeOrder={setRouteOrder}
pagination={{
...routePagination,
total: data?.routes?.count,
showSizeChanger: true,
onChange: (page, pageSize) => {
setRoutePagination({ current: page, pageSize });
},
}}
/>
Key points:
useDeferredValue wraps pagination/order state so React shows stale data while loadingconvertToOrderBy(camelCaseString) converts to { field: 'UPPER_SNAKE', direction } for Strawberrypagination is passed via ...tableProps — never hardcode pagination={false} in the Nodes component@skipOnClient(if: $skip) for feature-gated fieldsAfter generation, show comprehensive next steps:
Component generated successfully!
**Generated File:**
`{full_path_to_generated_file}`
**Next Steps:**
1. **Run Relay Compiler** to generate fragment types:
```bash
pnpm run relay
```
2. **Customize GraphQL Fragment:**
- Add fields you need from {GraphQLType}
- Consider performance: only request fields you'll display
3. **Define Table Columns:**
- Customize the `baseColumns` array
- Use **camelCase** keys that match fragment field names
- Add callback props (`onClickXxx`) for interactive columns
4. **Update Sortable Fields:**
- Edit `available{Entity}SorterKeys` with camelCase field names
- Only include fields that support sorting in your API
5. **Add Internationalization:**
- Add translation keys to locale files
6. **Export from barrel:**
- Add export to the appropriate `index.ts`
7. **Verify:**
```bash
bash scripts/verify.sh
```
Generated components follow the Relay Fragment Architecture:
┌─────────────────────────────────────┐
│ Query Orchestrator Component │
│ - useLazyLoadQuery │
│ - useState for pagination/order │
│ - useDeferredValue for smoothness │
│ - convertToOrderBy for Strawberry │
│ - Passes fragment refs │
└───────────────┬─────────────────────┘
│ fragment ref
▼
┌─────────────────────────────────────┐
│ Nodes Component (Generated) │
│ - useFragment │
│ - Receives fragment ref as prop │
│ - baseColumns + customizeColumns │
│ - disableSorter toggle │
│ - onClickXxx callback props │
│ - Renders BAITable │
└─────────────────────────────────────┘
Benefits:
customizeColumns patterncustomizeColumns={(baseColumns) => [
...baseColumns,
{
key: 'actions',
title: 'Actions',
fixed: 'right',
render: (__, record) => (
<BAIButton size="small" onClick={() => handleEdit(record)}>
Edit
</BAIButton>
),
},
]}
customizeColumns={(baseColumns) =>
baseColumns.filter((col) => col.key !== 'unwanted_column')
}
customizeColumns={(baseColumns) => {
const nameCol = baseColumns.find((col) => col.key === 'name');
const others = baseColumns.filter((col) => col.key !== 'name');
return nameCol ? [nameCol, ...others] : others;
}}
Column Keys
convertToOrderBy() handles conversion to UPPER_SNAKE_CASE for Strawberry OrderBy inputsBAITable order matchingFragment Fields
@required(action: NONE) for critical fieldsPagination
pagination={false} in the Nodes component...tablePropsuseDeferredValue in the query orchestrator for smooth transitionsSorting
disableSorter prop to conditionally disable all sortersfilterOutEmpty + _.map pattern handles sorter removalavailableSorterKeys in sync with API capabilitiesCallback Props
onClickXxx callbacks for domain-specific interactions (navigation, modals)Type Safety
$key and $data typesReact Compiler
'use memo' directive at the top of the component body| File | Purpose |
|------|---------|
| BAISchedulingHistoryNodes.tsx | Template with customizeColumns, disableSorter, expandable rows |
| BAISessionHistorySubStepNodes.tsx | Minimal template with customizeColumns |
| BAIRouteNodes.tsx | Template with callback props (onClickSessionId, onClickErrorData) |
| BAIAgentTable.tsx | Complex example with many columns |
Location: packages/backend.ai-ui/src/components/fragments/
pnpm run relaycustomizeColumns pattern for flexibilitybash scripts/verify.sh to validate Relay, Lint, Format, and TypeScriptdevelopment
Find WebUI dev server address and Backend.AI API endpoint/credentials for testing. Trigger on: "which server", "connection info", "login credentials", "dev server URL", "API endpoint", "where to connect", "how to login", "test server", or when needing to interact with the running WebUI (screenshots, live checks, E2E).
tools
GraphQL/Relay integration patterns for Backend.AI WebUI React components. Covers useLazyLoadQuery, useFragment, useRefetchableFragment, fragment architecture (query orchestrator + fragment component), naming conventions, modern directives (@required, @alias), client directives (@since, @deprecatedSince, @skipOnClient), and query optimization.
data-ai
Create Relay-based infinite scroll select components extending BAISelect. Supports name-based values (usePaginationFragment) and id-based values (useLazyLoadQuery + useLazyPaginatedQuery) with search, optimistic updates, and multiple selection modes.
development
# record-e2e-gif Skill Record Playwright e2e tests as GIF animations, one GIF per test case. ## Prerequisites - `ffmpeg` must be installed (`/opt/homebrew/bin/ffmpeg` on macOS) - Playwright dev server must be running. The endpoint is read from `e2e/envs/.env.playwright` (`E2E_WEBUI_ENDPOINT`). Do NOT hardcode the port — it varies per environment. ## How It Works 1. Run specified Playwright tests with `--video=on` (Playwright saves `.webm` per test in `test-results/`) 2. For each `.webm` fil