Architectural patterns and constraints that AI agents cannot reliably infer from reading the source code. Supplements AGENTS.md. When uncertain about a pattern, imitate the reference implementation cited — not this prose.
All components/ paths in this document are relative to
libs/ui-components/src/ unless stated otherwise.
These are caught by no-restricted-imports but agents write the wrong code
first. Avoid the round-trip.
| Never import | Use instead |
|---|---|
Form from @patternfly/react-core |
FlightCtlForm from components/form/FlightCtlForm |
WizardFooterWrapper / WizardFooter from @patternfly/… |
FlightCtlWizardFooter from components/common/… |
Drawer / DrawerPanelContent from @patternfly/… |
FlightCtlPageDrawer from components/common/… |
Modal from @patternfly/react-core |
FlightCtlModal from components/common/FlightCtlModal |
useTranslation from react-i18next |
useTranslation from @flightctl/ui-components/hooks/… |
@patternfly/react-icons (barrel import) |
@patternfly/react-icons/dist/js/icons/<icon-name> |
@patternfly/react-tokens (barrel import) |
@patternfly/react-tokens/dist/js/<token-name> |
lodash (barrel import) |
lodash/<function> (e.g. lodash/debounce) |
Additionally, always use useNavigate and Link from
@flightctl/ui-components/hooks/useNavigate — never from react-router-dom
directly. This is a project convention (not ESLint-enforced).
Two page layouts exist. Always use these — never compose raw PatternFly
PageSection + Title as a page.
List page — ListPage > ListPageBody > Toolbar + Table + Pagination:
<ListPage title={t('Fleets')}>
<ListPageBody error={error} loading={isLoading}>
<Toolbar inset={{ default: 'insetNone' }}>
<ToolbarContent>
<ToolbarItem><TableTextSearch ... /></ToolbarItem>
<ToolbarItem><Button>Create fleet</Button></ToolbarItem>
</ToolbarContent>
</Toolbar>
<Table columns={columns} ...>
<Tbody>
{items.map(item => <FleetRow key={...} fleet={item} />)}
</Tbody>
</Table>
<TablePagination pagination={pagination} />
</ListPageBody>
</ListPage>Reference: components/Fleet/FleetsPage.tsx
Details page — DetailsPage with TabsNav + nested Routes:
<DetailsPage
id={resourceId}
title={<ResourceLink id={resourceId} />}
error={error}
loading={isLoading}
resourceLink={ROUTE.FLEETS}
resourceType="Fleets"
resourceTypeLabel={t('Fleets')}
actions={<DetailsPageActions>...</DetailsPageActions>}
nav={<TabsNav ...>{/* Tab components */}</TabsNav>}
>
<Routes>
<Route path="details" element={<DetailsTab />} />
<Route path="yaml" element={<YamlEditor />} />
</Routes>
</DetailsPage>Reference: components/Fleet/FleetDetails/FleetDetailsPage.tsx
Never use bare PatternFly form controls inside a FlightCtlForm. Use the
Formik-integrated wrappers in components/form/:
| Instead of (PatternFly) | Use (project wrapper) |
|---|---|
TextInput |
TextField |
Checkbox |
CheckboxField |
Radio |
RadioField |
Switch |
SwitchField |
Select |
FormSelect / FormSelectTypeahead |
TextArea |
TextAreaField |
These wrappers bind to Formik context — they handle validation, error display, and field registration automatically.
Actions (delete, decommission, resume) use hook pairs that return both trigger JSX and modal JSX. Render them separately:
const { deleteAction, deleteModal } = useDeleteAction({
onDelete: () => remove(`fleets/${id}`),
resourceType: 'Fleet',
resourceName: name,
});
<DetailsPageActions>{deleteAction}</DetailsPageActions>
{deleteModal}- Feature directories:
components/Fleet/,components/Device/, etc. - Sub-pages in subdirectories:
Fleet/FleetDetails/,Fleet/CreateFleet/ - No barrel exports — import components by full path, never via
index.ts - Hooks co-located:
use<Feature>.tslives next to the component using it - CSS class prefix:
fctl-<component>__<element>(BEM-style)
Access the API through context. Never import from apiCalls.ts directly:
// Correct
const { get, post, put, remove, patch } = useFetch();
// Wrong — bypasses context, breaks in OCP plugin
import { fetchData } from 'utils/apiCalls';useFetchPeriodically is the standard read pattern. It polls every 10 seconds,
manages abort signals, and distinguishes initial loading from background updates.
Always wrap it in a domain-specific hook — components never call
useFetchPeriodically directly:
// Domain hook (useFleets.ts)
export const useFleets = (args: FleetsEndpointArgs): FleetLoad => {
const pagination = useTablePagination<FleetList>();
const [endpoint, debouncing] = useFleetsEndpoint({
...args,
nextContinue: pagination.nextContinue,
});
const [data, isLoading, error, refetch, isUpdating] = useFetchPeriodically<FleetList>(
{ endpoint },
pagination.onPageFetched,
);
return {
fleets: data?.items || [],
isLoading,
error,
isUpdating: isUpdating || debouncing,
refetch,
pagination,
};
};
// Component consumes the domain hook
const { fleets, isLoading, error, pagination } = useFleets({ addDevicesSummary: true });Reference: components/Fleet/useFleets.ts, components/Device/DevicesPage/useDevices.ts
Same useFetch() context for writes:
const { post, put, patch, remove } = useFetch();
await post<Fleet>('fleets', resource); // Create
await put<Fleet>(`fleets/${id}`, updated); // Full replace
await patch<Fleet>(`fleets/${id}`, jsonPatchOps); // Partial (JSON Patch array)
await remove<void>(`fleets/${id}`); // DeleteURL search params are the source of truth for filter and sort state. Never store filter state in component state alone — it must survive navigation:
const { router: { useSearchParams } } = useAppContext();
const [searchParams, setSearchParams] = useSearchParams();
const name = searchParams.get('name') || undefined;
// Updating params triggers endpoint rebuild and automatic refetch
setSearchParams(new URLSearchParams({ name: newValue }));Reference: components/Fleet/useFleets.ts (useFleetBackendFilters)
The API uses continue tokens, not offset-based pagination:
const pagination = useTablePagination<FleetList>();
// Pass pagination.nextContinue when building the endpoint
// Pass pagination.onPageFetched as the useFetchPeriodically callback
// Render: <TablePagination pagination={pagination} />Page size: PAGE_SIZE (15) from constants.ts.
Use the typed ROUTE enum — never use react-router's navigation directly:
import { useNavigate, Link, ROUTE } from '@flightctl/ui-components/hooks/useNavigate';
navigate(ROUTE.FLEETS);
navigate({ route: ROUTE.FLEET_DETAILS, postfix: fleetName });
<Link to={ROUTE.FLEETS}>Fleets</Link>
<Link to={{ route: ROUTE.FLEET_DETAILS, postfix: fleetName }}>View</Link>Routes with a resource ID use { route, postfix }. Routes without use the
enum directly. The ROUTE enum is the single source of truth for all
application paths.
Wizards compose three layers: Formik (form state + validation), PatternFly Wizard (step navigation), and FlightCtlWizardFooter (bridges the two). Never build a multi-step form without this structure.
<Formik<MyFormValues>
initialValues={getInitialValues()}
validationSchema={getValidationSchema(t)}
validateOnMount
onSubmit={async (values) => { /* post/patch, then navigate */ }}
>
{({ errors }) => {
const validStepIds = getValidStepIds(errors);
return (
<Wizard footer={<MyWizardFooter />}>
<WizardStep id={stepOneId}
isDisabled={isWizardStepDisabled(stepOneId, orderedIds, validStepIds)}>
{currentStep?.id === stepOneId && <StepOneContent />}
</WizardStep>
{/* ... more steps ... */}
</Wizard>
);
}}
</Formik>Reference: components/Fleet/CreateFleet/CreateFleetWizard.tsx
Each step file exports a step ID and a validation function:
export const generalInfoStepId = 'general-info';
export const isGeneralInfoStepValid = (errors: FormikErrors<FleetFormValues>) =>
!errors.name && !errors.labels && !errors.fleetLabels;The wizard root collects these into an ordered array. isWizardStepDisabled
(from utils/wizards.ts) disables any step whose predecessors are invalid.
The review/submit step is valid only when all other steps pass.
- Always render disabled steps — use
isDisabled, never conditionally omit<WizardStep>. Content inside is conditionally rendered with{currentStep?.id === stepId && <Component />}. - Use
Yup.lazy()for schemas where validation depends on other field values (e.g. advanced vs. basic config). - Reusable validators live in
components/form/validations.ts— check there before writing a new one (validKubernetesDnsSubdomain,validLabelsSchema,validConfigTemplatesSchema, etc.). - rjsf / DynamicForm exists for JSON Schema-driven forms (catalog items).
Don't reinvent schema-driven forms — use
components/DynamicForm/DynamicForm.tsx.
AGENTS.md covers the core rules (hardcoded keys, npm run i18n). Additional
constraints:
- Interpolation syntax:
t('Version {{ num }}', { num: version })— double braces, not JSX expressions. - Components take translated strings, never i18n keys. The caller runs
t()and passes the result:<ListPage title={t('Fleets')}>. - Keys are flat —
keySeparator: falsein the parser config. No dot notation nesting. - Colons in translation text require
nsSeparatoroverride — i18next treats:as a namespace separator by default:t('e.g. https://example.com', { nsSeparator: '|' }). npm run i18nis part ofnpm run lint— if you add or change a translation key in source, lint will fail until you runnpm run i18nand commit the updatedtranslation.json.
Use usePermissionsContext() with RESOURCE and VERB enums from
types/rbac.ts:
const { checkPermissions, loading } = usePermissionsContext();
const [canList, canCreate] = checkPermissions([
{ kind: RESOURCE.FLEET, verb: VERB.LIST },
{ kind: RESOURCE.FLEET, verb: VERB.CREATE },
]);checkPermissions takes an array and returns a parallel boolean array.
- No raw 403s — users should never see an action they lack permission
for. Use
checkPermissionsto hide or disable the action before it can be triggered. - Disabled, not hidden, with tooltip — when a user has permission but the action is disabled for another reason, show the button as disabled with a tooltip explaining why.
- New APIs need new constants — when adding API endpoints, add the
corresponding entries to the
RESOURCEandVERBenums intypes/rbac.ts.
Wrap entire pages with PageWithPermissions — it handles loading spinners
and access-denied states:
<PageWithPermissions allowed={canList} loading={loading}>
<FleetsContent />
</PageWithPermissions>For button-level checks, conditionally render:
{canCreate && <Button>{t('Create fleet')}</Button>}Reference: components/Catalog/ResourceCatalog/ResourceCatalogPage.tsx,
components/AuthProvider/AuthProvidersPage.tsx
Unit tests use Vitest (vitest.config.ts at the repo root). Test files
live in libs/ui-components/src/ alongside the modules they test, named
*.test.ts. The test environment is node (not jsdom) — tests cover pure
logic and utilities, not rendered component output.
- Run:
npm run test(type-check viatsc --noEmit, thenvitest run) - Watch:
npm run test:watch - Include pattern:
libs/ui-components/src/**/*.test.ts
Existing Cypress E2E tests live in libs/cypress/. The existing suite and its
CI integration must be preserved, but do not add new Cypress test specs, test
cases, coverage, or placeholder/stub tests. The presence of the existing
suite is not permission to extend it.
Tests live in libs/cypress/e2e/{feature}/. Every test file:
- Calls
cy.loadApiInterceptors()in the firstbeforeEach - Instantiates page objects and navigates in a second
beforeEach - Uses page object getters for all selectors — never hardcode selectors in test files
beforeEach(() => {
cy.loadApiInterceptors();
});
beforeEach(() => {
FleetsPage.visit();
fleetsPage = new FleetsPage();
});Page objects live in libs/cypress/pages/. Pattern:
export class FleetsPage {
constructor() {
cy.get('section').as(FleetsPage.name);
}
static visit() {
cy.visit('/devicemanagement/fleets');
}
get createButton() {
return cy.contains('button', 'Create');
}
fleetRow(name: string) {
return cy.get('td[data-label="Name"]').contains(name);
}
}- Static
visit()for navigation - Constructor aliases the page container
- Getter-based selectors
data-testidattributes (most stable)aria-labelattributes (accessibility-based)cy.contains('element', 'text')(for buttons/labels without IDs)- CSS/DOM selectors (last resort)
All API calls are intercepted — tests never hit a real backend.
Interceptors live in libs/cypress/support/interceptors/ and use regex
matchers from matchers.ts (createListMatcher, createDetailMatcher).
Wait for intercepts after user actions: cy.wait('@create-new-fleet').