Initial commit
This commit is contained in:
129
packages/navigation/AGENTS.md
Normal file
129
packages/navigation/AGENTS.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# AGENTS.md — navigation
|
||||
|
||||
Header global for main site navigation. Provides the Header Payload global and tRPC procedures for dynamic navigation content.
|
||||
|
||||
## Overview
|
||||
|
||||
`@repo/navigation` owns: Header and HeaderItem domain models, navigation-scoped errors, the `IHeaderRepository` interface, one use case, one controller, a real Payload-backed repository, and the tRPC `navigationRouter`. The `headerQuery` React Query builder lives in `./ui`.
|
||||
|
||||
## Layer responsibilities
|
||||
|
||||
| Layer | Key files |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| **entities/models** | `header.ts` — `Header`, `HeaderItem` Zod schemas + types |
|
||||
| **entities/errors** | `header.ts` (HeaderNotFoundError), `common.ts` (InputParseError) |
|
||||
| **application/use-cases** | `get-header.use-case.ts` — factory function + exported schemas |
|
||||
| **application/repositories** | `header.repository.interface.ts` — `IHeaderRepository` |
|
||||
| **infrastructure/repositories** | `header.repository.ts` (real Payload-backed), `header.repository.mock.ts` (in-memory) |
|
||||
| **interface-adapters/controllers** | `get-header.controller.ts` — one file per use case |
|
||||
| **di** | `symbols.ts` (NAVIGATION_SYMBOLS), `module.ts`, `container.ts`, `bind-production.ts` |
|
||||
| **integrations/api** | `procedures.ts` (navigationProcedure), `router.ts` (navigationRouter) |
|
||||
| **integrations/cms** | `globals/header.ts` — Payload Header GlobalConfig |
|
||||
| **ui** | `src/ui/index.ts` — re-exports `headerQuery` |
|
||||
|
||||
## Public exports
|
||||
|
||||
| Subpath | Contents |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `.` | `Header`, `HeaderItem` types; `HeaderNotFoundError`, `InputParseError`; `getHeaderInputSchema`, `getHeaderOutputSchema`, `GetHeaderInput`, `GetHeaderOutput`, `IGetHeaderUseCase`; `IGetHeaderController` type alias; `NavigationRouter` type |
|
||||
| `./ui` | `headerQuery` — React Query option builder |
|
||||
| `./api` | `navigationRouter` (tRPC router) |
|
||||
| `./cms` | Payload Header global definition |
|
||||
| `./di/bind-production` | `bindProductionNavigation(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedNavigation(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
|
||||
## Use-case + controller patterns
|
||||
|
||||
See `CLAUDE.md` Key Conventions and `docs/architecture/overview.md` for the canonical factory templates.
|
||||
|
||||
### Use case
|
||||
|
||||
| Use case | Input schema | Output schema | Notes |
|
||||
| ------------------ | ------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `getHeaderUseCase` | `getHeaderInputSchema` — `z.object({}).strict()` (void input) | `getHeaderOutputSchema` — `= headerSchema` | Takes `_input: GetHeaderInput`; throws `HeaderNotFoundError` when repository returns falsy; ends with `getHeaderOutputSchema.parse(header)` |
|
||||
|
||||
### Controller
|
||||
|
||||
`getHeaderController` uses an identity presenter — `function presenter(value: GetHeaderOutput) { return value; }` — and returns `Promise<ReturnType<typeof presenter>>`. Accepts `unknown` input and `safeParse` with `getHeaderInputSchema`, throwing `InputParseError` on failure.
|
||||
|
||||
## Errors → tRPC codes
|
||||
|
||||
| Error class | tRPC code | Thrown by |
|
||||
| --------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `InputParseError` | `BAD_REQUEST` | controller (safeParse failure; also triggers on `strict()` rejecting unknown keys) |
|
||||
| `HeaderNotFoundError` | `NOT_FOUND` | `getHeaderUseCase` when repository returns falsy |
|
||||
|
||||
Defined in `src/integrations/api/procedures.ts` via `navigationProcedure = t.procedure.use(defineErrorMiddleware([...]))`.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Factories:** `src/__factories__/header.factory.ts`, `src/__factories__/nav-item.factory.ts`
|
||||
- **Contract suite:** `src/__contracts__/header-repository.contract.ts` — runs against mock and real `HeaderRepository`
|
||||
- **Unit tests:** colocated `*.test.ts` next to each source file
|
||||
- **R25** (output validation): `get-header.use-case.test.ts` has a test using an inline malformed repository mock (e.g., `{ items: [{ label: "", href: "/", external: false }] }`, label failing `min(1)`) to assert `.rejects.toBeInstanceOf(ZodError)`.
|
||||
- **R26** (router error mapping): `router.test.ts` asserts `BAD_REQUEST` when input has extra unknown keys (strict mode rejection → InputParseError), and `NOT_FOUND` via an inline `NullHeaderRepository` rebind causing `HeaderNotFoundError`.
|
||||
|
||||
```bash
|
||||
pnpm test --filter @repo/navigation
|
||||
pnpm test --filter @repo/navigation -- --watch
|
||||
```
|
||||
|
||||
See `docs/guides/tdd-workflow.md` for the full cycle.
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
src/
|
||||
entities/
|
||||
models/
|
||||
header.ts # Header, HeaderItem schemas + types
|
||||
errors/
|
||||
header.ts # HeaderNotFoundError
|
||||
common.ts # InputParseError
|
||||
application/
|
||||
repositories/
|
||||
header.repository.interface.ts
|
||||
use-cases/
|
||||
get-header.use-case.ts
|
||||
infrastructure/
|
||||
repositories/
|
||||
header.repository.ts # real Payload-backed
|
||||
header.repository.mock.ts
|
||||
interface-adapters/
|
||||
controllers/
|
||||
get-header.controller.ts
|
||||
integrations/
|
||||
api/
|
||||
procedures.ts # navigationProcedure
|
||||
router.ts # navigationRouter
|
||||
cms/
|
||||
globals/
|
||||
header.ts
|
||||
index.ts
|
||||
di/
|
||||
symbols.ts
|
||||
module.ts
|
||||
container.ts
|
||||
bind-production.ts
|
||||
ui/
|
||||
index.ts # headerQuery
|
||||
query.ts
|
||||
index.ts
|
||||
__factories__/
|
||||
header.factory.ts
|
||||
nav-item.factory.ts
|
||||
__contracts__/
|
||||
header-repository.contract.ts
|
||||
```
|
||||
|
||||
## What it must NOT import
|
||||
|
||||
- Any other feature package (`@repo/auth`, `@repo/blog`, etc.)
|
||||
- Any app package
|
||||
- `@repo/core-api`, `@repo/core-cms`, `@repo/core-trpc`, `@repo/core-ui` directly; only `@repo/core-shared`
|
||||
> Note: `@repo/core-trpc` and `@repo/core-ui` are optional packages scaffolded via `pnpm turbo gen core-package trpc` / `ui`. If not present, these constraints still apply to any future installation.
|
||||
|
||||
## Cross-links
|
||||
|
||||
- ADR-012 (`docs/decisions/adr-012-feature-conventions.md`) — factory-style use cases, per-use-case controllers, file-naming conventions
|
||||
- ADR-013 (`docs/decisions/adr-013-input-output-unification.md`) — schemas-in-use-case, presenter, `./ui` subpath, error middleware
|
||||
11
packages/navigation/CHANGELOG.md
Normal file
11
packages/navigation/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Changelog — @repo/navigation
|
||||
|
||||
All notable changes to the `navigation` feature package. Maintained by [release-please](https://github.com/googleapis/release-please) on merges to `main`. See [ADR-021](../../docs/decisions/adr-021-versioning-and-changelog.md) and [`docs/guides/releasing.md`](../../docs/guides/releasing.md).
|
||||
|
||||
## 0.1.0 (2026-05-13)
|
||||
|
||||
### Initial baseline
|
||||
|
||||
The `navigation` feature established at v0.1.0 alongside the hybrid versioning rollout (ADR-021). The feature has been stable since the template-reset cleanup; this is the first formally versioned baseline.
|
||||
|
||||
Future entries appear above this section as release-please assembles them from conventional commits scoped to `packages/navigation/**` since the last release.
|
||||
3
packages/navigation/eslint.config.js
Normal file
3
packages/navigation/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
41
packages/navigation/package.json
Normal file
41
packages/navigation/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@repo/navigation",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cms": "./src/integrations/cms/index.ts",
|
||||
"./api": "./src/integrations/api/router.ts",
|
||||
"./di/bind-production": "./src/di/bind-production.ts",
|
||||
"./di/bind-dev-seed": "./src/di/bind-dev-seed.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@repo/core-trpc": "workspace:^",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@trpc/client": "^11.17.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"inversify": "^6.2.0",
|
||||
"payload": "^3.14.0",
|
||||
"react": "^19.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { it, expect, beforeEach, describe } from "vitest";
|
||||
import { defineContractSuite } from "@repo/core-testing/contract";
|
||||
import type { IHeaderRepository } from "../application/repositories/header.repository.interface";
|
||||
import type { Header } from "../entities/models/header";
|
||||
|
||||
/**
|
||||
* Known fixtures that every implementation's `buildSubject` must pre-seed.
|
||||
* Exported so that test files can pass them to `MockHeaderRepository` or the
|
||||
* Payload stub without duplicating definitions.
|
||||
*/
|
||||
export const CONTRACT_HEADER_SEED: Header = {
|
||||
items: [
|
||||
{ label: "Home", href: "/", external: false },
|
||||
{ label: "Blog", href: "/blog", external: false },
|
||||
{ label: "Docs", href: "/docs", external: true },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Contract for IHeaderRepository.
|
||||
*
|
||||
* Header is a singleton (Payload Global). The interface exposes only
|
||||
* getHeader(). The contract verifies the shape, count, and order of items.
|
||||
*/
|
||||
export const headerRepositoryContract = defineContractSuite<IHeaderRepository>(
|
||||
"IHeaderRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IHeaderRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
repo = await buildSubject();
|
||||
});
|
||||
|
||||
// --- getHeader ---
|
||||
|
||||
it("getHeader returns an object with an items array of the seeded length", async () => {
|
||||
const header = await repo.getHeader();
|
||||
expect(header).toBeDefined();
|
||||
expect(header.items).toBeInstanceOf(Array);
|
||||
expect(header.items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("getHeader items appear in the seeded order with correct shape", async () => {
|
||||
const header = await repo.getHeader();
|
||||
expect(header.items[0]?.label).toBe("Home");
|
||||
expect(header.items[0]?.href).toBe("/");
|
||||
expect(header.items[0]?.external).toBe(false);
|
||||
expect(header.items[1]?.label).toBe("Blog");
|
||||
expect(header.items[1]?.href).toBe("/blog");
|
||||
expect(header.items[1]?.external).toBe(false);
|
||||
expect(header.items[2]?.label).toBe("Docs");
|
||||
expect(header.items[2]?.href).toBe("/docs");
|
||||
expect(header.items[2]?.external).toBe(true);
|
||||
});
|
||||
|
||||
it("getHeader items have label, href, and external fields", async () => {
|
||||
const header = await repo.getHeader();
|
||||
for (const item of header.items) {
|
||||
expect(typeof item.label).toBe("string");
|
||||
expect(item.label.length).toBeGreaterThan(0);
|
||||
expect(typeof item.href).toBe("string");
|
||||
expect(item.href.length).toBeGreaterThan(0);
|
||||
expect(typeof item.external).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("getHeader logoId is string or undefined", async () => {
|
||||
const header = await repo.getHeader();
|
||||
expect(
|
||||
header.logoId === undefined || typeof header.logoId === "string",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("span emission", () => {
|
||||
it("getHeader emits header.getHeader span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getHeader();
|
||||
const span = tracer.findSpan("header.getHeader");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
36
packages/navigation/src/__factories__/header.factory.test.ts
Normal file
36
packages/navigation/src/__factories__/header.factory.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { headerFactory } from "@/__factories__/header.factory";
|
||||
|
||||
describe("headerFactory", () => {
|
||||
beforeEach(() => headerFactory.reset());
|
||||
|
||||
it("returns a Header with stable defaults", () => {
|
||||
const h = headerFactory.build();
|
||||
expect(h.items).toHaveLength(0);
|
||||
expect(h.logoId).toBe("logo-1");
|
||||
});
|
||||
|
||||
it("applies overrides", () => {
|
||||
const h = headerFactory.build({
|
||||
logoId: "logo-42",
|
||||
items: [{ label: "Home", href: "/", external: false }],
|
||||
});
|
||||
expect(h.logoId).toBe("logo-42");
|
||||
expect(h.items).toHaveLength(1);
|
||||
expect(h.items[0]?.label).toBe("Home");
|
||||
});
|
||||
|
||||
it("builds multiple headers independently", () => {
|
||||
const a = headerFactory.build({ items: [{ label: "A", href: "/a", external: false }] });
|
||||
const b = headerFactory.build({ items: [] });
|
||||
expect(a.items).toHaveLength(1);
|
||||
expect(b.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("logoId reflects sequence for deterministic buildList output", () => {
|
||||
const first = headerFactory.build();
|
||||
const second = headerFactory.build();
|
||||
expect(first.logoId).toBe("logo-1");
|
||||
expect(second.logoId).toBe("logo-2");
|
||||
});
|
||||
});
|
||||
7
packages/navigation/src/__factories__/header.factory.ts
Normal file
7
packages/navigation/src/__factories__/header.factory.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineFactory } from "@repo/core-testing/factory";
|
||||
import type { Header } from "../entities/models/header";
|
||||
|
||||
export const headerFactory = defineFactory<Header>(({ sequence }) => ({
|
||||
logoId: `logo-${sequence}`,
|
||||
items: [],
|
||||
}));
|
||||
2
packages/navigation/src/__factories__/index.ts
Normal file
2
packages/navigation/src/__factories__/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { headerFactory } from "./header.factory";
|
||||
export { navItemFactory } from "./nav-item.factory";
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { navItemFactory } from "@/__factories__/nav-item.factory";
|
||||
|
||||
describe("navItemFactory", () => {
|
||||
beforeEach(() => navItemFactory.reset());
|
||||
|
||||
it("returns a HeaderItem with stable defaults", () => {
|
||||
const item = navItemFactory.build();
|
||||
expect(item.label).toBe("Item 1");
|
||||
expect(item.href).toBe("/item-1");
|
||||
expect(item.external).toBe(false);
|
||||
});
|
||||
|
||||
it("applies overrides", () => {
|
||||
const item = navItemFactory.build({ label: "Home", href: "/", external: true });
|
||||
expect(item.label).toBe("Home");
|
||||
expect(item.href).toBe("/");
|
||||
expect(item.external).toBe(true);
|
||||
});
|
||||
|
||||
it("increments sequence per build", () => {
|
||||
const a = navItemFactory.build();
|
||||
const b = navItemFactory.build();
|
||||
expect(a.label).toBe("Item 1");
|
||||
expect(b.label).toBe("Item 2");
|
||||
expect(a.href).toBe("/item-1");
|
||||
expect(b.href).toBe("/item-2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineFactory } from "@repo/core-testing/factory";
|
||||
import type { HeaderItem } from "../entities/models/header";
|
||||
|
||||
export const navItemFactory = defineFactory<HeaderItem>(({ sequence }) => ({
|
||||
label: `Item ${sequence}`,
|
||||
href: `/item-${sequence}`,
|
||||
external: false,
|
||||
}));
|
||||
30
packages/navigation/src/__seeds__/dev.ts
Normal file
30
packages/navigation/src/__seeds__/dev.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { headerFactory } from "../__factories__/header.factory";
|
||||
import { navItemFactory } from "../__factories__/nav-item.factory";
|
||||
import type { Header } from "../entities/models/header";
|
||||
|
||||
/**
|
||||
* Realistic navigation seed for dev mode + storybook stories.
|
||||
*
|
||||
* Built from `headerFactory` and `navItemFactory` so factory defaults take
|
||||
* care of boring fields and we only override what makes the nav look like a
|
||||
* real site header.
|
||||
*
|
||||
* Lazily produced so importing this module is side-effect-free — the
|
||||
* factory's sequence counter only advances when a binder calls
|
||||
* `buildDevHeader()`.
|
||||
*/
|
||||
export function buildDevHeader(): Header {
|
||||
return headerFactory.build({
|
||||
logoId: "logo-main",
|
||||
items: [
|
||||
navItemFactory.build({ label: "Home", href: "/", external: false }),
|
||||
navItemFactory.build({ label: "Blog", href: "/blog", external: false }),
|
||||
navItemFactory.build({ label: "About", href: "/about", external: false }),
|
||||
navItemFactory.build({
|
||||
label: "Pricing",
|
||||
href: "/pricing",
|
||||
external: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Header } from "../../entities/models/header";
|
||||
|
||||
export interface IHeaderRepository {
|
||||
getHeader(): Promise<Header>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { getHeaderUseCase } from "@/application/use-cases/get-header.use-case";
|
||||
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
||||
|
||||
describe("getHeaderUseCase", () => {
|
||||
it("returns the seeded header items", async () => {
|
||||
const repo = new MockHeaderRepository();
|
||||
const useCase = getHeaderUseCase(repo);
|
||||
const result = await useCase({});
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(result.items[0]?.label).toBe("Home");
|
||||
});
|
||||
|
||||
it("throws ZodError when repository returns malformed header", async () => {
|
||||
const malformedRepo = {
|
||||
getHeader: async () =>
|
||||
({ items: [{ label: "", href: "/", external: false }] }) as never,
|
||||
};
|
||||
const useCase = getHeaderUseCase(malformedRepo);
|
||||
await expect(useCase({})).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { HeaderNotFoundError } from "../../entities/errors/header";
|
||||
import { headerSchema } from "../../entities/models/header";
|
||||
import type { IHeaderRepository } from "../repositories/header.repository.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const getHeaderInputSchema = z.object({}).strict();
|
||||
export type GetHeaderInput = z.infer<typeof getHeaderInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const getHeaderOutputSchema = headerSchema;
|
||||
export type GetHeaderOutput = z.infer<typeof getHeaderOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type IGetHeaderUseCase = ReturnType<typeof getHeaderUseCase>;
|
||||
|
||||
export const getHeaderUseCase =
|
||||
(headerRepository: IHeaderRepository) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async (_input: GetHeaderInput): Promise<GetHeaderOutput> => {
|
||||
const header = await headerRepository.getHeader();
|
||||
if (!header) {
|
||||
throw new HeaderNotFoundError("Header global not found");
|
||||
}
|
||||
return getHeaderOutputSchema.parse(header);
|
||||
};
|
||||
76
packages/navigation/src/di/bind-dev-seed.test.ts
Normal file
76
packages/navigation/src/di/bind-dev-seed.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||
import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation";
|
||||
import { bindDevSeedNavigation } from "@/di/bind-dev-seed";
|
||||
import { navigationContainer } from "@/di/container";
|
||||
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
|
||||
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
||||
import type { IHeaderRepository } from "@/application/repositories/header.repository.interface";
|
||||
|
||||
const noop = { tracer: new NoopTracer(), logger: new NoopLogger() };
|
||||
|
||||
describe("bindDevSeedNavigation", () => {
|
||||
// Each test starts from the default mock binding and tears down afterwards
|
||||
// so the global navigationContainer state stays clean for siblings.
|
||||
beforeEach(() => {
|
||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
||||
}
|
||||
navigationContainer
|
||||
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
|
||||
.to(MockHeaderRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
||||
}
|
||||
navigationContainer
|
||||
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
|
||||
.to(MockHeaderRepository);
|
||||
});
|
||||
|
||||
it("populates the header repository with the dev header", async () => {
|
||||
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
|
||||
const repo = navigationContainer.get<IHeaderRepository>(
|
||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||
);
|
||||
const header = await repo.getHeader();
|
||||
|
||||
expect(header).toBeDefined();
|
||||
});
|
||||
|
||||
it("seeds a header with a non-empty items array", async () => {
|
||||
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
|
||||
const repo = navigationContainer.get<IHeaderRepository>(
|
||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||
);
|
||||
const header = await repo.getHeader();
|
||||
|
||||
expect(header.items.length).toBeGreaterThan(0);
|
||||
const homeItem = header.items.find((item) => item.label === "Home");
|
||||
expect(homeItem).toBeDefined();
|
||||
expect(homeItem?.href).toBe("/");
|
||||
});
|
||||
|
||||
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
||||
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
const before = navigationContainer.get<IHeaderRepository>(
|
||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||
);
|
||||
const beforeHeader = await before.getHeader();
|
||||
|
||||
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
const after = navigationContainer.get<IHeaderRepository>(
|
||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||
);
|
||||
const afterHeader = await after.getHeader();
|
||||
|
||||
expect(afterHeader.items.length).toBe(beforeHeader.items.length);
|
||||
// It's a fresh instance — not the previous one.
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
});
|
||||
107
packages/navigation/src/di/bind-dev-seed.ts
Normal file
107
packages/navigation/src/di/bind-dev-seed.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
withSpan,
|
||||
withCapture,
|
||||
INSTRUMENTATION_SYMBOLS,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
assertFeatureConformance,
|
||||
wireUseCase,
|
||||
} from "@repo/core-shared/conformance";
|
||||
import { navigationManifest } from "../feature.manifest";
|
||||
import { navigationContainer } from "./container";
|
||||
import { NAVIGATION_SYMBOLS } from "./symbols";
|
||||
import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock";
|
||||
import { buildDevHeader } from "../__seeds__/dev";
|
||||
import { getHeaderUseCase } from "../application/use-cases/get-header.use-case";
|
||||
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
|
||||
import type { IHeaderRepository } from "../application/repositories/header.repository.interface";
|
||||
|
||||
/**
|
||||
* Replace the default mock with a populated one for dev mode + storybook.
|
||||
*
|
||||
* Call this from app boot when `USE_DEV_SEED=true`, mutually exclusive with
|
||||
* `bindProductionNavigation(config)`. Tests must NOT call this — they
|
||||
* construct `new MockHeaderRepository()` directly and seed via factories
|
||||
* per-test.
|
||||
*
|
||||
* Idempotent: safe to call multiple times; each call rebuilds a fresh
|
||||
* populated repo and rebinds the symbol.
|
||||
*/
|
||||
export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
|
||||
const { tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
|
||||
}
|
||||
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
navigationContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
navigationContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
||||
}
|
||||
const repo = new MockHeaderRepository(buildDevHeader(), tracer, logger);
|
||||
navigationContainer
|
||||
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
|
||||
.toConstantValue(repo);
|
||||
|
||||
// Use case
|
||||
const wrappedGetHeader = wireUseCase({
|
||||
container: navigationContainer,
|
||||
symbol: NAVIGATION_SYMBOLS.IGetHeaderUseCase,
|
||||
factory: getHeaderUseCase,
|
||||
deps: [repo],
|
||||
feature: "navigation",
|
||||
layer: "use-case",
|
||||
name: "getHeader",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IGetHeaderController)) {
|
||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IGetHeaderController);
|
||||
}
|
||||
navigationContainer
|
||||
.bind(NAVIGATION_SYMBOLS.IGetHeaderController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "navigation.getHeader", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{
|
||||
feature: "navigation",
|
||||
layer: "controller",
|
||||
name: "navigation.getHeader",
|
||||
},
|
||||
getHeaderController(wrappedGetHeader),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
void realtimeRegistry;
|
||||
// <gen:event-handlers>
|
||||
// <gen:jobs>
|
||||
// <gen:realtime-handlers>
|
||||
|
||||
// Boot-time conformance check (dev-seed mode).
|
||||
assertFeatureConformance(
|
||||
navigationContainer,
|
||||
navigationManifest,
|
||||
{ getHeader: NAVIGATION_SYMBOLS.IGetHeaderUseCase },
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
17
packages/navigation/src/di/bind-production.smoke.test.ts
Normal file
17
packages/navigation/src/di/bind-production.smoke.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import "reflect-metadata";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||
import { bindProductionNavigation } from "@/di/bind-production";
|
||||
|
||||
describe("bindProductionNavigation — boot-time conformance", () => {
|
||||
it("binds every manifest use case through withSpan + withCapture", () => {
|
||||
expect(() =>
|
||||
bindProductionNavigation({
|
||||
config: {} as SanitizedConfig,
|
||||
tracer: new NoopTracer(),
|
||||
logger: new NoopLogger(),
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
97
packages/navigation/src/di/bind-production.ts
Normal file
97
packages/navigation/src/di/bind-production.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
withSpan,
|
||||
withCapture,
|
||||
INSTRUMENTATION_SYMBOLS,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindProductionContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
assertFeatureConformance,
|
||||
wireUseCase,
|
||||
} from "@repo/core-shared/conformance";
|
||||
import { navigationContainer } from "./container";
|
||||
import { NAVIGATION_SYMBOLS } from "./symbols";
|
||||
import { navigationManifest } from "../feature.manifest";
|
||||
import { HeaderRepository } from "../infrastructure/repositories/header.repository";
|
||||
import { getHeaderUseCase } from "../application/use-cases/get-header.use-case";
|
||||
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
|
||||
|
||||
export function bindProductionNavigation(ctx: BindProductionContext): void {
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
|
||||
ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
|
||||
}
|
||||
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
navigationContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
navigationContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
// Real repository
|
||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
||||
}
|
||||
const repo = new HeaderRepository(config, tracer, logger);
|
||||
navigationContainer
|
||||
.bind(NAVIGATION_SYMBOLS.IHeaderRepository)
|
||||
.toConstantValue(repo);
|
||||
|
||||
// Use case
|
||||
const wrappedGetHeader = wireUseCase({
|
||||
container: navigationContainer,
|
||||
symbol: NAVIGATION_SYMBOLS.IGetHeaderUseCase,
|
||||
factory: getHeaderUseCase,
|
||||
deps: [repo],
|
||||
feature: "navigation",
|
||||
layer: "use-case",
|
||||
name: "getHeader",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Controller — wrapped with span at bind time
|
||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IGetHeaderController)) {
|
||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IGetHeaderController);
|
||||
}
|
||||
navigationContainer
|
||||
.bind(NAVIGATION_SYMBOLS.IGetHeaderController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "navigation.getHeader", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{
|
||||
feature: "navigation",
|
||||
layer: "controller",
|
||||
name: "navigation.getHeader",
|
||||
},
|
||||
getHeaderController(wrappedGetHeader),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
void realtimeRegistry;
|
||||
// <gen:event-handlers>
|
||||
// <gen:jobs>
|
||||
// <gen:realtime-handlers>
|
||||
|
||||
// Boot-time conformance check.
|
||||
assertFeatureConformance(
|
||||
navigationContainer,
|
||||
navigationManifest,
|
||||
{ getHeader: NAVIGATION_SYMBOLS.IGetHeaderUseCase },
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
40
packages/navigation/src/di/container.test.ts
Normal file
40
packages/navigation/src/di/container.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { navigationContainer } from "./container";
|
||||
import { NAVIGATION_SYMBOLS } from "./symbols";
|
||||
import { NavigationModule } from "./module";
|
||||
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
||||
import type { IHeaderRepository } from "@/application/repositories/header.repository.interface";
|
||||
import type { IGetHeaderUseCase } from "@/application/use-cases/get-header.use-case";
|
||||
import type { IGetHeaderController } from "@/interface-adapters/controllers/get-header.controller";
|
||||
|
||||
describe("navigationContainer", () => {
|
||||
beforeEach(() => {
|
||||
navigationContainer.unbindAll();
|
||||
navigationContainer.load(NavigationModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
navigationContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("resolves IHeaderRepository to MockHeaderRepository", () => {
|
||||
const repo = navigationContainer.get<IHeaderRepository>(
|
||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
||||
);
|
||||
expect(repo).toBeInstanceOf(MockHeaderRepository);
|
||||
});
|
||||
|
||||
it("resolves IGetHeaderUseCase as a function", () => {
|
||||
const useCase = navigationContainer.get<IGetHeaderUseCase>(
|
||||
NAVIGATION_SYMBOLS.IGetHeaderUseCase,
|
||||
);
|
||||
expect(typeof useCase).toBe("function");
|
||||
});
|
||||
|
||||
it("resolves IGetHeaderController as a function", () => {
|
||||
const controller = navigationContainer.get<IGetHeaderController>(
|
||||
NAVIGATION_SYMBOLS.IGetHeaderController,
|
||||
);
|
||||
expect(typeof controller).toBe("function");
|
||||
});
|
||||
});
|
||||
6
packages/navigation/src/di/container.ts
Normal file
6
packages/navigation/src/di/container.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import "reflect-metadata";
|
||||
import { Container } from "inversify";
|
||||
import { NavigationModule } from "./module";
|
||||
|
||||
export const navigationContainer = new Container({ defaultScope: "Singleton" });
|
||||
navigationContainer.load(NavigationModule);
|
||||
33
packages/navigation/src/di/module.ts
Normal file
33
packages/navigation/src/di/module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ContainerModule, type interfaces } from "inversify";
|
||||
|
||||
import type { IHeaderRepository } from "../application/repositories/header.repository.interface";
|
||||
import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock";
|
||||
import {
|
||||
getHeaderUseCase,
|
||||
type IGetHeaderUseCase,
|
||||
} from "../application/use-cases/get-header.use-case";
|
||||
import {
|
||||
getHeaderController,
|
||||
type IGetHeaderController,
|
||||
} from "../interface-adapters/controllers/get-header.controller";
|
||||
import { NAVIGATION_SYMBOLS } from "./symbols";
|
||||
|
||||
export const NavigationModule = new ContainerModule((bind: interfaces.Bind) => {
|
||||
bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository).to(
|
||||
MockHeaderRepository,
|
||||
);
|
||||
|
||||
bind<IGetHeaderUseCase>(NAVIGATION_SYMBOLS.IGetHeaderUseCase).toDynamicValue(
|
||||
(ctx) =>
|
||||
getHeaderUseCase(
|
||||
ctx.container.get<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository),
|
||||
),
|
||||
);
|
||||
|
||||
bind<IGetHeaderController>(NAVIGATION_SYMBOLS.IGetHeaderController).toDynamicValue(
|
||||
(ctx) =>
|
||||
getHeaderController(
|
||||
ctx.container.get<IGetHeaderUseCase>(NAVIGATION_SYMBOLS.IGetHeaderUseCase),
|
||||
),
|
||||
);
|
||||
});
|
||||
10
packages/navigation/src/di/symbols.ts
Normal file
10
packages/navigation/src/di/symbols.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const NAVIGATION_SYMBOLS = {
|
||||
IHeaderRepository: Symbol.for("navigation:IHeaderRepository"),
|
||||
// Use cases
|
||||
IGetHeaderUseCase: Symbol.for("navigation:IGetHeaderUseCase"),
|
||||
// Controllers
|
||||
IGetHeaderController: Symbol.for("navigation:IGetHeaderController"),
|
||||
// <gen:event-handler-symbols>
|
||||
// <gen:job-symbols>
|
||||
// <gen:realtime-handler-symbols>
|
||||
} as const;
|
||||
6
packages/navigation/src/entities/errors/common.ts
Normal file
6
packages/navigation/src/entities/errors/common.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class InputParseError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "InputParseError";
|
||||
}
|
||||
}
|
||||
6
packages/navigation/src/entities/errors/header.ts
Normal file
6
packages/navigation/src/entities/errors/header.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class HeaderNotFoundError extends Error {
|
||||
constructor(message = "Header not found", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "HeaderNotFoundError";
|
||||
}
|
||||
}
|
||||
61
packages/navigation/src/entities/models/header.test.ts
Normal file
61
packages/navigation/src/entities/models/header.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { headerSchema, headerItemSchema } from "./header";
|
||||
|
||||
describe("headerItemSchema", () => {
|
||||
it("accepts a valid nav item", () => {
|
||||
const result = headerItemSchema.parse({
|
||||
label: "Home",
|
||||
href: "/",
|
||||
});
|
||||
expect(result.label).toBe("Home");
|
||||
expect(result.external).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an external nav item", () => {
|
||||
const result = headerItemSchema.parse({
|
||||
label: "Blog",
|
||||
href: "https://blog.example.com",
|
||||
external: true,
|
||||
});
|
||||
expect(result.external).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty label", () => {
|
||||
expect(() => headerItemSchema.parse({ label: "", href: "/" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a label over 64 chars", () => {
|
||||
expect(() =>
|
||||
headerItemSchema.parse({ label: "x".repeat(65), href: "/" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an empty href", () => {
|
||||
expect(() =>
|
||||
headerItemSchema.parse({ label: "Home", href: "" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("headerSchema", () => {
|
||||
it("accepts a valid header with items", () => {
|
||||
const result = headerSchema.parse({
|
||||
items: [{ label: "Home", href: "/" }],
|
||||
});
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.logoId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a header with a logoId", () => {
|
||||
const result = headerSchema.parse({
|
||||
logoId: "logo-abc",
|
||||
items: [],
|
||||
});
|
||||
expect(result.logoId).toBe("logo-abc");
|
||||
});
|
||||
|
||||
it("accepts a header with no items", () => {
|
||||
const result = headerSchema.parse({ items: [] });
|
||||
expect(result.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
15
packages/navigation/src/entities/models/header.ts
Normal file
15
packages/navigation/src/entities/models/header.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const headerItemSchema = z.object({
|
||||
label: z.string().min(1).max(64),
|
||||
href: z.string().min(1),
|
||||
external: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const headerSchema = z.object({
|
||||
logoId: z.string().optional(),
|
||||
items: z.array(headerItemSchema),
|
||||
});
|
||||
|
||||
export type Header = z.infer<typeof headerSchema>;
|
||||
export type HeaderItem = z.infer<typeof headerItemSchema>;
|
||||
35
packages/navigation/src/feature.manifest.ts
Normal file
35
packages/navigation/src/feature.manifest.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineFeature } from "@repo/core-shared/conformance";
|
||||
|
||||
/**
|
||||
* The navigation feature's conformance manifest.
|
||||
*/
|
||||
export const navigationManifest = defineFeature({
|
||||
name: "navigation",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
getHeader: { mutates: false, audits: [], publishes: [], consumes: [] },
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
coverage: {
|
||||
bands: {
|
||||
baseline: { statements: 80, branches: 75, functions: 80, lines: 80 },
|
||||
entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
"use-cases": {
|
||||
statements: 100,
|
||||
branches: 95,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
controllers: {
|
||||
statements: 100,
|
||||
branches: 95,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
mutationTargets: ["entities", "use-cases"],
|
||||
},
|
||||
} as const);
|
||||
|
||||
export type NavigationManifest = typeof navigationManifest;
|
||||
23
packages/navigation/src/index.ts
Normal file
23
packages/navigation/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type { Header, HeaderItem } from "./entities/models/header";
|
||||
export type { NavigationRouter } from "./integrations/api/router";
|
||||
export { HeaderNotFoundError } from "./entities/errors/header";
|
||||
export { InputParseError } from "./entities/errors/common";
|
||||
|
||||
// Use case schemas + types
|
||||
export {
|
||||
getHeaderInputSchema,
|
||||
getHeaderOutputSchema,
|
||||
type GetHeaderInput,
|
||||
type GetHeaderOutput,
|
||||
type IGetHeaderUseCase,
|
||||
} from "./application/use-cases/get-header.use-case";
|
||||
|
||||
// Controller type aliases
|
||||
export type { IGetHeaderController } from "./interface-adapters/controllers/get-header.controller";
|
||||
|
||||
// <gen:events>
|
||||
// <gen:realtime-channels>
|
||||
export {
|
||||
navigationManifest,
|
||||
type NavigationManifest,
|
||||
} from "./feature.manifest";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe } from "vitest";
|
||||
import { RecordingTracer } from "@repo/core-testing/instrumentation";
|
||||
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
||||
import { headerRepositoryContract, CONTRACT_HEADER_SEED } from "@/__contracts__/header-repository.contract";
|
||||
|
||||
describe("MockHeaderRepository", () => {
|
||||
const tracer = new RecordingTracer();
|
||||
headerRepositoryContract.run(
|
||||
() => new MockHeaderRepository(CONTRACT_HEADER_SEED, tracer),
|
||||
{ tracer: () => tracer },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import "reflect-metadata";
|
||||
import { injectable } from "inversify";
|
||||
import {
|
||||
NoopTracer,
|
||||
NoopLogger,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
|
||||
import type { IHeaderRepository } from "../../application/repositories/header.repository.interface";
|
||||
import type { Header, HeaderItem } from "../../entities/models/header";
|
||||
|
||||
const DEFAULT_ITEMS: HeaderItem[] = [
|
||||
{ label: "Home", href: "/", external: false },
|
||||
{ label: "Blog", href: "/blog", external: false },
|
||||
{ label: "Docs", href: "/docs", external: true },
|
||||
];
|
||||
|
||||
@injectable()
|
||||
export class MockHeaderRepository implements IHeaderRepository {
|
||||
private readonly data: Header;
|
||||
private tracer: ITracer;
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(
|
||||
initialData?: Header,
|
||||
tracer: ITracer = new NoopTracer(),
|
||||
logger: ILogger = new NoopLogger(),
|
||||
) {
|
||||
this.data = initialData ?? { items: DEFAULT_ITEMS };
|
||||
this.tracer = tracer;
|
||||
this.logger = logger;
|
||||
void this.logger; // currently unused; reserved for future mock-thrown captures
|
||||
}
|
||||
|
||||
async getHeader(): Promise<Header> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "header.getHeader", op: "repository", attributes: {} },
|
||||
async (span) => {
|
||||
span.setAttribute("itemCount", this.data.items.length);
|
||||
return this.data;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
||||
|
||||
// Mock repo also wraps in spans.
|
||||
describe("MockHeaderRepository emits spans", () => {
|
||||
it("getHeader emits one span with op='repository'", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
const repo = new MockHeaderRepository(undefined, tracer, logger);
|
||||
await repo.getHeader();
|
||||
expect(tracer.spans).toHaveLength(1);
|
||||
expect(tracer.spans[0]).toMatchObject({
|
||||
name: "header.getHeader",
|
||||
op: "repository",
|
||||
});
|
||||
expect(typeof tracer.spans[0]!.attributes.itemCount).toBe("number");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { RecordingTracer } from "@repo/core-testing/instrumentation";
|
||||
import { HeaderRepository } from "@/infrastructure/repositories/header.repository";
|
||||
import { headerRepositoryContract, CONTRACT_HEADER_SEED } from "@/__contracts__/header-repository.contract";
|
||||
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory Payload stub for header (Global)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildHeaderStub(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
findGlobal: vi.fn(async () => {
|
||||
return { ...CONTRACT_HEADER_SEED, ...overrides };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("payload", () => ({
|
||||
getPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contract suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("HeaderRepository", () => {
|
||||
describe("contract", () => {
|
||||
const tracer = new RecordingTracer();
|
||||
headerRepositoryContract.run(
|
||||
async () => {
|
||||
const stub = buildHeaderStub();
|
||||
const { getPayload } = await import("payload");
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||
return new HeaderRepository(stubPayloadConfig, tracer);
|
||||
},
|
||||
{ tracer: () => tracer },
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logo field branch coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("resolves logoId from a relation object ({ id })", async () => {
|
||||
const stub = buildHeaderStub({ logo: { id: "abc-123" } });
|
||||
const { getPayload } = await import("payload");
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||
|
||||
const repo = new HeaderRepository(stubPayloadConfig);
|
||||
const header = await repo.getHeader();
|
||||
|
||||
expect(header.logoId).toBe("abc-123");
|
||||
});
|
||||
|
||||
it("resolves logoId from a scalar id (string)", async () => {
|
||||
const stub = buildHeaderStub({ logo: "scalar-id" });
|
||||
const { getPayload } = await import("payload");
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||
|
||||
const repo = new HeaderRepository(stubPayloadConfig);
|
||||
const header = await repo.getHeader();
|
||||
|
||||
expect(header.logoId).toBe("scalar-id");
|
||||
});
|
||||
|
||||
it("resolves logoId as undefined when logo is null", async () => {
|
||||
const stub = buildHeaderStub({ logo: null });
|
||||
const { getPayload } = await import("payload");
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||
|
||||
const repo = new HeaderRepository(stubPayloadConfig);
|
||||
const header = await repo.getHeader();
|
||||
|
||||
expect(header.logoId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps null item fields to empty strings and false", async () => {
|
||||
const stub = {
|
||||
findGlobal: vi.fn(async () => ({
|
||||
items: [{ label: null, href: null, external: null }],
|
||||
})),
|
||||
};
|
||||
const { getPayload } = await import("payload");
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||
|
||||
const repo = new HeaderRepository(stubPayloadConfig);
|
||||
const header = await repo.getHeader();
|
||||
|
||||
expect(header.items[0]?.label).toBe("");
|
||||
expect(header.items[0]?.href).toBe("");
|
||||
expect(header.items[0]?.external).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import "reflect-metadata";
|
||||
import { injectable } from "inversify";
|
||||
import { getPayload } from "payload";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import {
|
||||
NoopTracer,
|
||||
NoopLogger,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
|
||||
import type { IHeaderRepository } from "../../application/repositories/header.repository.interface";
|
||||
import type { Header, HeaderItem } from "../../entities/models/header";
|
||||
|
||||
type PayloadHeaderGlobal = {
|
||||
logo?: string | number | { id: string | number } | null;
|
||||
items?: Array<{
|
||||
label?: string | null;
|
||||
href?: string | null;
|
||||
external?: boolean | null;
|
||||
}> | null;
|
||||
};
|
||||
|
||||
const FEATURE = "navigation" as const;
|
||||
const REPO = "header" as const;
|
||||
|
||||
@injectable()
|
||||
export class HeaderRepository implements IHeaderRepository {
|
||||
private config: SanitizedConfig;
|
||||
private tracer: ITracer;
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(
|
||||
config: SanitizedConfig,
|
||||
tracer: ITracer = new NoopTracer(),
|
||||
logger: ILogger = new NoopLogger(),
|
||||
) {
|
||||
this.config = config;
|
||||
this.tracer = tracer;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async getHeader(): Promise<Header> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "header.getHeader", op: "repository", attributes: {} },
|
||||
async (span) => {
|
||||
try {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const doc = (await payload.findGlobal({
|
||||
slug: "header",
|
||||
overrideAccess: true,
|
||||
})) as PayloadHeaderGlobal;
|
||||
|
||||
const logoId =
|
||||
typeof doc.logo === "object" && doc.logo !== null
|
||||
? String(doc.logo.id)
|
||||
: doc.logo != null
|
||||
? String(doc.logo)
|
||||
: undefined;
|
||||
|
||||
const items: HeaderItem[] = (doc.items ?? []).map((item) => ({
|
||||
label: item.label ?? "",
|
||||
href: item.href ?? "",
|
||||
external: item.external ?? false,
|
||||
}));
|
||||
|
||||
span.setAttribute("itemCount", items.length);
|
||||
return { logoId, items };
|
||||
} catch (err) {
|
||||
this.logger.captureException(err, {
|
||||
tags: { feature: FEATURE, repo: REPO, method: "getHeader" },
|
||||
});
|
||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
12
packages/navigation/src/integrations/api/procedures.ts
Normal file
12
packages/navigation/src/integrations/api/procedures.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
|
||||
import { HeaderNotFoundError } from "../../entities/errors/header";
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
|
||||
export const navigationProcedure = t.procedure.use(
|
||||
defineErrorMiddleware([
|
||||
[InputParseError, "BAD_REQUEST"],
|
||||
[HeaderNotFoundError, "NOT_FOUND"],
|
||||
]),
|
||||
);
|
||||
93
packages/navigation/src/integrations/api/router.test.ts
Normal file
93
packages/navigation/src/integrations/api/router.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { injectable } from "inversify";
|
||||
import { navigationContainer } from "@/di/container";
|
||||
import { NavigationModule } from "@/di/module";
|
||||
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
|
||||
import { getHeaderUseCase } from "@/application/use-cases/get-header.use-case";
|
||||
import { getHeaderController } from "@/interface-adapters/controllers/get-header.controller";
|
||||
import { navigationRouter } from "@/integrations/api/router";
|
||||
|
||||
describe("navigationRouter", () => {
|
||||
beforeEach(() => {
|
||||
navigationContainer.unbindAll();
|
||||
navigationContainer.load(NavigationModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
navigationContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("exposes header procedure", () => {
|
||||
const names = Object.keys(navigationRouter._def.procedures);
|
||||
expect(names).toContain("header");
|
||||
});
|
||||
|
||||
it("header returns 3 items", async () => {
|
||||
const caller = navigationRouter.createCaller({});
|
||||
const result = await caller.header({});
|
||||
expect(result.items).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigationRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
navigationContainer.unbindAll();
|
||||
navigationContainer.load(NavigationModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
navigationContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("translates InputParseError → BAD_REQUEST when extra fields are passed", async () => {
|
||||
const caller = navigationRouter.createCaller({});
|
||||
try {
|
||||
await caller.header({ unexpected: "field" } as unknown as Record<
|
||||
string,
|
||||
never
|
||||
>);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TRPCError);
|
||||
expect((e as TRPCError).code).toBe("BAD_REQUEST");
|
||||
}
|
||||
});
|
||||
|
||||
it("translates HeaderNotFoundError → NOT_FOUND when repository returns null", async () => {
|
||||
@injectable()
|
||||
class NullHeaderRepository {
|
||||
async getHeader() {
|
||||
return null as never;
|
||||
}
|
||||
}
|
||||
|
||||
navigationContainer.unbindAll();
|
||||
navigationContainer
|
||||
.bind(NAVIGATION_SYMBOLS.IHeaderRepository)
|
||||
.to(NullHeaderRepository);
|
||||
navigationContainer
|
||||
.bind(NAVIGATION_SYMBOLS.IGetHeaderUseCase)
|
||||
.toDynamicValue((ctx) =>
|
||||
getHeaderUseCase(
|
||||
ctx.container.get(NAVIGATION_SYMBOLS.IHeaderRepository),
|
||||
),
|
||||
);
|
||||
navigationContainer
|
||||
.bind(NAVIGATION_SYMBOLS.IGetHeaderController)
|
||||
.toDynamicValue((ctx) =>
|
||||
getHeaderController(
|
||||
ctx.container.get(NAVIGATION_SYMBOLS.IGetHeaderUseCase),
|
||||
),
|
||||
);
|
||||
|
||||
const caller = navigationRouter.createCaller({});
|
||||
try {
|
||||
await caller.header({});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TRPCError);
|
||||
expect((e as TRPCError).code).toBe("NOT_FOUND");
|
||||
}
|
||||
});
|
||||
});
|
||||
22
packages/navigation/src/integrations/api/router.ts
Normal file
22
packages/navigation/src/integrations/api/router.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { router } from "@repo/core-shared/trpc/init";
|
||||
|
||||
import { navigationContainer } from "../../di/container";
|
||||
import { NAVIGATION_SYMBOLS } from "../../di/symbols";
|
||||
|
||||
import { getHeaderInputSchema } from "../../application/use-cases/get-header.use-case";
|
||||
import type { IGetHeaderController } from "../../interface-adapters/controllers/get-header.controller";
|
||||
|
||||
import { navigationProcedure } from "./procedures";
|
||||
|
||||
export const navigationRouter = router({
|
||||
header: navigationProcedure
|
||||
.input(getHeaderInputSchema)
|
||||
.query(({ input }) => {
|
||||
const ctrl = navigationContainer.get<IGetHeaderController>(
|
||||
NAVIGATION_SYMBOLS.IGetHeaderController,
|
||||
);
|
||||
return ctrl(input);
|
||||
}),
|
||||
});
|
||||
|
||||
export type NavigationRouter = typeof navigationRouter;
|
||||
34
packages/navigation/src/integrations/cms/globals/header.ts
Normal file
34
packages/navigation/src/integrations/cms/globals/header.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const header: GlobalConfig = {
|
||||
slug: "header",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "monthly",
|
||||
postDeletion: {
|
||||
duration: "P90D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
group: "Navigation",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "logo",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
},
|
||||
{
|
||||
name: "items",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "label", type: "text", required: true },
|
||||
{ name: "href", type: "text", required: true },
|
||||
{ name: "external", type: "checkbox", defaultValue: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
2
packages/navigation/src/integrations/cms/index.ts
Normal file
2
packages/navigation/src/integrations/cms/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { header } from "./globals/header";
|
||||
// <gen:job-tasks>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHeaderController } from "@/interface-adapters/controllers/get-header.controller";
|
||||
import { getHeaderUseCase } from "@/application/use-cases/get-header.use-case";
|
||||
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
|
||||
describe("getHeaderController", () => {
|
||||
it("returns the header with items", async () => {
|
||||
const repo = new MockHeaderRepository();
|
||||
const useCase = getHeaderUseCase(repo);
|
||||
const controller = getHeaderController(useCase);
|
||||
|
||||
const result = await controller({});
|
||||
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(result.items[0]?.label).toBe("Home");
|
||||
});
|
||||
|
||||
it("throws InputParseError on unknown fields (strict schema)", async () => {
|
||||
const repo = new MockHeaderRepository();
|
||||
const useCase = getHeaderUseCase(repo);
|
||||
const controller = getHeaderController(useCase);
|
||||
|
||||
await expect(controller({ unknownField: 1 })).rejects.toThrow(
|
||||
InputParseError,
|
||||
);
|
||||
});
|
||||
|
||||
it("InputParseError preserves the cause from Zod", async () => {
|
||||
const repo = new MockHeaderRepository();
|
||||
const useCase = getHeaderUseCase(repo);
|
||||
const controller = getHeaderController(useCase);
|
||||
|
||||
try {
|
||||
await controller({ unknownField: 1 });
|
||||
expect.fail("expected InputParseError");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(InputParseError);
|
||||
expect((err as Error).name).toBe("InputParseError");
|
||||
expect((err as InputParseError).cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import {
|
||||
getHeaderInputSchema,
|
||||
type GetHeaderOutput,
|
||||
type IGetHeaderUseCase,
|
||||
} from "../../application/use-cases/get-header.use-case";
|
||||
|
||||
function presenter(value: GetHeaderOutput) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export type IGetHeaderController = ReturnType<typeof getHeaderController>;
|
||||
|
||||
export const getHeaderController =
|
||||
(getHeaderUseCase: IGetHeaderUseCase) =>
|
||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = getHeaderInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid get-header input", { cause: parsed.error });
|
||||
}
|
||||
const result = await getHeaderUseCase(parsed.data);
|
||||
return presenter(result);
|
||||
};
|
||||
46
packages/navigation/src/ui/components/site-header.client.tsx
Normal file
46
packages/navigation/src/ui/components/site-header.client.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useHeader } from "../hooks/use-header";
|
||||
|
||||
export type SiteHeaderProps = {
|
||||
siteName: string;
|
||||
siteDescription?: string;
|
||||
};
|
||||
|
||||
export function SiteHeader({ siteName, siteDescription }: SiteHeaderProps) {
|
||||
const { data: header } = useHeader();
|
||||
|
||||
return (
|
||||
<header className="border-b border-border bg-background px-6 py-4">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between">
|
||||
<div>
|
||||
<a href="/">
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{siteName}
|
||||
</span>
|
||||
</a>
|
||||
{siteDescription ? (
|
||||
<p className="text-sm text-muted-foreground">{siteDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<nav>
|
||||
<ul className="flex gap-4">
|
||||
{header.items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<a
|
||||
href={item.href}
|
||||
{...(item.external
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
className="text-sm font-medium text-foreground hover:text-primary"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
27
packages/navigation/src/ui/components/site-header.server.tsx
Normal file
27
packages/navigation/src/ui/components/site-header.server.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { getQueryClient } from "@repo/core-trpc";
|
||||
import { navigationContainer } from "../../di/container";
|
||||
import { NAVIGATION_SYMBOLS } from "../../di/symbols";
|
||||
import type { IGetHeaderController } from "../../interface-adapters/controllers/get-header.controller";
|
||||
import { SiteHeader as SiteHeaderClient } from "./site-header.client";
|
||||
|
||||
export async function SiteHeader({
|
||||
siteName,
|
||||
siteDescription,
|
||||
}: {
|
||||
siteName: string;
|
||||
siteDescription?: string;
|
||||
}) {
|
||||
const controller = navigationContainer.get<IGetHeaderController>(
|
||||
NAVIGATION_SYMBOLS.IGetHeaderController,
|
||||
);
|
||||
const header = await controller({});
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.setQueryData(["navigation", "header", { input: {} }], header);
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<SiteHeaderClient siteName={siteName} siteDescription={siteDescription} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
15
packages/navigation/src/ui/hooks/use-header.ts
Normal file
15
packages/navigation/src/ui/hooks/use-header.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@repo/core-trpc";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function useHeader() {
|
||||
const trpc = useTRPC();
|
||||
return useSuspenseQuery(trpc.navigation.header.queryOptions({})) as {
|
||||
data: {
|
||||
items: { label: string; href: string; external: boolean }[];
|
||||
logoId?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
3
packages/navigation/src/ui/index.ts
Normal file
3
packages/navigation/src/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { headerQuery } from "./query";
|
||||
export { useHeader } from "./hooks/use-header";
|
||||
export { SiteHeader } from "./components/site-header.server";
|
||||
9
packages/navigation/src/ui/query.ts
Normal file
9
packages/navigation/src/ui/query.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type TrpcClient = {
|
||||
navigation: {
|
||||
header: { queryOptions: () => unknown };
|
||||
};
|
||||
};
|
||||
|
||||
export function headerQuery(client: TrpcClient) {
|
||||
return client.navigation.header.queryOptions();
|
||||
}
|
||||
14
packages/navigation/tsconfig.json
Normal file
14
packages/navigation/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"jsx": "preserve",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
packages/navigation/turbo.json
Normal file
4
packages/navigation/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["feature"]
|
||||
}
|
||||
32
packages/navigation/vitest.config.ts
Normal file
32
packages/navigation/vitest.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
import {
|
||||
DEFAULT_COVERAGE_BANDS,
|
||||
vitestThresholdsFromBands,
|
||||
} from "@repo/core-shared/conformance/coverage";
|
||||
|
||||
// Coverage thresholds derived from DEFAULT_COVERAGE_BANDS via the shared
|
||||
// helper (ADR-020). The feature.manifest.ts `coverage.bands` section
|
||||
// declares these for boot-time `assertFeatureConformance`. Edit the
|
||||
// manifest when adjusting per-feature bands.
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
test: {
|
||||
coverage: {
|
||||
exclude: [
|
||||
// DI bootstrap — wires InversifyJS at app startup; not unit-testable
|
||||
"src/di/bind-production.ts",
|
||||
// Pure TypeScript interface files — not executable
|
||||
"src/application/repositories/**",
|
||||
// Payload CMS global config — declarative data, tested via Payload integration
|
||||
"src/integrations/cms/**",
|
||||
// React Query option builders — integration-tested in apps
|
||||
"src/ui/**",
|
||||
],
|
||||
thresholds: vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS),
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user