Moved all static layout properties (display, gap, margin, padding, colour) from inline style props to makeStyles classes in: - MapBansTable.tsx: Pagination row flexbox layout - JailDetailPage.tsx: Link styling for textDecoration - HistoryPage.tsx: Summary text styling - IpDetailView.tsx: Loading container and text formatting Kept inline styles only for genuinely dynamic values: - WorldMap.tsx: Tooltip position (follows mouse) - TopCountriesPieChart.tsx: Legend color (from recharts data) - TopCountriesBarChart.tsx: Chart height (derives from data length) This change improves performance by leveraging Griffel's atomic CSS cache and ensures consistency with the established Fluent UI pattern. Updated Docs/Web-Development.md with explicit rule: inline styles only for runtime-dynamic values, all static properties go in makeStyles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
31 KiB
Frontend Development — Rules & Guidelines
Rules and conventions every frontend developer must follow. Read this before writing your first line of code.
1. Language & Typing
- TypeScript is mandatory — no plain JavaScript files (
.js,.jsx) in the codebase. - Use strict mode (
"strict": trueintsconfig.json) — the project must compile with zero errors. - Never use
any. If a type is truly unknown, useunknownand narrow it with type guards. - Prefer interfaces for object shapes that may be extended, type aliases for unions, intersections, and utility types.
- Every function must have explicit parameter types and return types — including React components (
React.FCis discouraged; type props and returnJSX.Elementexplicitly). - Use
T | nullorT | undefinedinstead ofOptionalpatterns — be explicit about nullability. - Use
as constfor constant literals and enums where it improves type narrowness. - Run
tsc --noEmitin CI — the codebase must pass with zero type errors.
// Good
interface BanEntry {
ip: string;
jail: string;
bannedAt: string;
expiresAt: string | null;
}
function BanRow({ ban }: { ban: BanEntry }): JSX.Element {
return <tr><td>{ban.ip}</td><td>{ban.jail}</td></tr>;
}
// Bad — untyped, uses `any`
function BanRow({ ban }: any) {
return <tr><td>{ban.ip}</td></tr>;
}
2. Reusable Types
- All shared type definitions live in a dedicated
types/directory. - Group types by domain:
types/ban.ts,types/jail.ts,types/auth.ts,types/api.ts, etc. - Import types using the
import typesyntax when the import is only used for type checking — this keeps the runtime bundle clean. - Component-specific prop types may live in the same file as the component, but any type used by two or more files must move to
types/. - Never duplicate a type definition — define it once, import everywhere.
- Export API response shapes alongside their domain types so consumers always know what the server returns.
// types/ban.ts
export interface Ban {
ip: string;
jail: string;
bannedAt: string;
expiresAt: string | null;
banCount: number;
country: string | null;
}
export interface BanListResponse {
bans: Ban[];
total: number;
}
// components/BanTable.tsx
import type { Ban } from "../types/ban";
3. Type Safety in API Calls
- Every API call must have a typed request and typed response.
- Define response shapes as TypeScript interfaces in
types/and cast the response through them. - Use a central API client (e.g., a thin wrapper around
fetchoraxios) that returns typed data — individual components never callfetchdirectly. - Validate or assert the response structure at the boundary when dealing with untrusted data; for critical flows, consider a runtime validation library (e.g.,
zod). - API endpoint paths are constants defined in a single file (
api/endpoints.ts) — never hard-code URLs in components. - All API functions that perform a
GETrequest must accept an optionalsignal?: AbortSignalparameter and forward it to the HTTP client. This enables hooks to cancel in-flight requests when components unmount, preventing silent state-update errors and wasted resources. When an API function calls another internal API function, thread the signal through to the underlying call.
// api/client.ts
const BASE_URL = import.meta.env.VITE_API_URL ?? "/api";
async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
const response: Response = await fetch(`${BASE_URL}${path}`, {
credentials: "include",
signal,
});
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
return (await response.json()) as T;
}
export const api = { get, post, put, del } as const;
// api/bans.ts
import type { BanListResponse } from "../types/ban";
import { api } from "./client";
export async function fetchBans(hours: number, signal?: AbortSignal): Promise<BanListResponse> {
return api.get<BanListResponse>(`/bans?hours=${hours}`, signal);
}
// hooks/useBans.ts
const ctrl = new AbortController();
fetchBans(24, ctrl.signal) // Pass the signal to enable cancellation on unmount
.then(resp => { /* ... */ })
.catch(err => { /* ... */ });
4. Code Organization
Project Structure
frontend/
├── public/
├── src/
│ ├── api/ # API client, endpoint definitions, per-domain request files
│ ├── assets/ # Static images, fonts, icons
│ ├── components/ # Reusable UI components (buttons, modals, tables, etc.)
│ ├── hooks/ # Custom React hooks
│ ├── layouts/ # Page-level layout wrappers (sidebar, header, etc.)
│ ├── pages/ # Route-level page components (one per route)
│ ├── providers/ # React context providers (auth, theme, etc.)
│ ├── theme/ # Fluent UI custom theme, tokens, and overrides
│ ├── types/ # Shared TypeScript type definitions
│ ├── utils/ # Pure helper functions, constants, formatters
│ ├── App.tsx # Root component, FluentProvider + router setup
│ ├── main.tsx # Entry point
│ └── vite-env.d.ts # Vite type shims
├── .eslintrc.cjs
├── .prettierrc
├── tsconfig.json
├── vite.config.ts # Dev proxy: /api → http://backend:8000 (service DNS)
└── package.json
Dev proxy target:
vite.config.tsproxies all/apirequests tohttp://backend:8000by default. SetVITE_BACKEND_URLinfrontend/.envor your shell to override the backend address for local development outside Docker.Use the compose service name (
backend), notlocalhost— inside the container networklocalhostresolves to the frontend container itself and causesECONNREFUSED.
Pages vs Components
The distinction between pages/ and components/ is fundamental to the project structure:
-
pages/contains route-level entry point components — exactly one component per route. Pages map directly to URL paths (e.g.,JailDetailPage.tsx→/jail/:name). Pages orchestrate the layout and compose multiple components, but contain no reusable UI logic. Pages should rarely be reused. -
components/contains reusable UI building blocks — anything that could plausibly be used on multiple pages or in multiple contexts. This includes:- Presentation components (Button wrappers, Cards, custom form fields, data tables)
- Feature sub-sections (e.g.,
JailInfoSection,BannedIpsSection— components that render a logical grouping of related UI within a page) - Modals, dialogs, popovers
- Complex, stateful UI patterns
Rule of thumb: If a component is only ever used on a single page, it still belongs in components/ if it represents a coherent, self-contained piece of UI that could logically be reused on another page in the future. Pages are entry points; components are building blocks.
Example: BannedIpsSection lives in components/jail/ (not pages/jail/) because it is a reusable UI section that presents banned IPs. If a future report or dashboard also needed to show banned IPs, the same component could be imported and reused. By contrast, JailDetailPage.tsx lives in pages/ because it is the top-level route component.
Separation of Concerns
- Pages handle routing and compose layout + components — they contain no business logic.
- Components are reusable, receive data via props, and emit changes via callbacks — they never call the API directly.
- Hooks encapsulate stateful logic, side effects, and API calls so components stay declarative.
- API layer handles all HTTP communication — components and hooks consume typed functions from
api/, never rawfetch. - Types are purely declarative — no runtime code in
types/files. - Utils are pure functions with no side effects and no React dependency.
- Theme contains exclusively Fluent UI custom token overrides and theme definitions — no component logic.
Providers — App-Wide vs Page-Scoped
The providers/ directory is reserved for app-wide context providers — providers that wrap the entire application or large sections of it and are used by many pages or components.
App-wide providers belong in providers/:
AuthProvider— authentication state for the whole appThemeProvider— theme/styling state for the whole appTimezoneProvider— timezone preference for the whole app
Page-scoped providers belong co-located with their consumer:
If a React Context provider is used by only one page (e.g., DashboardFilterProvider is used only by DashboardPage), it should live in the same directory as the page or in a subdirectory alongside the page's components. This prevents the providers/ directory from being cluttered with page-specific state and makes the scope of these providers clear to future contributors.
Example: DashboardFilterProvider manages dashboard time-range and origin filters. It is instantiated only inside DashboardPage.tsx and its sub-components. Therefore, it lives in pages/DashboardFilterProvider.tsx (or pages/dashboard/DashboardFilterProvider.tsx if the page is split into a subdirectory), not in providers/.
5. UI Framework — Fluent UI React (v9)
- Fluent UI React Components v9 (
@fluentui/react-components) is the only UI component library allowed — do not add alternative component libraries (Material UI, Chakra, Ant Design, etc.). - Install via npm:
npm install @fluentui/react-components @fluentui/react-icons
FluentProvider
- Wrap the entire application in
<FluentProvider>at the root — this supplies the theme and design tokens to all Fluent components. - The provider must sit above the router so every page inherits the theme.
// App.tsx
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { BrowserRouter } from "react-router-dom";
import AppRoutes from "./AppRoutes";
function App(): JSX.Element {
return (
<FluentProvider theme={webLightTheme}>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</FluentProvider>
);
}
export default App;
Theming & Design Tokens
- Use the built-in themes (
webLightTheme,webDarkTheme) as the base. - Customise design tokens by creating a custom theme in
theme/— never override Fluent styles with raw CSS. - Reference tokens via the
tokensobject from@fluentui/react-componentswhen writingmakeStylesrules. - If light/dark mode is needed, switch the
themeprop onFluentProvider— never duplicate style definitions for each mode.
// theme/customTheme.ts
import { createLightTheme, createDarkTheme } from "@fluentui/react-components";
import type { BrandVariants, Theme } from "@fluentui/react-components";
const brandColors: BrandVariants = {
10: "#020305",
// ... define brand colour ramp
160: "#e8ebf9",
};
export const lightTheme: Theme = createLightTheme(brandColors);
export const darkTheme: Theme = createDarkTheme(brandColors);
Styling with makeStyles (Griffel)
- All custom styling is done via
makeStylesfrom@fluentui/react-components— Fluent UI uses Griffel (CSS-in-JS with atomic classes) under the hood. - Never use inline
styleprops, global CSS, or external CSS frameworks for Fluent components. - Co-locate styles in the same file as the component they belong to, defined above the component function.
- Use
mergeClasseswhen combining multiple style sets conditionally. - Reference Fluent design tokens (
tokens.colorBrandBackground,tokens.fontSizeBase300, etc.) instead of hard-coded values — this ensures consistency and automatic theme support. - Inline styles are only allowed for genuinely dynamic values (e.g., tooltip position calculated from mouse position, or height derived from data count). All static layout properties (
display,gap,margin,padding, colour) must go inmakeStyles.
import { makeStyles, tokens, mergeClasses } from "@fluentui/react-components";
const useStyles = makeStyles({
root: {
padding: tokens.spacingVerticalM,
backgroundColor: tokens.colorNeutralBackground1,
},
highlighted: {
backgroundColor: tokens.colorPaletteRedBackground2,
},
});
function BanCard({ isHighlighted }: BanCardProps): JSX.Element {
const styles = useStyles();
return (
<div className={mergeClasses(styles.root, isHighlighted && styles.highlighted)}>
{/* ... */}
</div>
);
}
Component Usage Rules
- Always prefer Fluent UI components over plain HTML elements for interactive and presentational UI:
<Button>,<Input>,<Table>,<Dialog>,<Card>,<Badge>,<Spinner>,<Toast>,<MessageBar>, etc. - Use
<DataGrid>for data-heavy tables (ban lists, jail lists) — it provides sorting, selection, and accessibility out of the box. - Use Fluent UI
<Dialog>for modals and confirmations — never build custom modal overlays. - Use
@fluentui/react-iconsfor all icons — do not mix icon libraries. - Customise Fluent components only through their public API (props, slots,
makeStyles) — never patch internal DOM or override internal class names.
Libraries you must NOT use alongside Fluent UI
tailwindcss— usemakeStylesand design tokens.styled-components/emotion— Fluent UI uses Griffel; mixing CSS-in-JS runtimes causes conflicts.@mui/*,antd,chakra-ui— one design system only.- Global CSS files that target Fluent class names — use
makeStylesoverrides.
6. Component Rules
- One component per file. The filename matches the component name:
BanTable.tsxexportsBanTable. - Use function declarations for components — not arrow-function variables.
- Keep components small and focused — if a component exceeds ~150 lines, split it.
- Props are defined as an
interfacenamed<ComponentName>Propsin the same file (or imported fromtypes/if shared). - Destructure props in the function signature.
- Never mutate props or state directly — always use immutable update patterns.
- Avoid inline styles — use
makeStylesfrom Fluent UI for all custom styling (see section 5). - Supply a
keyprop whenever rendering lists — never use array indices as keys if the list can reorder. - Prefer Fluent UI components (
Button,Table,Input, …) over raw HTML elements for any interactive or styled element.
Tab Panels
- Never use
keyon a tab panel wrapper to switch between tabs. This causes the entire subtree to unmount and remount, destroying all state, pending saves, and form input. - Instead, render all tab panels and use CSS
display: none/display: blockto hide inactive tabs, keeping components mounted across tab switches. - All tab components remain mounted throughout the page lifetime. Hooks continue to run in hidden tabs — if a tab-specific effect must only run on activation, use an explicit activation flag rather than relying on mount/unmount.
import { Table, TableBody, TableRow, TableCell, Button } from "@fluentui/react-components";
import type { Ban } from "../types/ban";
interface BanTableProps {
bans: Ban[];
onUnban: (ip: string) => void;
}
function BanTable({ bans, onUnban }: BanTableProps): JSX.Element {
return (
<Table>
<TableBody>
{bans.map((ban) => (
<TableRow key={ban.ip}>
<TableCell>{ban.ip}</TableCell>
<TableCell>{ban.jail}</TableCell>
<TableCell>
<Button appearance="subtle" onClick={() => onUnban(ban.ip)}>Unban</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
export default BanTable;
7. Hooks & State Management
- Prefix custom hooks with
use— e.g.,useBans,useAuth,useJails. - Each hook lives in its own file under
hooks/. - Use
useStatefor local UI state,useReducerfor complex state transitions. - Use React Context sparingly — only for truly global concerns (auth, theme). Do not use context as a replacement for prop drilling one or two levels.
- Avoid
useEffectfor derived data — compute it during render or useuseMemo. - Always include the correct dependency arrays in
useEffect,useMemo, anduseCallback. Disable the ESLint exhaustive-deps rule only with a comment explaining why. - Clean up side effects (subscriptions, timers, abort controllers) in the
useEffectcleanup function.
Object Parameters in Hooks (Reference Stability)
When a hook accepts an object parameter, include it in dependency arrays only if it is guaranteed to be a stable reference. If callers pass inline object literals, the object reference changes on every render, causing unnecessary re-fetches and potential infinite loops.
Preferred solution: Design hook signatures to accept individual primitive parameters instead of objects. This makes incorrect usage a compile-time error:
// ❌ Footgun: query object causes infinite fetches if caller uses inline literals
export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
const load = useCallback(() => { /* ... */ }, [query]);
}
// Called like this (creates new object every render):
const result = useHistory({ page: 1, jail: selectedJail });
// ✅ Safe: individual primitives can't be accidentally unstable
export function useHistory(
page: number = 1,
pageSize: number = 50,
jail?: string,
): UseHistoryResult {
const load = useCallback(() => { /* ... */ }, [page, pageSize, jail]);
}
// Called like this (all primitives are stable):
const result = useHistory(page, PAGE_SIZE, jailFilter);
If refactoring to individual parameters is not feasible, document the constraint clearly in JSDoc and require callers to stabilize the reference using useMemo.
// hooks/useBans.ts
import { useState, useEffect } from "react";
import type { Ban } from "../types/ban";
import { fetchBans } from "../api/bans";
interface UseBansResult {
bans: Ban[];
loading: boolean;
error: string | null;
}
function useBans(hours: number): UseBansResult {
const [bans, setBans] = useState<Ban[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function load(): Promise<void> {
setLoading(true);
try {
const data = await fetchBans(hours);
setBans(data.bans);
setError(null);
} catch (err) {
if (!controller.signal.aborted) {
setError(err instanceof Error ? err.message : "Unknown error");
}
} finally {
setLoading(false);
}
}
load();
return () => controller.abort();
}, [hours]);
return { bans, loading, error };
}
export default useBans;
AbortController in Hooks
When using AbortController for fetch cancellation in hooks with mutable refs:
- Always capture the controller in a local
constvariable before the async operation. - Use that local variable in all callbacks (
.then(),.catch(),.finally()), never readabortRef.currentfrom inside an async callback. - This prevents race conditions: if
load()is called while a fetch is in flight, the previous fetch's callbacks will use the old, locally-captured controller reference, not the newly-assigned one.
Incorrect (reads abortRef.current in callback — this is racy):
const load = useCallback(() => {
const ctrl = new AbortController();
abortRef.current = ctrl;
fetchData()
.finally(() => {
if (!abortRef.current?.signal.aborted) { // ❌ Wrong: reads mutable ref
setLoading(false);
}
});
}, []);
Correct (uses local ctrl in all callbacks):
const load = useCallback(() => {
const ctrl = new AbortController();
abortRef.current = ctrl;
fetchData()
.finally(() => {
if (!ctrl.signal.aborted) { // ✅ Correct: uses locally-captured variable
setLoading(false);
}
});
}, []);
8. Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Components | PascalCase | BanTable, JailCard |
| Component files | PascalCase .tsx |
BanTable.tsx |
| Hooks | camelCase with use prefix |
useBans, useAuth |
| Hook files | camelCase .ts |
useBans.ts |
| Type / Interface | PascalCase | BanEntry, JailListResponse |
| Type files | camelCase .ts |
ban.ts, jail.ts |
| Utility functions | camelCase | formatDate, buildQuery |
| Constants | UPPER_SNAKE_CASE | MAX_RETRIES, API_BASE_URL |
| makeStyles hooks | useStyles (file-scoped) |
const useStyles = makeStyles({…}) |
| makeStyles keys | camelCase slot names | root, header, highlighted |
| Directories | lowercase kebab‑case or camelCase | components/, hooks/ |
| Boolean props/variables | is/has/should prefix |
isLoading, hasError |
9. Linting & Formatting
- ESLint with the following plugins is required:
@typescript-eslint/eslint-plugin— TypeScript-specific rules.eslint-plugin-react— React best practices.eslint-plugin-react-hooks— enforce rules of hooks.eslint-plugin-import— ordered and valid imports.
- Prettier handles all formatting — ESLint must not conflict with Prettier (use
eslint-config-prettier). - Format on save is expected — every developer must enable it in their editor.
- Run
eslint . --max-warnings 0andprettier --check .in CI — zero warnings, zero formatting diffs. - Line length: 100 characters max.
- Strings: use double quotes (
"). - Semicolons: always.
- Trailing commas: all (ES5+).
- Indentation: 2 spaces.
- No unused variables, no unused imports, no
@ts-ignorewithout an accompanying comment. - Import order (enforced by ESLint): React → third-party → aliases → relative, each group separated by a blank line.
// .prettierrc
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
10. Clean Code Principles
- Single Responsibility: Every function, hook, and component does one thing well.
- DRY (Don't Repeat Yourself): Extract repeated JSX into components, repeated logic into hooks or utils. If you copy-paste, refactor.
- KISS (Keep It Simple, Stupid): Prefer the simplest solution that works. Avoid premature abstraction.
- Meaningful Names: Variable and function names describe what, not how. Avoid abbreviations (
btn→button,idx→index) except universally understood ones (id,url,ip). - Small Functions: If a function exceeds ~30 lines, it likely does too much — split it.
- No Magic Numbers / Strings: Extract constants with descriptive names.
- Explicit over Implicit: Favor clarity over cleverness. Code is written once and read many times.
- No Dead Code: Remove unused functions, commented-out blocks, and unreachable branches before committing.
- Early Returns: Reduce nesting by returning early from guard clauses.
- Immutability: Default to
const. Use spread /map/filterinstead of mutating arrays and objects.
// Bad — magic number, unclear name
if (data.length > 50) { ... }
// Good
const MAX_VISIBLE_BANS = 50;
if (data.length > MAX_VISIBLE_BANS) { ... }
10. Authentication
Session Model
The authentication model is cookie-based:
-
Login: The frontend sends the master password (SHA256-hashed) to
POST /api/auth/login. The backend validates it, creates a session, and returns an HTTP response with aSet-Cookieheader containingbangui_session. -
Requests: All API requests automatically include the session cookie via
credentials: "include"in the fetch options. The frontend does not send an Authorization header or token in the request body. -
Session validity: The backend is the sole authority on whether a session is valid. The frontend is authenticated when the backend accepts the request (returns 2xx) and is not authenticated when the backend rejects it (returns 401 or 403).
-
Logout: The frontend sends
POST /api/auth/logout, and the backend invalidates the session and clears the cookie.
Frontend Auth State
- The
AuthProvidercontext (providers/AuthProvider.tsx) manages a simple booleanisAuthenticatedstate. - On successful login,
isAuthenticatedis set totrueand persisted tosessionStoragefor page-reload continuity. - On logout or when
SESSION_EXPIRED_EVENTfires (triggered by a 401/403 API response),isAuthenticatedis set tofalseand cleared fromsessionStorage. - The
sessionStorageentry (bangui_authenticated) survives page refreshes within the same tab but is automatically cleared when the tab closes. - The session cookie persists according to the backend's cookie settings (typically for the duration of the browser session or as configured server-side).
Why Not Token-Based?
The frontend previously stored JWT tokens in sessionStorage but never actually used them. The authentication model is entirely cookie-based (handled by the browser automatically), making stored tokens confusing and misleading. If token-based auth is needed in the future, the storage approach would need to change significantly (e.g., to include Authorization headers in all requests). For now, the only persistent state the frontend needs is the boolean isAuthenticated flag.
Error Handling
When an API request returns 401 or 403:
- The
client.tsmodule dispatches aSESSION_EXPIRED_EVENT. - The
AuthProviderlistener handles it by clearingisAuthenticatedand redirecting to/login. - Hooks must use
handleFetchError(fromutils/fetchError.ts) to avoid displaying auth errors as user-facing error messages.
12. Error Handling
- Wrap API calls in
try-catchinside hooks — components should never see raw exceptions. - All hook catch blocks must use
handleFetchErrorrather than directly callingsetError. This ensures auth errors (401/403) are routed to the global session-expiry flow instead of displaying confusing error text in the UI. Use the pattern:handleFetchError(err, setError, "User-friendly fallback message"). - Display user-friendly error messages — never expose stack traces or raw server responses in the UI.
- Use an error boundary (
ErrorBoundarycomponent) at the page level to catch unexpected render errors. - Log errors to the console (or a future logging service) with sufficient context for debugging.
- Always handle the loading, error, and empty states for every data-driven component.
13. Performance
- Use
React.memoonly when profiling reveals unnecessary re-renders — do not wrap every component by default. - Use
useMemoanduseCallbackfor expensive computations and stable callback references passed to child components — not for trivial values. - Lazy-load route-level pages with
React.lazy+Suspenseto reduce initial bundle size. - Avoid creating new objects or arrays inside render unless necessary — stable references prevent child re-renders.
- Keep bundle size in check — review dependencies before adding them and prefer lightweight alternatives.
14. Accessibility
- Use semantic HTML elements (
<button>,<nav>,<table>,<main>,<header>) — not<div>with click handlers. - Every interactive element must be keyboard accessible (focusable, operable with Enter/Space/Escape as appropriate).
- Images and icons require
alttext oraria-label. - Form inputs must have associated
<label>elements. - Maintain sufficient color contrast ratios (WCAG AA minimum).
- Test with a screen reader periodically.
15. Testing
- Write tests for every new component, hook, and utility function.
- Use Vitest (or Jest) as the test runner and React Testing Library for component tests.
- Test behavior, not implementation — query by role, label, or text, never by CSS class or internal state.
- Mock API calls at the network layer (e.g.,
msw— Mock Service Worker) — components should not know about mocks. - Aim for >80 % line coverage — critical paths (auth flow, ban/unban actions) must be 100 %.
- Test name pattern:
it("should <expected behavior> when <condition>"). - Wrap components under test in
<FluentProvider>so Fluent UI styles and tokens resolve correctly:
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import BanTable from "./BanTable";
import type { Ban } from "../types/ban";
const mockBans: Ban[] = [
{ ip: "192.168.1.1", jail: "sshd", bannedAt: "2026-02-28T12:00:00Z", expiresAt: null, banCount: 3, country: "DE" },
];
function renderWithProvider(ui: JSX.Element) {
return render(<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>);
}
it("should render a row for each ban", () => {
renderWithProvider(<BanTable bans={mockBans} onUnban={vi.fn()} />);
expect(screen.getByText("192.168.1.1")).toBeInTheDocument();
});
15. Git & Workflow
- Branch naming:
feature/<short-description>,fix/<short-description>,chore/<short-description>. - Commit messages: imperative tense, max 72 chars first line (
Add ban table component,Fix date formatting in dashboard). - Every merge request must pass: ESLint, Prettier, TypeScript compiler, all tests.
- Do not merge with failing CI.
- Keep pull requests small and focused — one feature or fix per PR.
- Review your own diff before requesting review.
16. Quick Reference — Do / Don't
| Do | Don't |
|---|---|
| Type every prop, state, and return value | Use any or leave types implicit |
Keep shared types in types/ |
Duplicate interfaces across files |
| Call API from hooks, not components | Scatter fetch calls across the codebase |
Use import type for type-only imports |
Import types as regular imports |
| One component per file | Export multiple components from one file |
| Destructure props in the signature | Access props.x throughout the body |
| Use Fluent UI components for all interactive UI | Build custom buttons, inputs, dialogs from scratch |
Style with makeStyles + design tokens |
Use inline styles, global CSS, or Tailwind |
Wrap the app in <FluentProvider> |
Render Fluent components outside a provider |
Use @fluentui/react-icons for icons |
Mix multiple icon libraries |
| Use semantic HTML elements | Use <div> for everything |
| Handle loading, error, and empty states | Only handle the happy path |
Name booleans with is/has/should |
Name booleans as plain nouns (loading) |
| Extract constants for magic values | Hard-code numbers and strings |
| Clean up effects (abort, unsub) | Let effects leak resources |
| Format and lint before every commit | Push code that doesn't pass CI |