Next.js 14+ (App Router) web UI for Cadence Workflow. TypeScript throughout. Talks to a Cadence backend over gRPC.
npm run dev # Dev server on :8088
npm run test # All tests + type tests (85% coverage threshold enforced)
npm run test:unit:browser # Browser (jsdom) tests only
npm run test:unit:node # Node (server-side) tests only
npm run lint # ESLint
npm run typecheck # tsc --noemit
npm run build # Production build| Layer | Technology |
|---|---|
| Framework | Next.js 14 App Router |
| Language | TypeScript |
| UI components | Base Web (baseui) — Uber's design system |
| Styling | baseui's styled() utility (Styletron under the hood) |
| Data fetching | TanStack Query (React Query v5) with Next.js experimental RSC support |
| Backend protocol | gRPC via @grpc/grpc-js |
| Form validation | React Hook Form + Zod |
| Testing | Jest — two separate configs: browser (jsdom) and node |
| Test utilities | @testing-library/react, MSW (Mock Service Worker) for API mocking |
| Type testing | tstyche |
src/
app/ # Next.js App Router — pages, layouts, error/loading boundaries
(Home)/ # Route group for main UI pages
api/ # API route handlers (server-side, call Cadence gRPC)
components/ # Abstract, reusable UI components (no business logic)
views/ # Feature modules (complex, page-level components)
shared/ # Cadence-specific reusable components (has business logic)
hooks/ # Shared custom React hooks
providers/ # React context providers
route-handlers/ # Server-side route handlers
utils/ # Pure utility functions
config/ # App configuration
test-utils/ # Shared test helpers (excluded from coverage)
__generated__/ # Generated protobuf types — never edit manually
src/components/ — Generic UI, no Cadence knowledge:
- Buttons, tables, inputs, layout primitives
- Could be used in any app
src/views/shared/ — Cadence business logic components:
- Components that fetch/render Cadence objects (workflows, domains, etc.)
- Understand Cadence data structures and APIs
- Examples:
WorkflowStatusTag,DomainStatusTag
Every component/view follows a consistent multi-file pattern:
component-name/
component-name.tsx # Component implementation
component-name.styles.ts # Styletron/baseui styles
component-name.types.ts # TypeScript types/interfaces
__tests__/
component-name.test.tsx # Tests
__fixtures__/ # Test data (excluded from coverage)
- Files and directories: kebab-case
- Components/types: PascalCase
- Constants: UPPER_SNAKE_CASE
- Functions/variables: camelCase
- Path alias:
@/resolves tosrc/
Max 2 levels of component directories under src/views/ and src/components/. __tests__/ at level 3 is fine. Never go deeper.
src/views/
domain-page/ # Level 1 ✓
domain-page-header/ # Level 2 ✓
__tests__/ # Level 3 ✓ (tests/fixtures/helpers only)
- Config — Values and wiring that forks are expected to change between deployments or product variants (clusters, auth, feature visibility, columns).
- Constants — Stable, reusable literals that are not treated as fork overrides (shared labels, fixed format tokens, internal limits used the same way upstream and in forks). Prefer
*.constants.ts, feature-local modules for cross-cutting fixed values.
Use baseui's styled utility — not raw Styletron or CSS modules:
// component.styles.ts
import { styled as createStyled, type Theme } from 'baseui';
import { type StyleObject } from 'styletron-react';
export const styled = {
Container: createStyled('div', ({ $theme }: { $theme: Theme }): StyleObject => ({
padding: $theme.sizing.scale600,
color: $theme.colors.contentPrimary,
})),
};Always use theme tokens: $theme.sizing.scale* for spacing, $theme.colors.* for colors, $theme.typography.* for text.
For responsive styles, use $theme.mediaQuery.*:
{ [$theme.mediaQuery.medium]: { flexDirection: 'row' } }Use useStyletronClasses hook for CSS object styles:
import useStyletronClasses from '@/hooks/use-styletron-classes';
const { cls } = useStyletronClasses(cssStyles);Use mergeOverrides from baseui/helpers/overrides when extending baseui component overrides.
- Prefer
typeoverinterface; - Use generics with meaningful constraints for reusable components/hooks
- Never use
any— useunknownand narrow - Always explicitly type hook return values
export type Props = {
required: string;
optional?: number;
};Next.js App Router already decodes params and searchParams. Do not call decodeUrlParams() or decodeURIComponent() on values from route handler params, page params, or searchParams — it double-decodes and corrupts values (e.g. task list names with % or @).
- Do use
encodeURIComponent()when building URLs from user input beforehref,router.push(), orfetch(). decodeUrlParams()is only for query string values passed through non-Next mechanisms.
import { type NextRequest, NextResponse } from 'next/server';
import { getHTTPStatusCode, GRPCError } from '@/utils/grpc/grpc-error';
import logger, { type RouteHandlerErrorPayload } from '@/utils/logger';
import requestBodySchema from './schemas/request-body-schema';
export async function handlerName(request: NextRequest, requestParams: RequestParams, ctx: Context) {
const { data, error } = requestBodySchema.safeParse(await request.json());
if (error) {
return NextResponse.json({ message: 'Invalid values provided', validationErrors: error.errors }, { status: 400 });
}
const params = requestParams.params; // already decoded — do not decode again
try {
const response = await ctx.grpcClusterMethods.someMethod({ ...params });
return NextResponse.json(response);
} catch (e) {
logger.error<RouteHandlerErrorPayload>({ requestParams: params, error: e }, 'Error description');
return NextResponse.json(
{ message: e instanceof GRPCError ? e.message : 'Unexpected error', cause: e },
{ status: getHTTPStatusCode(e) }
);
}
}Test type is determined by file extension, not directory location:
| Extension | Environment | Run with |
|---|---|---|
*.test.ts / *.test.tsx |
Browser (jsdom) | npm run test:unit:browser |
*.node.ts |
Node | npm run test:unit:node |
Default to .test.tsx unless the code is server-only or uses Node.js APIs.
To run a single file:
npm run test:unit:browser cron-schedule-input.test.tsx
npm run test:unit:node cron-validate.node.tsUse @/test-utils/rtl (not @testing-library/react directly) — it wraps with QueryClient, Styletron, and baseui providers:
import { render, screen, userEvent } from '@/test-utils/rtl';Always use a setup() function when multiple tests share initialization:
describe(ComponentName.name, () => {
it('renders correctly', () => {
setup({});
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('handles clicks', async () => {
const { user, mockHandler } = setup({});
await user.click(screen.getByRole('button'));
expect(mockHandler).toHaveBeenCalledTimes(1);
});
});
function setup(props: Partial<Props> = {}) {
const mockHandler = jest.fn();
const user = userEvent.setup();
render(<ComponentName onAction={mockHandler} {...props} />);
return { user, mockHandler };
}Mock child components in unit tests to isolate the parent:
jest.mock('../child-component', () =>
function MockChild({ onAction }: any) {
return <button onClick={onAction}>mock</button>;
}
);- MSW is set up globally — use
server.use(...)for per-test handlers clearTestQueryClient()runs after each test automatically- Coverage threshold: 85% branches, functions, lines, statements — enforced on CI
- Test behavior, not implementation details
- Fork-friendly assertions — Do not query or assert on nested app components (custom components under
src/) without mocking them. Forks may swap implementations; tests should assert on HTML elements and third-party UI (e.g. baseui) only, so parent tests do not break when a child is replaced. __fixtures__/for test data (excluded from coverage)
- Never edit
src/__generated__/— regenerate withnpm run generate:idl TZ=UTCis set for all test runs — use UTC in date-related tests
- Double-decoding params — Next.js already decodes route params; calling
decodeURIComponentagain corrupts values with%or@ - Using
any— useunknownand narrow instead - Hardcoding style values — always use
$themetokens, never raw px/hex - Wrong test import — import from
@/test-utils/rtl, not@testing-library/react - Wrong test extension — server-side code must use
.node.ts, not.test.tsx - Skipping the
setup()function — always use one when tests share initialization; don't repeatrendercalls inline - Nesting too deep — max 2 levels under
src/views/andsrc/components/;__tests__/at level 3 only - Creating barrel
index.tsfiles — this project does not use them - Helpers, types or constants in the component
.tsx— add them into their dedicated files.