- TypeScript 6.0, React 19
- UI component library: PatternFly 6 (
@patternfly/react-core,@patternfly/react-table) - Build tool: Vite (client), Rollup (common, server)
- Routing: react-router-dom v7 with lazy-loaded routes
- Data fetching: TanStack React Query v5
- API client: auto-generated from OpenAPI spec via
@hey-api/openapi-tswith Axios - Forms: react-hook-form + yup validation
- E2E testing: Playwright with playwright-bdd (Gherkin features)
- Unit testing: Vitest
- Linting/formatting: ESLint + Prettier
- Package manager: npm workspaces (Node.js >= 22)
- Formatter: Prettier with space indentation (2 spaces), double quotes
- Line length: 80 characters (
.editorconfig) - Line endings: LF
- Run
npm run lintbefore committing - Run
npm run format:fixto auto-format - Organize imports: disabled in ESLint config (manual ordering)
- Import Order: Group imports alphabetically and follow the order below,
with each block separated by a blank line:
- React/Router block: Dependencies from
react,react-dom,react-router,react-router-dom,react-oidc-context,react-hook-form, etc. - Package dependencies block: Any dependency declared in
package.json(e.g.,axios,dayjs,yup,lodash, etc.) - PatternFly block: Any
@patternfly/*dependency - App imports block: Any
@app/*dependency - Relative imports block: Local relative imports (
./,../, etc.)
- React/Router block: Dependencies from
Example:
import React from "react";
import {Link, useNavigate} from "react-router-dom";
import type {AxiosError} from "axios";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import {
Breadcrumb,
Tab,
} from "@patternfly/react-core";
import {PathParam, Paths, useRouteParams} from "@app/Routes";
import {LoadingWrapper} from "@app/components/LoadingWrapper";
import {Overview} from "./overview";- The
client/src/app/client/directory is auto-generated — never edit directly; regenerate withnpm run generate -w client - CSS modules enabled for
.module.cssfiles
- React components: PascalCase (
SbomList,SbomTable,SbomToolbar,ErrorFallback) - Component files: kebab-case for page files (
sbom-list.tsx,sbom-table.tsx,sbom-context.tsx), PascalCase for reusable components (SBOMEditLabelsForm.tsx,SbomVulnerabilities.tsx) - Custom hooks: camelCase with
useprefix (useFetchSBOMs,useDeleteSbomMutation,useUpload) - Query key constants: PascalCase with
QueryKeysuffix (SBOMsQueryKey,AdvisoriesQueryKey,VulnerabilitiesQueryKey) - Query hooks:
useFetch<Resource>for reads,use<Action><Resource>Mutationfor writes - Context providers:
<Domain>SearchProviderwrapping a<Domain>SearchContext - Page directories: kebab-case matching the domain (
sbom-list/,advisory-details/,vulnerability-list/) - Route paths: kebab-case (
/sboms,/advisories,/vulnerabilities) - Path alias:
@app/maps toclient/src/app/ - E2E page objects: PascalCase classes (
SbomListPage,AdvisoryListPage)
package.json # workspace root
common/ # shared ESM module (branding, environment config)
client/ # React SPA
server/ # Express.js production server (proxying, env injection)
e2e/ # Playwright end-to-end tests
eslint.config.mjs # ESLint config
.prettierrc.mjs # Prettier config
Routes.tsx # route definitions with lazy() imports
Constants.ts # app-wide constants
env.ts # environment config
oidc.ts # OIDC auth config
dayjs.ts # dayjs setup
pages/ # page components, one directory per page
<domain>-list/ # list page for a domain
index.ts # re-export: `export { DomainList as default } from "./domain-list"`
<domain>-list.tsx # main page component
<domain>-table.tsx # table component
<domain>-toolbar.tsx # toolbar with filters/actions
<domain>-context.ts # React context definition and interface
<domain>-provider.tsx # context provider with table state, data fetching
helpers.ts # page-specific helpers
components/ # page-specific sub-components
<domain>-details/ # detail page for a domain
<domain>-details.tsx
overview.tsx
<sub-tab>.tsx # tab content components
components/ # detail-specific sub-components
index.ts
queries/ # TanStack Query hooks, one file per domain
sboms.ts # useFetchSBOMs, useDeleteSbomMutation, etc.
advisories.ts
vulnerabilities.ts
packages.ts
...
components/ # shared UI components
FilterToolbar/ # complex components get their own directory
SimplePagination/
ConfirmDialog.tsx # simple components are single files
...
hooks/ # custom hooks
table-controls/ # table state management (pagination, sorting, filtering)
domain-controls/ # domain-specific data hooks
useUpload.ts
useUrlParams.ts
...
api/ # custom REST calls (uploads, downloads)
rest.ts
client/ # auto-generated API client (DO NOT EDIT)
axios-config/ # Axios instance and interceptors
apiInit.ts
ui/
features/ # BDD .feature files (Gherkin)
@<domain>/ # feature + step definitions grouped by domain
pages/ # Page Object Model classes
<domain>-list/
<Domain>ListPage.ts # page object class
columns.spec.ts # spec per concern
filter.spec.ts
sort.spec.ts
pagination.spec.ts
actions.spec.ts
<domain>-details/
<Domain>DetailsPage.ts
...
helpers/ # shared test utilities
steps/ # shared step definitions
fixtures.ts # test fixtures
api/ # API-level tests
features/ # API test files
dependencies/ # setup (global.setup.ts)
common/ # shared test assets
dataset/sbom/ # test SBOM files
dataset/advisory/csaf/ # test CSAF advisory files
dataset/advisory/csaf_security/ # test CSAF security files
constants.ts
- API errors are typed as
AxiosErrorand propagated through query hooks viafetchErrorreturn values - Mutation hooks accept
onSuccessandonErrorcallbacks, handling query invalidation internally - Top-level error boundary via
react-error-boundarywithErrorFallbackcomponent - Query hooks return a normalized shape:
{ result: { data, total, params }, isFetching, fetchError, refetch } - Components display errors using
StateErrorcomponent for failed data fetches StateNoDataandStateNoResultsfor empty states
API types and SDK functions are auto-generated from the OpenAPI spec
(client/openapi/trustd.yaml) using @hey-api/openapi-ts with Axios. The
generated output at client/src/app/client/ must never be edited manually.
| Layer | Location | Examples | Ownership |
|---|---|---|---|
| Generated | client/types.gen.ts, sdk.gen.ts |
PaginatedResultsAdvisorySummary, ListAdvisoriesData |
Auto-generated — never edit |
| Manual (request) | api/models.ts |
HubRequestParams, HubFilter |
Hand-maintained |
| Manual (response) | api/models.ts, api/rest.ts |
HubPaginatedResult<T>, PaginatedResponse<T> |
Hand-maintained |
Query hooks in queries/ bridge the two layers: they call generated SDK
functions and normalize responses into the HubPaginatedResult<T> shape.
Update client/openapi/trustd.yaml with the new backend spec, then run
npm run generate -w client. Follow the full
Adapting to upstream API changes checklist
to reconcile generated types, query hooks, manual types, and constants.
getHubPaginatedResultinapi/rest.tsandserializeRequestParamsForHubinhooks/table-controls/getHubRequestParams.tsare the legacy serialization path (URLSearchParams). They do not serialize thetotalquery parameter.- Only used for upload/download flows — all paginated list queries use
requestParamsQuery(plain object, includestotal). - New paginated endpoints must use the
requestParamsQuerypath.
All list pages use server-side pagination. The frontend requests one page at a
time from the backend and displays the server-reported total in the PatternFly
Pagination component.
Context provider (e.g., pages/advisory-list/advisory-provider.tsx)
└─ useTableControlState() → { pageNumber (1-indexed), itemsPerPage }
└─ getHubRequestParams(tableControlState) → HubRequestParams { page, sort, filters }
└─ Spread extra params: { ...hubRequestParams, total: true }
^^^^^^^^^^^^
MUST be added per-page
↓
Query hook (e.g., queries/advisories.ts → useFetchAdvisories)
└─ requestParamsQuery(params)
→ { offset: (pageNumber-1)*itemsPerPage, limit: itemsPerPage, q, sort, total }
└─ Generated SDK function (e.g., listAdvisories({ client, query: {...} }))
↓
GET /api/v2/advisory?offset=0&limit=10&sort=modified:desc&total=true
Key rule: getHubRequestParams does not include total. Each context
provider must explicitly add total: true onto the params object. Omitting it
means the server skips the COUNT query and returns total: null.
HTTP response: { items: T[], total: number | null }
↓
Query hook normalizes (e.g., queries/advisories.ts):
data: data?.data?.items || []
total: data?.data?.total ?? 0 ← standard nullable guard
→ returns { result: { data, total, params }, isFetching, fetchError, refetch }
↓
Context provider destructures:
{ data: advisories, total: totalItemCount } = result
→ passes totalItemCount to useTableControlProps(...)
↓
useTableControlProps → usePaginationPropHelpers
→ paginationProps: { itemCount: totalItemCount, perPage, page, onSetPage, ... }
↓
<SimplePagination paginationProps={paginationProps} />
→ renders PatternFly <Pagination>
Key rule: the ?? 0 fallback in query hooks is the standard pattern for
nullable total. If the backend makes a field nullable, add a ?? guard in
the query hook — do not change the manual types to optional.
MAX_ITEMS_PER_PAGE = 1000inConstants.ts— mirrors the server's default max pagination limit (TRUSTD_PAGINATION_MAX_LIMIT). Update when the server default changes.- Default
itemsPerPageis10(fromusePaginationState). pageNumberis 1-indexed. Conversion to 0-indexedoffsethappens inrequestParamsQuery:offset = (pageNumber - 1) * itemsPerPage.
Interceptors are registered in axios-config/apiInit.ts:
Request interceptor:
- Bearer token injection — attaches the OIDC access token to outgoing requests
Response interceptors:
- Read-only detection (503) — invalidates trustify info cache
- Auth token refresh (401) — silent re-auth with one retry
No centralized 400 handler exists — errors propagate via fetchError in query
hooks. To add centralized handling for a new HTTP error code, add a response
interceptor in initInterceptors().
When the backend OpenAPI spec changes:
- Update
client/openapi/trustd.yamlwith the new spec - Regenerate:
npm run generate -w client - Check generated types for changed shapes (nullable fields, new params)
- Update query hooks (
queries/) — add?? defaultValuefor nullable fields - Update context providers (
pages/) if new request params need passing (e.g., a new opt-in flag liketotal: true) - Update
Constants.tsif server limits changed - Add interceptor in
apiInit.tsif new error codes need centralized handling - Update manual types in
api/models.tsandapi/rest.tsif needed - Run
npm run lintand verify the build
- Run with
npm run test(from root orclientworkspace) - Config in
client/vite.config.ts(test block) - CI runs:
npm run test -- --coverage
- Two test styles coexist:
- BDD features:
.featurefiles ine2e/tests/ui/features/with step definitions in.step.tsfiles - Spec files:
.spec.tsfiles ine2e/tests/ui/pages/<domain>/organized by concern (columns, filter, sort, pagination, actions)
- BDD features:
- Page Object Model pattern: each page has a class (e.g.,
SbomListPage) with a staticbuild()factory, encapsulating navigation and element access - Shared page objects:
Table,Toolbar,Pagination,Navigationine2e/tests/ui/pages/ - Tags for test tiers:
{ tag: "@tier1" } - No explicit timeouts in shared page objects: Playwright provides automatic waiting for elements, assertions, and actions. Shared classes (
Table,Toolbar,Pagination,Navigation) must not add explicittimeoutparameters. If a component triggers async re-rendering, the wait belongs in that component's page object, not in shared infrastructure. - Composition over conditional expansion: Page object methods should do one thing. For example,
clearAllFilters()clears filters — checking whether filters exist is the caller's responsibility. Do not add conditional guards inside action methods. - Run e2e:
npm run e2e:test:ui(full),npm run e2e:test:api(API only)
- Follow Conventional Commits:
<type>[optional scope]: <description> - Types:
feat,fix,refactor,test,docs,chore - Reference the Jira issue in the commit footer (e.g.,
Implements TC-123) - AI-assisted commits include
--trailer="Assisted-by: Claude Code"
- All workspaces managed via npm workspaces in root
package.json - Use caret ranges (
^) for dependencies inclient/package.json - Key dependencies:
react19,@patternfly/react-core6,@tanstack/react-query5,axios,react-router-dom7,react-hook-form,yup - API client generated from
client/openapi/trustd.yaml— update the spec, thennpm run generate -w client - Dependabot configured for automated dependency updates
Each list page follows a consistent architecture:
- Context definition (
<domain>-context.ts): creates the<Domain>SearchContextwithReact.createContext()and defines the context interface - Context provider (
<domain>-provider.tsx): manages table state viauseTableControlState, fetches data with the domain query hook, and provides values through<Context.Provider> - Page component (
<domain>-list.tsx): rendersPageSectionwith title, wraps content in the context provider, renders toolbar and table - Toolbar (
<domain>-toolbar.tsx): consumes context for pagination and filter props - Table (
<domain>-table.tsx): consumes context for data, columns, sorting, and row rendering
- Main component fetches by ID from route params
- Content organized into tabs (overview, packages, vulnerabilities, etc.)
- Sub-components for each tab content area
Detail pages use a sequence of PatternFly PageSection blocks:
<PageSection type="breadcrumb"> — breadcrumb navigation
<PageSection> — page header (title, actions)
<PageSection> — tab bar (Tabs)
<PageSection> — tab content panels (TabContent)
Components rendered inside <TabContent> must not include their own
<PageSection> wrapper. The parent detail page provides the PageSection that
wraps all tab content panels.
Canonical example — client/src/app/pages/sbom-details/sbom-details.tsx:
{/* Parent provides the PageSection */}
<PageSection>
<TabContent {...getTabContentProps("info")}>
{sbom && <Overview sbom={sbom} />} {/* no PageSection inside */}
</TabContent>
<TabContent {...getTabContentProps("packages")}>
{sbomId && <PackagesBySbom sbomId={sbomId} />}
</TabContent>
</PageSection>This keeps spacing and background styling consistent across all tabs and avoids
double-nesting PageSection elements.
- One file per domain in
queries/ - Export a query key constant (e.g.,
export const SBOMsQueryKey = "sboms") - Fetch hooks use
useQuerywith the sharedclientAxios instance - Mutation hooks use
useMutationwithqueryClient.invalidateQuerieson success/error - Fetch hooks return
{ result: { data, total, params }, isFetching, fetchError, refetch }
- Routes defined in
Routes.tsxwith aPathsconstant object - Pages lazy-loaded via
React.lazy(() => import("./pages/<domain>")) - Each page directory has an
index.tsthat re-exports the default component:export { SbomList as default } from "./sbom-list"