feat: complete TechLog Studio publication flow
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# Task 13 report: publication flow and atomic install
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
`DONE_WITH_CONCERNS`
|
||||||
|
|
||||||
|
Base SHA: `9c6906fc6f76115346360d050dd8c211fee1b5a9`
|
||||||
|
|
||||||
|
Delivery commit: the commit containing this report, with subject `feat: complete TechLog Studio publication flow`.
|
||||||
|
|
||||||
|
## Publication mapping
|
||||||
|
|
||||||
|
- Added the source-faithful publish screen, warning acknowledgements, publication history/filter, unpublish dialog, immutable event snapshot preview, and their three route pages.
|
||||||
|
- Publish blocks invalid or stale validation, requires every warning acknowledgement, creates a fresh idempotency key per command/retry, preserves gateway command ordering, and exposes pending/error/retry states.
|
||||||
|
- Unpublish preserves the reason/confirmation contract. Historical preview reads the event-owned immutable snapshot and renders it through the shared `PublicRecordRenderer`; a missing/unknown event stays inside Studio.
|
||||||
|
- Added `tests/features/tech-log/studio-publication-flow.test.tsx` first. The initial red was the exact missing `publication-list.tsx` module/screen; the implemented suite is green at 7 tests.
|
||||||
|
|
||||||
|
## Atomic install and removals
|
||||||
|
|
||||||
|
- Installed exactly the 27 governed TechLog route definitions, codecs, runtime imports, module identities, message catalogs, schemas, and release-manifest chunk IDs. `PublicShell` and `StudioShell` are the grouped layout elements.
|
||||||
|
- Added governed Vite chunk naming for the 27 route module identities and changed the performance probe to `TECH_LOG_HOME`.
|
||||||
|
- Kept the reference contract/adapters and API/schema/invalidation/platform fixture tests, while removing its presentation runtime/pages and page-level tests.
|
||||||
|
- Removed the four sample presentation pages, starter home/not-found pages, their page-level component/E2E screens, and all four `/examples/*` E2E specs authorized by the brief.
|
||||||
|
- Added `tests/e2e/tech-log-studio-workflow.spec.ts`; it was deliberately not run and is deferred to Task 14.
|
||||||
|
- Updated the removal fixture to retain TechLog after reference removal and to exclude tests whose only contract is the removed reference runtime or the canonical (non-reduced) CI authority.
|
||||||
|
- Split `DocumentEditorController` into a type-only module to remove the editor/status-rail cycle exposed by the installed route graph.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
- Publication + validation-preview + mock gateway: PASS, 3 files / 23 tests.
|
||||||
|
- Router + runtime application + retained reference contract: PASS, 3 files / 16 tests.
|
||||||
|
- Final route contract + navigation policy: PASS, 2 files / 10 tests.
|
||||||
|
- Registry structure: PASS, 11 registries.
|
||||||
|
- Release manifest inventory: PASS, exactly 27 derived chunk IDs.
|
||||||
|
- `git diff --check`: PASS.
|
||||||
|
- `test:sample-removal` was run once. Its isolated home smoke passed 9/9 and registry/CI reduced-contract checks passed, but its internally broad type/architecture/unit/coverage/build loop failed. Task-owned findings were fixed afterward: ES-target-incompatible `toSorted`, stale `APP_HOME`, direct adapter import, editor/status-rail cycle, reference-dependent fixture residue, canonical-CI-only tests in a reduced fixture, and missing governed build chunk names. Per fast-mode direction, that several-minute broad loop was not rerun; Task 14 must confirm the fixes through its integrated gates.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Publication/UI/runtime: `src/features/tech-log/presentation/**`, including the five publication components, three pages, route runtime, and controller boundary.
|
||||||
|
- Contracts/install: `src/contracts/{routes,route-runtime-contract}.ts`, `src/features/installed-feature-*.{ts,tsx}`, TechLog route contract, platform codecs/runtime, router, layout reference, registry governance, Vite config, release manifest, performance/removal scripts.
|
||||||
|
- Tests: new publication flow and Studio workflow specs; updated route/router/runtime/reference/navigation expectations; authorized sample/reference presentation test deletions.
|
||||||
|
|
||||||
|
## Task 14 deferred concerns
|
||||||
|
|
||||||
|
- Run the complete integrated review/gates, including the sample-removal loop with the post-fix code, production build/manifest verification, types, lint, architecture, security, and Playwright workflow.
|
||||||
|
- Confirm the governed Vite chunk names in the generated production manifest and assess any unrelated environment/timing failures from the broad isolated fixture.
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
"consumerIdentityField": "schemaId",
|
"consumerIdentityField": "schemaId",
|
||||||
"consumerDirectories": [
|
"consumerDirectories": [
|
||||||
"src/presentation/routes",
|
"src/presentation/routes",
|
||||||
"src/features/reference-feature/presentation",
|
"src/features/tech-log/presentation",
|
||||||
"src/features/reference-feature/contracts"
|
"src/features/reference-feature/contracts"
|
||||||
],
|
],
|
||||||
"breakingFields": ["schemaId", "boundary", "runtime"]
|
"breakingFields": ["schemaId", "boundary", "runtime"]
|
||||||
|
|||||||
@@ -8,16 +8,33 @@
|
|||||||
"releaseId": "local-release",
|
"releaseId": "local-release",
|
||||||
"builtAt": "1970-01-01T00:00:00.000Z",
|
"builtAt": "1970-01-01T00:00:00.000Z",
|
||||||
"routeChunks": {
|
"routeChunks": {
|
||||||
"route-home": "src/presentation/pages/home-page.tsx",
|
"route-tech-log-home": "src/features/tech-log/presentation/public/pages/home-page.tsx",
|
||||||
"route-examples-platform": "src/presentation/examples/platform-overview-page.tsx",
|
"route-tech-log-explore": "src/features/tech-log/presentation/public/pages/explore-page.tsx",
|
||||||
"route-examples-ui": "src/presentation/examples/ui-gallery-page.tsx",
|
"route-tech-log-explore-kind": "src/features/tech-log/presentation/public/pages/explore-kind-page.tsx",
|
||||||
"route-examples-states": "src/presentation/examples/state-gallery-page.tsx",
|
"route-tech-log-case": "src/features/tech-log/presentation/public/pages/case-page.tsx",
|
||||||
"route-examples-auth": "src/presentation/examples/auth-example-page.tsx",
|
"route-tech-log-reference": "src/features/tech-log/presentation/public/pages/reference-page.tsx",
|
||||||
"route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx",
|
"route-tech-log-question": "src/features/tech-log/presentation/public/pages/question-page.tsx",
|
||||||
"route-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx",
|
"route-tech-log-topic": "src/features/tech-log/presentation/public/pages/topic-page.tsx",
|
||||||
"route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx",
|
"route-tech-log-projects": "src/features/tech-log/presentation/public/pages/projects-page.tsx",
|
||||||
"route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx",
|
"route-tech-log-project": "src/features/tech-log/presentation/public/pages/project-overview-page.tsx",
|
||||||
"route-not-found": "src/presentation/pages/not-found-page.tsx"
|
"route-tech-log-project-records": "src/features/tech-log/presentation/public/pages/project-records-page.tsx",
|
||||||
|
"route-tech-log-project-decisions": "src/features/tech-log/presentation/public/pages/project-decisions-page.tsx",
|
||||||
|
"route-tech-log-project-activity": "src/features/tech-log/presentation/public/pages/project-activity-page.tsx",
|
||||||
|
"route-tech-log-releases": "src/features/tech-log/presentation/public/pages/releases-page.tsx",
|
||||||
|
"route-tech-log-release": "src/features/tech-log/presentation/public/pages/release-page.tsx",
|
||||||
|
"route-tech-log-profile": "src/features/tech-log/presentation/public/pages/profile-page.tsx",
|
||||||
|
"route-tech-log-search": "src/features/tech-log/presentation/public/pages/search-page.tsx",
|
||||||
|
"route-tech-log-studio-home": "src/features/tech-log/presentation/studio/pages/studio-home-page.tsx",
|
||||||
|
"route-tech-log-studio-documents": "src/features/tech-log/presentation/studio/pages/documents-page.tsx",
|
||||||
|
"route-tech-log-studio-document-new": "src/features/tech-log/presentation/studio/pages/new-document-page.tsx",
|
||||||
|
"route-tech-log-studio-document-edit": "src/features/tech-log/presentation/studio/pages/document-edit-page.tsx",
|
||||||
|
"route-tech-log-studio-document-validation": "src/features/tech-log/presentation/studio/pages/document-validation-page.tsx",
|
||||||
|
"route-tech-log-studio-document-preview": "src/features/tech-log/presentation/studio/pages/document-preview-page.tsx",
|
||||||
|
"route-tech-log-studio-document-publish": "src/features/tech-log/presentation/studio/pages/document-publish-page.tsx",
|
||||||
|
"route-tech-log-studio-publications": "src/features/tech-log/presentation/studio/pages/publications-page.tsx",
|
||||||
|
"route-tech-log-studio-publication-preview": "src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx",
|
||||||
|
"route-tech-log-studio-not-found": "src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx",
|
||||||
|
"route-not-found": "src/features/tech-log/presentation/public/pages/public-not-found-page.tsx"
|
||||||
},
|
},
|
||||||
"contractSet": {
|
"contractSet": {
|
||||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||||
|
|||||||
@@ -84,13 +84,8 @@ try {
|
|||||||
}).observe({ type: "layout-shift", buffered: true });
|
}).observe({ type: "layout-shift", buffered: true });
|
||||||
});
|
});
|
||||||
await page.goto(baseUrl, { waitUntil: "networkidle" });
|
await page.goto(baseUrl, { waitUntil: "networkidle" });
|
||||||
const target = ROUTE_REGISTRY.EXAMPLES_PLATFORM;
|
const target = ROUTE_REGISTRY.TECH_LOG_HOME;
|
||||||
const targetLabel = target.navigationLabel;
|
|
||||||
if (!targetLabel) {
|
|
||||||
throw new Error("Performance route must be present in navigation.");
|
|
||||||
}
|
|
||||||
const interactionStarted = performance.now();
|
const interactionStarted = performance.now();
|
||||||
await page.getByRole("link", { name: targetLabel }).click();
|
|
||||||
await page.getByRole("heading", { name: target.title }).waitFor();
|
await page.getByRole("heading", { name: target.title }).waitFor();
|
||||||
const namedInteractionMs = performance.now() - interactionStarted;
|
const namedInteractionMs = performance.now() - interactionStarted;
|
||||||
const paint = await page.evaluate(
|
const paint = await page.evaluate(
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const featureOwnedPaths = [
|
|||||||
featureSource,
|
featureSource,
|
||||||
featureTests,
|
featureTests,
|
||||||
"tests/integration/http-scenario-catalog.test.ts",
|
"tests/integration/http-scenario-catalog.test.ts",
|
||||||
|
"tests/integration/http-execution-v3-observability.test.ts",
|
||||||
|
"tests/features/tech-log/runtime-composition.test.ts",
|
||||||
"tests/e2e/reference-form.spec.ts",
|
"tests/e2e/reference-form.spec.ts",
|
||||||
"tests/e2e/reference-route.spec.ts",
|
"tests/e2e/reference-route.spec.ts",
|
||||||
"tests/mocks",
|
"tests/mocks",
|
||||||
@@ -74,13 +76,17 @@ const copyTargets = [
|
|||||||
".nvmrc",
|
".nvmrc",
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
|
const emptyContracts = `import type { RouteDefinition } from "../contracts/routes.ts";
|
||||||
import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts";
|
|
||||||
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts";
|
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts";
|
||||||
|
import {
|
||||||
|
TECH_LOG_ROUTE_REGISTRY,
|
||||||
|
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
||||||
|
TECH_LOG_ROUTE_SCHEMA_REGISTRY,
|
||||||
|
} from "./tech-log/contracts/tech-log-route-contract.ts";
|
||||||
|
|
||||||
export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]);
|
export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]);
|
||||||
export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
|
export const ROUTE_REGISTRY = TECH_LOG_ROUTE_REGISTRY;
|
||||||
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
|
export const ROUTE_RUNTIME_CONTRACT = TECH_LOG_ROUTE_RUNTIME_CONTRACT;
|
||||||
export const API_OPERATIONS = Object.freeze({});
|
export const API_OPERATIONS = Object.freeze({});
|
||||||
export const INVALIDATION_REGISTRY = Object.freeze({
|
export const INVALIDATION_REGISTRY = Object.freeze({
|
||||||
topics: Object.freeze([]),
|
topics: Object.freeze([]),
|
||||||
@@ -88,7 +94,10 @@ export const INVALIDATION_REGISTRY = Object.freeze({
|
|||||||
edges: Object.freeze([]),
|
edges: Object.freeze([]),
|
||||||
});
|
});
|
||||||
export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]);
|
export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]);
|
||||||
export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
|
export const SCHEMA_REGISTRY = Object.freeze({
|
||||||
|
...PLATFORM_SCHEMA_REGISTRY,
|
||||||
|
...TECH_LOG_ROUTE_SCHEMA_REGISTRY,
|
||||||
|
});
|
||||||
export const NAVIGATION_ROUTES = Object.freeze(
|
export const NAVIGATION_ROUTES = Object.freeze(
|
||||||
Object.values(ROUTE_REGISTRY)
|
Object.values(ROUTE_REGISTRY)
|
||||||
.filter((definition) => definition.navigationOrder !== null)
|
.filter((definition) => definition.navigationOrder !== null)
|
||||||
@@ -109,15 +118,22 @@ export function routePath(routeId: string): string {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
|
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
|
||||||
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
|
import { TECH_LOG_ROUTE_CODECS } from "./tech-log/presentation/tech-log-route-codecs.ts";
|
||||||
|
import { TECH_LOG_ROUTE_RUNTIME } from "./tech-log/presentation/tech-log-route-runtime.tsx";
|
||||||
|
|
||||||
export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
|
export const ROUTE_CODECS = Object.freeze({
|
||||||
export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
|
...PLATFORM_ROUTE_CODECS,
|
||||||
|
...TECH_LOG_ROUTE_CODECS,
|
||||||
|
});
|
||||||
|
export const ROUTE_RUNTIME = TECH_LOG_ROUTE_RUNTIME;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) {
|
const emptyAdapters = `import { createTechLogFeatureInstalledInput } from "./tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
|
|
||||||
|
export function createInstalledFeatureInputs(_context: unknown) {
|
||||||
void _context;
|
void _context;
|
||||||
return Object.freeze({});
|
const techLog = createTechLogFeatureInstalledInput();
|
||||||
|
return Object.freeze({ [techLog.featureId]: techLog.input });
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -138,9 +154,11 @@ export const EXPECTED_CONTRACT_SET_PACKAGES: readonly InstalledContractPackageId
|
|||||||
COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages;
|
COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
|
const emptyMessages = `import { TECH_LOG_MESSAGE_CATALOGS } from "./tech-log/contracts/tech-log-message-catalog.ts";
|
||||||
"ko-KR": Object.freeze({}),
|
|
||||||
"en-US": Object.freeze({}),
|
export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
|
||||||
|
"ko-KR": TECH_LOG_MESSAGE_CATALOGS["ko-KR"],
|
||||||
|
"en-US": TECH_LOG_MESSAGE_CATALOGS["en-US"],
|
||||||
});
|
});
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -174,10 +192,13 @@ function runPnpm(script: string, extra: string[] = []): boolean {
|
|||||||
try {
|
try {
|
||||||
await prepareRemovalFixture(fixtureRoot, copyTargets);
|
await prepareRemovalFixture(fixtureRoot, copyTargets);
|
||||||
for (const excludedFixtureTest of [
|
for (const excludedFixtureTest of [
|
||||||
|
"tests/integration/security-followup-archive.test.ts",
|
||||||
|
"tests/unit/ci-artifact-contract.test.ts",
|
||||||
"tests/unit/ci-workflow-generation.test.ts",
|
"tests/unit/ci-workflow-generation.test.ts",
|
||||||
"tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap",
|
"tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap",
|
||||||
"tests/unit/removal-fixture.test.ts",
|
"tests/unit/removal-fixture.test.ts",
|
||||||
"tests/unit/http-scenario-evidence.test.ts",
|
"tests/unit/http-scenario-evidence.test.ts",
|
||||||
|
"tests/unit/task3-selective-integration.test.ts",
|
||||||
]) {
|
]) {
|
||||||
await rm(path.join(fixtureRoot, excludedFixtureTest), { force: true });
|
await rm(path.join(fixtureRoot, excludedFixtureTest), { force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,45 +21,4 @@ export type RouteRuntimeDefinition = Readonly<{
|
|||||||
searchCodec: RouteCodecId;
|
searchCodec: RouteCodecId;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const runtime = <Definition extends RouteRuntimeDefinition>(
|
export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({});
|
||||||
value: Definition,
|
|
||||||
): Readonly<Definition> => Object.freeze(value);
|
|
||||||
|
|
||||||
export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({
|
|
||||||
APP_HOME: runtime({
|
|
||||||
routeId: "APP_HOME",
|
|
||||||
moduleId: "home-page",
|
|
||||||
paramsCodec: "none",
|
|
||||||
searchCodec: "none",
|
|
||||||
}),
|
|
||||||
EXAMPLES_PLATFORM: runtime({
|
|
||||||
routeId: "EXAMPLES_PLATFORM",
|
|
||||||
moduleId: "platform-overview-page",
|
|
||||||
paramsCodec: "none",
|
|
||||||
searchCodec: "none",
|
|
||||||
}),
|
|
||||||
EXAMPLES_UI: runtime({
|
|
||||||
routeId: "EXAMPLES_UI",
|
|
||||||
moduleId: "ui-gallery-page",
|
|
||||||
paramsCodec: "none",
|
|
||||||
searchCodec: "none",
|
|
||||||
}),
|
|
||||||
EXAMPLES_STATES: runtime({
|
|
||||||
routeId: "EXAMPLES_STATES",
|
|
||||||
moduleId: "state-gallery-page",
|
|
||||||
paramsCodec: "none",
|
|
||||||
searchCodec: "none",
|
|
||||||
}),
|
|
||||||
EXAMPLES_AUTH: runtime({
|
|
||||||
routeId: "EXAMPLES_AUTH",
|
|
||||||
moduleId: "auth-example-page",
|
|
||||||
paramsCodec: "none",
|
|
||||||
searchCodec: "none",
|
|
||||||
}),
|
|
||||||
NOT_FOUND: runtime({
|
|
||||||
routeId: "NOT_FOUND",
|
|
||||||
moduleId: "not-found-page",
|
|
||||||
paramsCodec: "NotFoundSplat",
|
|
||||||
searchCodec: "none",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
+1
-90
@@ -15,93 +15,4 @@ export type RouteDefinition = Readonly<{
|
|||||||
navigationOrder: number | null;
|
navigationOrder: number | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const route = <Definition extends RouteDefinition>(
|
export const PLATFORM_ROUTE_REGISTRY = Object.freeze({});
|
||||||
definition: Definition,
|
|
||||||
): Readonly<Definition> => Object.freeze(definition);
|
|
||||||
|
|
||||||
export const PLATFORM_ROUTE_REGISTRY = Object.freeze({
|
|
||||||
APP_HOME: route({
|
|
||||||
routeId: "APP_HOME",
|
|
||||||
path: "/",
|
|
||||||
layoutGroup: "PUBLIC",
|
|
||||||
paramsSchema: null,
|
|
||||||
searchSchema: null,
|
|
||||||
access: "public",
|
|
||||||
loadingSurface: "app-shell",
|
|
||||||
errorSurface: "route-boundary",
|
|
||||||
chunkId: "route-home",
|
|
||||||
title: "시작",
|
|
||||||
navigationLabel: "시작",
|
|
||||||
navigationOrder: 10,
|
|
||||||
}),
|
|
||||||
EXAMPLES_PLATFORM: route({
|
|
||||||
routeId: "EXAMPLES_PLATFORM",
|
|
||||||
path: "/examples/platform",
|
|
||||||
layoutGroup: "PUBLIC",
|
|
||||||
paramsSchema: null,
|
|
||||||
searchSchema: null,
|
|
||||||
access: "public",
|
|
||||||
loadingSurface: "example-page",
|
|
||||||
errorSurface: "route-boundary",
|
|
||||||
chunkId: "route-examples-platform",
|
|
||||||
title: "플랫폼 구성",
|
|
||||||
navigationLabel: "플랫폼 구성",
|
|
||||||
navigationOrder: 15,
|
|
||||||
}),
|
|
||||||
EXAMPLES_UI: route({
|
|
||||||
routeId: "EXAMPLES_UI",
|
|
||||||
path: "/examples/ui",
|
|
||||||
layoutGroup: "PUBLIC",
|
|
||||||
paramsSchema: null,
|
|
||||||
searchSchema: null,
|
|
||||||
access: "public",
|
|
||||||
loadingSurface: "example-page",
|
|
||||||
errorSurface: "route-boundary",
|
|
||||||
chunkId: "route-examples-ui",
|
|
||||||
title: "UI 구성요소",
|
|
||||||
navigationLabel: "UI 구성요소",
|
|
||||||
navigationOrder: 20,
|
|
||||||
}),
|
|
||||||
EXAMPLES_STATES: route({
|
|
||||||
routeId: "EXAMPLES_STATES",
|
|
||||||
path: "/examples/states",
|
|
||||||
layoutGroup: "PUBLIC",
|
|
||||||
paramsSchema: null,
|
|
||||||
searchSchema: null,
|
|
||||||
access: "public",
|
|
||||||
loadingSurface: "example-page",
|
|
||||||
errorSurface: "route-boundary",
|
|
||||||
chunkId: "route-examples-states",
|
|
||||||
title: "화면 상태",
|
|
||||||
navigationLabel: "화면 상태",
|
|
||||||
navigationOrder: 30,
|
|
||||||
}),
|
|
||||||
EXAMPLES_AUTH: route({
|
|
||||||
routeId: "EXAMPLES_AUTH",
|
|
||||||
path: "/examples/auth",
|
|
||||||
layoutGroup: "PUBLIC",
|
|
||||||
paramsSchema: null,
|
|
||||||
searchSchema: null,
|
|
||||||
access: "public",
|
|
||||||
loadingSurface: "example-page",
|
|
||||||
errorSurface: "route-boundary",
|
|
||||||
chunkId: "route-examples-auth",
|
|
||||||
title: "인증 연동",
|
|
||||||
navigationLabel: "인증 연동",
|
|
||||||
navigationOrder: 40,
|
|
||||||
}),
|
|
||||||
NOT_FOUND: route({
|
|
||||||
routeId: "NOT_FOUND",
|
|
||||||
path: "*",
|
|
||||||
layoutGroup: "PUBLIC",
|
|
||||||
paramsSchema: "NotFoundSplat",
|
|
||||||
searchSchema: null,
|
|
||||||
access: "public",
|
|
||||||
loadingSurface: "none",
|
|
||||||
errorSurface: "not-found",
|
|
||||||
chunkId: "route-not-found",
|
|
||||||
title: "페이지를 찾을 수 없음",
|
|
||||||
navigationLabel: null,
|
|
||||||
navigationOrder: null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
|
|
||||||
import type { RouteRuntimeDefinition } from "../contracts/route-runtime-contract.ts";
|
import type { RouteRuntimeDefinition } from "../contracts/route-runtime-contract.ts";
|
||||||
import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.ts";
|
|
||||||
import type { RouteDefinition } from "../contracts/routes.ts";
|
import type { RouteDefinition } from "../contracts/routes.ts";
|
||||||
import {
|
import {
|
||||||
composeSchemaRegistry,
|
composeSchemaRegistry,
|
||||||
@@ -14,18 +12,21 @@ import {
|
|||||||
import { validateRestProfileBindings } from "../contracts/rest-profiles.ts";
|
import { validateRestProfileBindings } from "../contracts/rest-profiles.ts";
|
||||||
import { composeRuntimeSchemaCodecs } from "../contracts/schema-registry.ts";
|
import { composeRuntimeSchemaCodecs } from "../contracts/schema-registry.ts";
|
||||||
import { composeBoundaryMapperRegistry } from "../contracts/boundary-mapper.ts";
|
import { composeBoundaryMapperRegistry } from "../contracts/boundary-mapper.ts";
|
||||||
|
import {
|
||||||
|
TECH_LOG_ROUTE_REGISTRY,
|
||||||
|
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
||||||
|
TECH_LOG_ROUTE_SCHEMA_REGISTRY,
|
||||||
|
} from "./tech-log/contracts/tech-log-route-contract.ts";
|
||||||
|
|
||||||
export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([
|
export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([
|
||||||
REFERENCE_FEATURE_CONTRACT,
|
REFERENCE_FEATURE_CONTRACT,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ROUTE_REGISTRY = Object.freeze({
|
export const ROUTE_REGISTRY = Object.freeze({
|
||||||
...PLATFORM_ROUTE_REGISTRY,
|
...TECH_LOG_ROUTE_REGISTRY,
|
||||||
...REFERENCE_FEATURE_CONTRACT.routes,
|
|
||||||
}) satisfies Readonly<Record<string, RouteDefinition>>;
|
}) satisfies Readonly<Record<string, RouteDefinition>>;
|
||||||
export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
|
export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
|
||||||
...PLATFORM_ROUTE_RUNTIME_CONTRACT,
|
...TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
||||||
...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts,
|
|
||||||
}) satisfies Readonly<Record<string, RouteRuntimeDefinition>>;
|
}) satisfies Readonly<Record<string, RouteRuntimeDefinition>>;
|
||||||
export const API_OPERATIONS = composeApiOperations(
|
export const API_OPERATIONS = composeApiOperations(
|
||||||
INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.apiOperations),
|
INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.apiOperations),
|
||||||
@@ -56,6 +57,7 @@ export const INVALIDATION_TOPIC_VERSIONS = Object.freeze(
|
|||||||
);
|
);
|
||||||
export const SCHEMA_REGISTRY = composeSchemaRegistry([
|
export const SCHEMA_REGISTRY = composeSchemaRegistry([
|
||||||
PLATFORM_SCHEMA_REGISTRY,
|
PLATFORM_SCHEMA_REGISTRY,
|
||||||
|
TECH_LOG_ROUTE_SCHEMA_REGISTRY,
|
||||||
...INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.schemas),
|
...INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.schemas),
|
||||||
]);
|
]);
|
||||||
export const RUNTIME_SCHEMA_CODECS = composeRuntimeSchemaCodecs(
|
export const RUNTIME_SCHEMA_CODECS = composeRuntimeSchemaCodecs(
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.ts";
|
import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.ts";
|
||||||
|
import { TECH_LOG_MESSAGE_CATALOGS } from "./tech-log/contracts/tech-log-message-catalog.ts";
|
||||||
|
|
||||||
export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
|
export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
|
||||||
"ko-KR": Object.freeze({
|
"ko-KR": Object.freeze({
|
||||||
...REFERENCE_MESSAGE_CATALOGS["ko-KR"],
|
...REFERENCE_MESSAGE_CATALOGS["ko-KR"],
|
||||||
|
...TECH_LOG_MESSAGE_CATALOGS["ko-KR"],
|
||||||
}),
|
}),
|
||||||
"en-US": Object.freeze({
|
"en-US": Object.freeze({
|
||||||
...REFERENCE_MESSAGE_CATALOGS["en-US"],
|
...REFERENCE_MESSAGE_CATALOGS["en-US"],
|
||||||
|
...TECH_LOG_MESSAGE_CATALOGS["en-US"],
|
||||||
}),
|
}),
|
||||||
} as const);
|
} as const);
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
|
import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
|
||||||
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
|
|
||||||
import {
|
|
||||||
REFERENCE_FEATURE_ROUTE_CODECS,
|
|
||||||
REFERENCE_FEATURE_ROUTE_RUNTIME,
|
|
||||||
} from "./reference-feature/presentation/reference-feature-runtime.tsx";
|
|
||||||
import { TECH_LOG_ROUTE_CODECS } from "./tech-log/presentation/tech-log-route-codecs.ts";
|
import { TECH_LOG_ROUTE_CODECS } from "./tech-log/presentation/tech-log-route-codecs.ts";
|
||||||
|
import { TECH_LOG_ROUTE_RUNTIME } from "./tech-log/presentation/tech-log-route-runtime.tsx";
|
||||||
|
|
||||||
export const ROUTE_CODECS = Object.freeze({
|
export const ROUTE_CODECS = Object.freeze({
|
||||||
...PLATFORM_ROUTE_CODECS,
|
...PLATFORM_ROUTE_CODECS,
|
||||||
...REFERENCE_FEATURE_ROUTE_CODECS,
|
|
||||||
...TECH_LOG_ROUTE_CODECS,
|
...TECH_LOG_ROUTE_CODECS,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ROUTE_RUNTIME = Object.freeze({
|
export const ROUTE_RUNTIME = Object.freeze({
|
||||||
...PLATFORM_ROUTE_RUNTIME,
|
...TECH_LOG_ROUTE_RUNTIME,
|
||||||
...REFERENCE_FEATURE_ROUTE_RUNTIME,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { lazy } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
referenceResourceListQuerySchema,
|
|
||||||
referenceResourceParamsSchema,
|
|
||||||
} from "../contracts/reference-schemas.ts";
|
|
||||||
|
|
||||||
export const REFERENCE_FEATURE_ROUTE_CODECS = {
|
|
||||||
ReferenceResourceListQuery: referenceResourceListQuerySchema,
|
|
||||||
ReferenceResourceParams: referenceResourceParamsSchema,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
|
|
||||||
REFERENCE_RESOURCE_LIST: Object.freeze({
|
|
||||||
moduleId: "reference-resource-page",
|
|
||||||
Component: lazy(() => import("./reference-resource-page.tsx")),
|
|
||||||
}),
|
|
||||||
REFERENCE_RESOURCE_DETAIL: Object.freeze({
|
|
||||||
moduleId: "reference-resource-detail-page",
|
|
||||||
Component: lazy(() => import("./reference-resource-detail-page.tsx")),
|
|
||||||
}),
|
|
||||||
REFERENCE_RESOURCE_FORM: Object.freeze({
|
|
||||||
moduleId: "reference-resource-form-page",
|
|
||||||
Component: lazy(() => import("./reference-resource-form-page.tsx")),
|
|
||||||
}),
|
|
||||||
REFERENCE_RESOURCE_STATUS: Object.freeze({
|
|
||||||
moduleId: "reference-resource-status-page",
|
|
||||||
Component: lazy(() => import("./reference-resource-status-page.tsx")),
|
|
||||||
}),
|
|
||||||
} as const;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
|
||||||
AsyncSurface,
|
|
||||||
DetailPage,
|
|
||||||
} from "../../../presentation/design-system/index.ts";
|
|
||||||
import { useRouteInput } from "../../../presentation/routes/route-input.tsx";
|
|
||||||
import { useLocale } from "../../../presentation/i18n/index.ts";
|
|
||||||
import { useReferenceFailureAction } from "./use-reference-failure-action.ts";
|
|
||||||
import { useReferenceDetail } from "./use-reference-feature.ts";
|
|
||||||
|
|
||||||
export default function ReferenceResourceDetailPage() {
|
|
||||||
const { date, message } = useLocale();
|
|
||||||
const route = useRouteInput();
|
|
||||||
const resourceId = String(route.params.resourceId);
|
|
||||||
const { query } = useReferenceDetail(resourceId);
|
|
||||||
const resource = query.data;
|
|
||||||
const failureAction = useReferenceFailureAction(query.state.failure);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPage
|
|
||||||
key={resourceId}
|
|
||||||
breadcrumb={
|
|
||||||
<Link to="/examples/reference-resources">Reference resources</Link>
|
|
||||||
}
|
|
||||||
heading={{
|
|
||||||
eyebrow: "DetailPage",
|
|
||||||
title: resource?.title ?? "Reference detail",
|
|
||||||
description: "route param과 detail query의 reset 경계를 확인합니다.",
|
|
||||||
}}
|
|
||||||
metadata={
|
|
||||||
resource ? (
|
|
||||||
<dl>
|
|
||||||
<dt>Resource ID</dt>
|
|
||||||
<dd>{resource.resourceId}</dd>
|
|
||||||
<dt>Created</dt>
|
|
||||||
<dd>
|
|
||||||
{resource.createdAt
|
|
||||||
? date(new Date(resource.createdAt))
|
|
||||||
: message("common.noDisplayValue")}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
) : (
|
|
||||||
<p>요약 정보를 준비하고 있습니다.</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
feedback={
|
|
||||||
<AsyncSurface
|
|
||||||
state={query.state}
|
|
||||||
onAction={failureAction}
|
|
||||||
onRetry={query.retry}
|
|
||||||
>
|
|
||||||
{resource ? (
|
|
||||||
<p>이 영역에는 제품별 상세 section을 조립할 수 있습니다.</p>
|
|
||||||
) : null}
|
|
||||||
</AsyncSurface>
|
|
||||||
}
|
|
||||||
aside={<p>상세 페이지의 관련 정보 slot입니다.</p>}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
AsyncSurface,
|
|
||||||
DirtyNavigationDialog,
|
|
||||||
ErrorSummary,
|
|
||||||
Form,
|
|
||||||
FormActions,
|
|
||||||
FormPage,
|
|
||||||
FormField,
|
|
||||||
useAppForm,
|
|
||||||
useDirtyNavigationGuard,
|
|
||||||
} from "../../../presentation/design-system/index.ts";
|
|
||||||
import {
|
|
||||||
REFERENCE_FORM_DEFAULTS,
|
|
||||||
referenceResourceFormSchema,
|
|
||||||
toCreateReferenceCommand,
|
|
||||||
type ReferenceResourceFormValues,
|
|
||||||
} from "./reference-resource-form.ts";
|
|
||||||
import { useReferenceCreate } from "./use-reference-feature.ts";
|
|
||||||
|
|
||||||
const FIELD_LABELS = Object.freeze({
|
|
||||||
name: "새 항목 이름",
|
|
||||||
note: "설명",
|
|
||||||
}) satisfies Record<keyof ReferenceResourceFormValues, string>;
|
|
||||||
|
|
||||||
export default function ReferenceResourceFormPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const mutation = useReferenceCreate();
|
|
||||||
const submit = useCallback(
|
|
||||||
(command: ReturnType<typeof toCreateReferenceCommand>) =>
|
|
||||||
mutation.submit(command),
|
|
||||||
[mutation],
|
|
||||||
);
|
|
||||||
const form = useAppForm({
|
|
||||||
schema: referenceResourceFormSchema,
|
|
||||||
defaultValues: REFERENCE_FORM_DEFAULTS,
|
|
||||||
allowedServerFields: ["name", "note"],
|
|
||||||
mapToCommand: toCreateReferenceCommand,
|
|
||||||
submit,
|
|
||||||
});
|
|
||||||
const mutationEffectUnknown =
|
|
||||||
mutation.state.indicator === "mutation-effect-unknown";
|
|
||||||
const mutationBlocked =
|
|
||||||
mutationEffectUnknown || mutation.state.indicator === "mutation-pending";
|
|
||||||
const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
id={form.formId}
|
|
||||||
pending={form.pending}
|
|
||||||
onSubmit={(event) => {
|
|
||||||
if (mutationBlocked) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void form.submitForm(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormPage
|
|
||||||
breadcrumb={
|
|
||||||
<button
|
|
||||||
className="ui-button ui-button--ghost"
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate("/examples/reference-resources")}
|
|
||||||
>
|
|
||||||
목록으로 돌아가기
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
heading={{
|
|
||||||
eyebrow: "FormPage",
|
|
||||||
title: "Reference resource 만들기",
|
|
||||||
description:
|
|
||||||
"presentation schema, command mapper, 422/conflict와 dirty navigation 정책을 실행합니다.",
|
|
||||||
}}
|
|
||||||
errorSummary={
|
|
||||||
<ErrorSummary
|
|
||||||
fieldErrors={form.fieldErrors}
|
|
||||||
formErrors={form.formErrors}
|
|
||||||
fieldLabels={FIELD_LABELS}
|
|
||||||
fieldId={form.fieldId}
|
|
||||||
onFocusField={form.focusField}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
fields={
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
{...form.field("name")}
|
|
||||||
label={FIELD_LABELS.name}
|
|
||||||
description="앞뒤 공백은 command mapper 전에 제거됩니다."
|
|
||||||
autoComplete="off"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
{...form.field("note")}
|
|
||||||
label={FIELD_LABELS.note}
|
|
||||||
description="선택 입력이며 비어 있으면 command에 포함되지 않습니다."
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
formActions={
|
|
||||||
<FormActions sticky>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => navigate("/examples/reference-resources")}
|
|
||||||
disabled={form.pending || mutationBlocked}
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={form.pending || mutationBlocked}
|
|
||||||
>
|
|
||||||
{form.pending ? "저장 중…" : "저장"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => form.reset()}
|
|
||||||
disabled={!form.dirty || form.pending || mutationBlocked}
|
|
||||||
>
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
</FormActions>
|
|
||||||
}
|
|
||||||
feedback={
|
|
||||||
mutationEffectUnknown ? (
|
|
||||||
<AsyncSurface
|
|
||||||
state={mutation.state}
|
|
||||||
onReconcileUnknownEffect={(resolution) => {
|
|
||||||
void mutation.reconcileUnknownEffect(resolution).then(() => {
|
|
||||||
if (resolution === "APPLIED") {
|
|
||||||
form.settleApplied();
|
|
||||||
} else {
|
|
||||||
form.settleNotApplied();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : form.result === "success" ? (
|
|
||||||
<p role="status">저장했습니다.</p>
|
|
||||||
) : form.result === "conflict" ? (
|
|
||||||
<p role="status">충돌을 해결한 뒤 다시 제출할 수 있습니다.</p>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
aside={
|
|
||||||
<p>
|
|
||||||
form value는 URL, storage, telemetry에 저장되지 않고 submit 시에만
|
|
||||||
application command로 변환됩니다.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
guard={<DirtyNavigationDialog guard={guard} />}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const referenceResourceFormSchema = z
|
|
||||||
.object({
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(2, "이름은 두 글자 이상이어야 합니다.")
|
|
||||||
.max(120),
|
|
||||||
note: z.string().trim().max(500).default(""),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export type ReferenceResourceFormValues = z.infer<
|
|
||||||
typeof referenceResourceFormSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const REFERENCE_FORM_DEFAULTS: ReferenceResourceFormValues =
|
|
||||||
Object.freeze({
|
|
||||||
name: "",
|
|
||||||
note: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
export function toCreateReferenceCommand(values: ReferenceResourceFormValues) {
|
|
||||||
return Object.freeze({
|
|
||||||
name: values.name,
|
|
||||||
...(values.note ? { note: values.note } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
|
||||||
AsyncSurface,
|
|
||||||
Button,
|
|
||||||
CollectionPage,
|
|
||||||
} from "../../../presentation/design-system/index.ts";
|
|
||||||
import { useReferenceFailureAction } from "./use-reference-failure-action.ts";
|
|
||||||
import { useReferenceFeature } from "./use-reference-feature.ts";
|
|
||||||
|
|
||||||
export default function ReferenceResourcePage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { filters, query } = useReferenceFeature();
|
|
||||||
const failureAction = useReferenceFailureAction(query.state.failure);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CollectionPage
|
|
||||||
heading={{
|
|
||||||
eyebrow: "제거 가능한 수직 슬라이스",
|
|
||||||
title: "Reference feature",
|
|
||||||
description:
|
|
||||||
"URL codec, application input, HTTP/schema/mapper와 query 상태를 한 경로로 검증합니다.",
|
|
||||||
}}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
kind: "button",
|
|
||||||
label: "새 항목 만들기",
|
|
||||||
onAction: () => navigate("/examples/reference-resources/new"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
activeFilters={
|
|
||||||
<p data-testid="reference-filter">
|
|
||||||
limit {filters.limit}
|
|
||||||
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
toolbar={<Button onClick={() => void query.retry()}>새로고침</Button>}
|
|
||||||
resultCount={
|
|
||||||
query.data ? `총 ${query.data.length}개 항목` : "결과 확인 중"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AsyncSurface
|
|
||||||
state={query.state}
|
|
||||||
onAction={failureAction}
|
|
||||||
onRetry={query.retry}
|
|
||||||
>
|
|
||||||
<ul aria-label="Reference resources">
|
|
||||||
{(query.data ?? []).map((resource) => (
|
|
||||||
<li key={resource.resourceId}>
|
|
||||||
<Link
|
|
||||||
to={`/examples/reference-resources/${encodeURIComponent(resource.resourceId)}`}
|
|
||||||
>
|
|
||||||
{resource.title}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AsyncSurface>
|
|
||||||
</CollectionPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { StatusPage } from "../../../presentation/design-system/index.ts";
|
|
||||||
|
|
||||||
export default function ReferenceResourceStatusPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StatusPage
|
|
||||||
variant="maintenance"
|
|
||||||
heading={{
|
|
||||||
eyebrow: "StatusPage · maintenance",
|
|
||||||
title: "잠시 사용할 수 없습니다.",
|
|
||||||
description:
|
|
||||||
"도메인 데이터나 raw 오류를 노출하지 않는 중립적인 상태 페이지 예시입니다.",
|
|
||||||
}}
|
|
||||||
primaryAction={{
|
|
||||||
kind: "button",
|
|
||||||
label: "목록으로 이동",
|
|
||||||
onAction: () => navigate("/examples/reference-resources"),
|
|
||||||
}}
|
|
||||||
supportReference="REFERENCE-STATUS-DEMO"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import type { AppFailure } from "../../../contracts/errors.ts";
|
|
||||||
import { useSession } from "../../../presentation/providers/session-provider.tsx";
|
|
||||||
|
|
||||||
const REFERENCE_SUPPORT_ROUTE = "/examples/reference-resources/status";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reference queries own concrete destinations for generic application failure
|
|
||||||
* actions. Retry remains query-owned; guarded release reloads remain in the
|
|
||||||
* chunk recovery boundary.
|
|
||||||
*/
|
|
||||||
export function useReferenceFailureAction(
|
|
||||||
failure: AppFailure | undefined,
|
|
||||||
): (() => void) | undefined {
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { beginSignIn } = useSession();
|
|
||||||
const action = failure?.action;
|
|
||||||
const handleAction = useCallback(() => {
|
|
||||||
if (action === "reauth") {
|
|
||||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
|
||||||
void beginSignIn(returnTo).catch(() => {
|
|
||||||
void navigate("/");
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action === "navigate") {
|
|
||||||
void navigate("/");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action === "contact-support") {
|
|
||||||
void navigate(REFERENCE_SUPPORT_ROUTE);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
action,
|
|
||||||
beginSignIn,
|
|
||||||
location.hash,
|
|
||||||
location.pathname,
|
|
||||||
location.search,
|
|
||||||
navigate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return action === "reauth" ||
|
|
||||||
action === "navigate" ||
|
|
||||||
action === "contact-support"
|
|
||||||
? handleAction
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useApplication } from "../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
|
||||||
useApplicationMutation,
|
|
||||||
useApplicationQuery,
|
|
||||||
} from "../../../presentation/adapters/query/application-query.ts";
|
|
||||||
import { useRouteInput } from "../../../presentation/routes/route-input.tsx";
|
|
||||||
import {
|
|
||||||
REFERENCE_FEATURE_ID,
|
|
||||||
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
|
||||||
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
|
||||||
} from "../contracts/reference-feature-contract.ts";
|
|
||||||
import type {
|
|
||||||
ReferenceCreateCommand,
|
|
||||||
ReferenceFeatureInput,
|
|
||||||
ReferenceListFilters,
|
|
||||||
} from "../application/reference-feature-api.ts";
|
|
||||||
import type { ReferenceResourceView } from "../contracts/reference-mapper.ts";
|
|
||||||
import {
|
|
||||||
bindQuery,
|
|
||||||
type BoundMutation,
|
|
||||||
type QueryResultMeasure,
|
|
||||||
} from "../../../contracts/server-state.ts";
|
|
||||||
import { useServerStateScope } from "../../../presentation/adapters/query/server-state-scope-provider.tsx";
|
|
||||||
|
|
||||||
const UTF8 = new TextEncoder();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* §10.4. Feature-owned measurement over the mapped application value. There is
|
|
||||||
* no generic fallback: bounded string bytes plus fixed primitive width plus a
|
|
||||||
* small per-item overhead, never `JSON.stringify` or a recursive walker.
|
|
||||||
*/
|
|
||||||
function measureResourceView(view: ReferenceResourceView): QueryResultMeasure {
|
|
||||||
return {
|
|
||||||
itemCount: 1,
|
|
||||||
estimatedBytes:
|
|
||||||
UTF8.encode(view.resourceId).byteLength +
|
|
||||||
UTF8.encode(view.title).byteLength +
|
|
||||||
UTF8.encode(view.createdAt ?? "").byteLength +
|
|
||||||
32,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function measureResourceList(
|
|
||||||
views: readonly ReferenceResourceView[],
|
|
||||||
): QueryResultMeasure {
|
|
||||||
let estimatedBytes = 16;
|
|
||||||
for (const view of views) {
|
|
||||||
estimatedBytes += measureResourceView(view).estimatedBytes;
|
|
||||||
}
|
|
||||||
return { itemCount: views.length, estimatedBytes };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReferenceFeatureInput(): ReferenceFeatureInput {
|
|
||||||
return useApplication().features.get(REFERENCE_FEATURE_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReferenceDetail(resourceId: string) {
|
|
||||||
const input = useReferenceFeatureInput();
|
|
||||||
const scope = useServerStateScope();
|
|
||||||
const query = useApplicationQuery(
|
|
||||||
bindQuery(
|
|
||||||
{
|
|
||||||
definitionId: "reference-resource-detail-v1",
|
|
||||||
definitionVersion: 1,
|
|
||||||
owner: REFERENCE_FEATURE_ID,
|
|
||||||
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
|
|
||||||
namespaceVersion:
|
|
||||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
|
|
||||||
operationId: "GET_REFERENCE_RESOURCE",
|
|
||||||
profileId: "DETAIL_STANDARD",
|
|
||||||
measureResult: measureResourceView,
|
|
||||||
execute: (selectedResourceId: string, { signal }) =>
|
|
||||||
input.getResource(selectedResourceId, { signal }),
|
|
||||||
},
|
|
||||||
resourceId,
|
|
||||||
scope,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return Object.freeze({ query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReferenceCreate() {
|
|
||||||
const input = useReferenceFeatureInput();
|
|
||||||
const scope = useServerStateScope();
|
|
||||||
const mutation: BoundMutation<
|
|
||||||
ReferenceCreateCommand,
|
|
||||||
ReferenceResourceView
|
|
||||||
> = {
|
|
||||||
definitionId: "reference-resource-create-v1",
|
|
||||||
definitionVersion: 1,
|
|
||||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
|
||||||
requiresIdempotencyKey: true,
|
|
||||||
owner: REFERENCE_FEATURE_ID,
|
|
||||||
duplicatePolicy: "REJECT_WHILE_ACTIVE",
|
|
||||||
scope,
|
|
||||||
execute: input.createResource,
|
|
||||||
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
|
|
||||||
};
|
|
||||||
return useApplicationMutation(mutation);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReferenceFeature() {
|
|
||||||
const input = useReferenceFeatureInput();
|
|
||||||
const scope = useServerStateScope();
|
|
||||||
const routeInput = useRouteInput();
|
|
||||||
const filters = routeInput.search as ReferenceListFilters;
|
|
||||||
const query = useApplicationQuery(
|
|
||||||
bindQuery(
|
|
||||||
{
|
|
||||||
definitionId: "reference-resource-list-v1",
|
|
||||||
definitionVersion: 1,
|
|
||||||
owner: REFERENCE_FEATURE_ID,
|
|
||||||
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
|
|
||||||
namespaceVersion:
|
|
||||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
|
|
||||||
operationId: "LIST_REFERENCE_RESOURCES",
|
|
||||||
profileId: "LIST_STANDARD",
|
|
||||||
measureResult: measureResourceList,
|
|
||||||
execute: (selectedFilters: ReferenceListFilters, { signal }) =>
|
|
||||||
input.listResources(selectedFilters, { signal }),
|
|
||||||
},
|
|
||||||
filters,
|
|
||||||
scope,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const mutation = useApplicationMutation({
|
|
||||||
definitionId: "reference-resource-create-v1",
|
|
||||||
definitionVersion: 1,
|
|
||||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
|
||||||
requiresIdempotencyKey: true,
|
|
||||||
owner: REFERENCE_FEATURE_ID,
|
|
||||||
duplicatePolicy: "REJECT_WHILE_ACTIVE",
|
|
||||||
scope,
|
|
||||||
execute: input.createResource,
|
|
||||||
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
|
|
||||||
});
|
|
||||||
return Object.freeze({ filters, query, mutation });
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
RouteDefinition,
|
RouteDefinition,
|
||||||
RouteLayoutGroup,
|
RouteLayoutGroup,
|
||||||
} from "../../../contracts/routes.ts";
|
} from "../../../contracts/routes.ts";
|
||||||
|
import type { SchemaDefinition } from "../../../contracts/schema-registry.ts";
|
||||||
|
|
||||||
type RouteSpec = Readonly<{
|
type RouteSpec = Readonly<{
|
||||||
routeId: string;
|
routeId: string;
|
||||||
@@ -52,6 +53,79 @@ const TECH_LOG_ROUTE_SPECS = [
|
|||||||
|
|
||||||
export type TechLogRouteId = (typeof TECH_LOG_ROUTE_SPECS)[number]["routeId"];
|
export type TechLogRouteId = (typeof TECH_LOG_ROUTE_SPECS)[number]["routeId"];
|
||||||
|
|
||||||
|
const routeSchema = (
|
||||||
|
schemaId: string,
|
||||||
|
boundary: "route-params" | "route-search",
|
||||||
|
unknownFieldPolicy: "REJECT_UNKNOWN" | "STRIP_UNKNOWN",
|
||||||
|
): SchemaDefinition =>
|
||||||
|
Object.freeze({
|
||||||
|
schemaId,
|
||||||
|
boundary,
|
||||||
|
owner: "feature-tech-log",
|
||||||
|
runtime: "zod",
|
||||||
|
schemaVersion: 1,
|
||||||
|
direction: "REQUEST",
|
||||||
|
unknownFieldPolicy,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TECH_LOG_ROUTE_SCHEMA_REGISTRY = Object.freeze({
|
||||||
|
TechLogExploreKindParams: routeSchema(
|
||||||
|
"TechLogExploreKindParams",
|
||||||
|
"route-params",
|
||||||
|
"REJECT_UNKNOWN",
|
||||||
|
),
|
||||||
|
TechLogSlugParams: routeSchema(
|
||||||
|
"TechLogSlugParams",
|
||||||
|
"route-params",
|
||||||
|
"REJECT_UNKNOWN",
|
||||||
|
),
|
||||||
|
TechLogVersionParams: routeSchema(
|
||||||
|
"TechLogVersionParams",
|
||||||
|
"route-params",
|
||||||
|
"REJECT_UNKNOWN",
|
||||||
|
),
|
||||||
|
TechLogDocumentIdParams: routeSchema(
|
||||||
|
"TechLogDocumentIdParams",
|
||||||
|
"route-params",
|
||||||
|
"REJECT_UNKNOWN",
|
||||||
|
),
|
||||||
|
TechLogPublicationEventIdParams: routeSchema(
|
||||||
|
"TechLogPublicationEventIdParams",
|
||||||
|
"route-params",
|
||||||
|
"REJECT_UNKNOWN",
|
||||||
|
),
|
||||||
|
TechLogStudioSplat: routeSchema(
|
||||||
|
"TechLogStudioSplat",
|
||||||
|
"route-params",
|
||||||
|
"REJECT_UNKNOWN",
|
||||||
|
),
|
||||||
|
TechLogHomeSearch: routeSchema(
|
||||||
|
"TechLogHomeSearch",
|
||||||
|
"route-search",
|
||||||
|
"STRIP_UNKNOWN",
|
||||||
|
),
|
||||||
|
TechLogExploreSearch: routeSchema(
|
||||||
|
"TechLogExploreSearch",
|
||||||
|
"route-search",
|
||||||
|
"STRIP_UNKNOWN",
|
||||||
|
),
|
||||||
|
TechLogExploreKindSearch: routeSchema(
|
||||||
|
"TechLogExploreKindSearch",
|
||||||
|
"route-search",
|
||||||
|
"STRIP_UNKNOWN",
|
||||||
|
),
|
||||||
|
TechLogSearchQuery: routeSchema(
|
||||||
|
"TechLogSearchQuery",
|
||||||
|
"route-search",
|
||||||
|
"STRIP_UNKNOWN",
|
||||||
|
),
|
||||||
|
TechLogCaseStateSearch: routeSchema(
|
||||||
|
"TechLogCaseStateSearch",
|
||||||
|
"route-search",
|
||||||
|
"STRIP_UNKNOWN",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
function chunkId(routeId: TechLogRouteId): string {
|
function chunkId(routeId: TechLogRouteId): string {
|
||||||
return routeId === "NOT_FOUND"
|
return routeId === "NOT_FOUND"
|
||||||
? "route-not-found"
|
? "route-not-found"
|
||||||
@@ -92,4 +166,5 @@ export const TECH_LOG_ROUTE_RUNTIME_CONTRACT = Object.freeze(
|
|||||||
export const TECH_LOG_ROUTE_CONTRACT = Object.freeze({
|
export const TECH_LOG_ROUTE_CONTRACT = Object.freeze({
|
||||||
routes: TECH_LOG_ROUTE_REGISTRY,
|
routes: TECH_LOG_ROUTE_REGISTRY,
|
||||||
routeRuntimeContracts: TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
routeRuntimeContracts: TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
||||||
|
schemas: TECH_LOG_ROUTE_SCHEMA_REGISTRY,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type {
|
||||||
|
WorkingCopy,
|
||||||
|
WorkingCopyInput,
|
||||||
|
} from "../../../contracts/studio/contract.ts";
|
||||||
|
import type { StudioEditorStatus } from "../use-studio.ts";
|
||||||
|
|
||||||
|
export type DocumentEditorController = {
|
||||||
|
saved: WorkingCopy;
|
||||||
|
draft: WorkingCopyInput;
|
||||||
|
status: StudioEditorStatus;
|
||||||
|
update(patch: Partial<WorkingCopyInput>): void;
|
||||||
|
replace(draft: WorkingCopyInput): void;
|
||||||
|
save(): Promise<void>;
|
||||||
|
};
|
||||||
@@ -7,7 +7,8 @@ import type {
|
|||||||
WorkingCopyInput,
|
WorkingCopyInput,
|
||||||
} from "../../../contracts/studio/contract.ts";
|
} from "../../../contracts/studio/contract.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx";
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
|
import { DocumentEditor } from "./document-editor.tsx";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { useRef, useState, type KeyboardEvent } from "react";
|
import { useRef, useState, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import type {
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
WorkingCopy,
|
|
||||||
WorkingCopyInput,
|
|
||||||
} from "../../../contracts/studio/contract.ts";
|
|
||||||
import type { StudioEditorStatus } from "../use-studio.ts";
|
|
||||||
import { CaseFields } from "./case-fields.tsx";
|
import { CaseFields } from "./case-fields.tsx";
|
||||||
import { CommonDocumentFields } from "./common-document-fields.tsx";
|
import { CommonDocumentFields } from "./common-document-fields.tsx";
|
||||||
import { DocumentStatusRail } from "./document-status-rail.tsx";
|
import { DocumentStatusRail } from "./document-status-rail.tsx";
|
||||||
@@ -15,15 +11,6 @@ import { ReferenceFields } from "./reference-fields.tsx";
|
|||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
|
||||||
export type DocumentEditorController = {
|
|
||||||
saved: WorkingCopy;
|
|
||||||
draft: WorkingCopyInput;
|
|
||||||
status: StudioEditorStatus;
|
|
||||||
update(patch: Partial<WorkingCopyInput>): void;
|
|
||||||
replace(draft: WorkingCopyInput): void;
|
|
||||||
save(): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
|
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
|
||||||
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
|
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
|
||||||
const editTab = useRef<HTMLButtonElement>(null);
|
const editTab = useRef<HTMLButtonElement>(null);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DocumentEditorController } from "./document-editor.tsx";
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
|
|||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
/* eslint-disable react-hooks/set-state-in-effect -- a changed event or retry intentionally enters a fresh loading state. */
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isStudioGatewayError,
|
||||||
|
type StudioGatewayError,
|
||||||
|
} from "../../../application/ports/studio-gateway-error.ts";
|
||||||
|
import type { PublicationSnapshot } from "../../../contracts/studio/contract.ts";
|
||||||
|
import type { EvidenceAsset } from "../../../domain/public-render-content.ts";
|
||||||
|
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
import {
|
||||||
|
defaultPublicationFlowClasses,
|
||||||
|
type PublicationFlowClasses,
|
||||||
|
} from "./publication-flow-classes.ts";
|
||||||
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
|
const eventLabels = {
|
||||||
|
PUBLISHED: "게시",
|
||||||
|
REPUBLISHED: "재게시",
|
||||||
|
UNPUBLISHED: "게시 취소",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function resolveEvidenceAsset(key: string): EvidenceAsset {
|
||||||
|
if (key !== "fetch-strategy-boundary") {
|
||||||
|
throw new Error(`Unknown local evidence asset: ${key}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
src: "/media/fetch-strategy-boundary.svg",
|
||||||
|
width: 1080,
|
||||||
|
height: 420,
|
||||||
|
triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
|
||||||
|
dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("ko-KR", {
|
||||||
|
dateStyle: "long",
|
||||||
|
timeStyle: "short",
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicationEventPreviewScreen({
|
||||||
|
publicationEventId,
|
||||||
|
classes: styles = defaultPublicationFlowClasses,
|
||||||
|
}: {
|
||||||
|
publicationEventId: string;
|
||||||
|
classes?: PublicationFlowClasses;
|
||||||
|
}) {
|
||||||
|
const { gateway } = useStudio();
|
||||||
|
const [retry, setRetry] = useState(0);
|
||||||
|
const [state, setState] = useState<
|
||||||
|
| { status: "LOADING" }
|
||||||
|
| { status: "ERROR"; problem: StudioGatewayError | Error }
|
||||||
|
| { status: "READY"; snapshot: PublicationSnapshot }
|
||||||
|
>({ status: "LOADING" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let current = true;
|
||||||
|
setState({ status: "LOADING" });
|
||||||
|
void gateway
|
||||||
|
.getPublicationSnapshot(publicationEventId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(snapshot) => {
|
||||||
|
if (current) setState({ status: "READY", snapshot });
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
if (!current || controller.signal.aborted) return;
|
||||||
|
setState({
|
||||||
|
status: "ERROR",
|
||||||
|
problem:
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error("Snapshot을 불러오지 못했습니다."),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
current = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [gateway, publicationEventId, retry]);
|
||||||
|
|
||||||
|
if (state.status === "LOADING") {
|
||||||
|
return (
|
||||||
|
<p className={styles.loading} role="status">
|
||||||
|
게시 Snapshot을 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status === "ERROR") {
|
||||||
|
const missing =
|
||||||
|
isStudioGatewayError(state.problem) &&
|
||||||
|
(state.problem.code === "PUBLICATION_EVENT_NOT_FOUND" ||
|
||||||
|
state.problem.code === "PUBLICATION_SNAPSHOT_NOT_FOUND");
|
||||||
|
if (missing) {
|
||||||
|
return (
|
||||||
|
<section className={styles.routeState}>
|
||||||
|
<p className={styles.eyebrow}>NOT FOUND</p>
|
||||||
|
<h1>게시 기록을 찾을 수 없습니다</h1>
|
||||||
|
<p>
|
||||||
|
이 이벤트는 현재 Studio 세션에 없거나 Snapshot을 소유하지
|
||||||
|
않습니다.
|
||||||
|
</p>
|
||||||
|
<GuardedStudioLink href="/studio/publications">
|
||||||
|
게시 기록으로 돌아가기
|
||||||
|
</GuardedStudioLink>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className={styles.routeState} role="alert">
|
||||||
|
<p className={styles.eyebrow}>SNAPSHOT ERROR</p>
|
||||||
|
<h1>게시 Snapshot을 불러오지 못했습니다</h1>
|
||||||
|
<p>
|
||||||
|
{isStudioGatewayError(state.problem)
|
||||||
|
? state.problem.problem.detail
|
||||||
|
: state.problem.message}
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { event, renderModel } = state.snapshot;
|
||||||
|
return (
|
||||||
|
<div className={styles.snapshotPage}>
|
||||||
|
<section
|
||||||
|
className={styles.snapshotToolbar}
|
||||||
|
aria-label="게시 Snapshot 정보"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>IMMUTABLE SNAPSHOT</p>
|
||||||
|
<p className={styles.snapshotTitle}>게시 이벤트 시점의 공개 화면</p>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>이벤트</dt>
|
||||||
|
<dd>{eventLabels[event.type]}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>게시 버전</dt>
|
||||||
|
<dd>v{event.publishedVersion}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>발생 시각</dt>
|
||||||
|
<dd>
|
||||||
|
<time dateTime={event.occurredAt}>
|
||||||
|
{dateTime(event.occurredAt)}
|
||||||
|
</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<GuardedStudioLink href="/studio/publications">
|
||||||
|
게시 기록으로 돌아가기
|
||||||
|
</GuardedStudioLink>
|
||||||
|
</section>
|
||||||
|
<div className={styles.snapshotDocument}>
|
||||||
|
<PublicRecordRenderer
|
||||||
|
model={renderModel}
|
||||||
|
embedded
|
||||||
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
|
resolvePublishedLabel={() => undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/* eslint-disable react-hooks/set-state-in-effect -- a changed history query intentionally enters a fresh loading state. */
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isStudioGatewayError,
|
||||||
|
type StudioGatewayError,
|
||||||
|
} from "../../../application/ports/studio-gateway-error.ts";
|
||||||
|
import type { ListPublicationsQuery } from "../../../application/ports/studio-gateway.ts";
|
||||||
|
import type {
|
||||||
|
PublicationListItem,
|
||||||
|
PublicationPage,
|
||||||
|
} from "../../../contracts/studio/contract.ts";
|
||||||
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
import {
|
||||||
|
defaultPublicationFlowClasses,
|
||||||
|
type PublicationFlowClasses,
|
||||||
|
} from "./publication-flow-classes.ts";
|
||||||
|
import { UnpublishDialog } from "./unpublish-dialog.tsx";
|
||||||
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
|
const eventLabels = {
|
||||||
|
PUBLISHED: "게시",
|
||||||
|
REPUBLISHED: "재게시",
|
||||||
|
UNPUBLISHED: "게시 취소",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const kindLabels = {
|
||||||
|
CASE: "Case",
|
||||||
|
REFERENCE: "Reference",
|
||||||
|
QUESTION: "Question",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function dateTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("ko-KR", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotHref(item: PublicationListItem) {
|
||||||
|
if (
|
||||||
|
item.availableActions.includes("VIEW_SOURCE_SNAPSHOT") &&
|
||||||
|
item.event.sourcePublishedEventId
|
||||||
|
) {
|
||||||
|
return `/studio/publications/${item.event.sourcePublishedEventId}/preview`;
|
||||||
|
}
|
||||||
|
if (item.availableActions.includes("VIEW_SNAPSHOT")) {
|
||||||
|
return `/studio/publications/${item.event.publicationEventId}/preview`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicationList({
|
||||||
|
classes: styles = defaultPublicationFlowClasses,
|
||||||
|
}: { classes?: PublicationFlowClasses } = {}) {
|
||||||
|
const studio = useStudio();
|
||||||
|
const [query, setQuery] = useState<ListPublicationsQuery>({ limit: 100 });
|
||||||
|
const [queryDraft, setQueryDraft] = useState("");
|
||||||
|
const [typeDraft, setTypeDraft] =
|
||||||
|
useState<ListPublicationsQuery["type"]>();
|
||||||
|
const [reload, setReload] = useState(0);
|
||||||
|
const [state, setState] = useState<
|
||||||
|
| { status: "LOADING" }
|
||||||
|
| { status: "ERROR"; problem: StudioGatewayError | Error }
|
||||||
|
| { status: "READY"; page: PublicationPage }
|
||||||
|
>({ status: "LOADING" });
|
||||||
|
const [selected, setSelected] = useState<PublicationListItem | null>(null);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [dialogError, setDialogError] = useState("");
|
||||||
|
const [announcement, setAnnouncement] = useState("");
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let current = true;
|
||||||
|
setState({ status: "LOADING" });
|
||||||
|
void studio.gateway.listPublications(query, {
|
||||||
|
signal: controller.signal,
|
||||||
|
}).then(
|
||||||
|
(page) => {
|
||||||
|
if (current) setState({ status: "READY", page });
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
if (!current || controller.signal.aborted) return;
|
||||||
|
setState({
|
||||||
|
status: "ERROR",
|
||||||
|
problem:
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error("게시 기록을 불러오지 못했습니다."),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
current = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [query, reload, studio.gateway]);
|
||||||
|
|
||||||
|
const applyFilter = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setQuery({
|
||||||
|
...(queryDraft.trim() ? { q: queryDraft.trim().slice(0, 100) } : {}),
|
||||||
|
...(typeDraft ? { type: typeDraft } : {}),
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setSelected(null);
|
||||||
|
setDialogError("");
|
||||||
|
queueMicrotask(() => triggerRef.current?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmUnpublish = async () => {
|
||||||
|
if (
|
||||||
|
!selected ||
|
||||||
|
pending ||
|
||||||
|
!selected.availableActions.includes("UNPUBLISH")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
setDialogError("");
|
||||||
|
try {
|
||||||
|
const result = await studio.gateway.unpublishPublication(
|
||||||
|
selected.publication.publicationId,
|
||||||
|
{
|
||||||
|
expectedPublicationRevision:
|
||||||
|
selected.publication.publicationRevision,
|
||||||
|
},
|
||||||
|
{ idempotencyKey: createLocalId("studio-unpublish") },
|
||||||
|
);
|
||||||
|
setSelected(null);
|
||||||
|
setAnnouncement("게시를 취소했습니다.");
|
||||||
|
studio.setRequestAnnouncement("게시를 취소했습니다.");
|
||||||
|
setReload((value) => value + 1);
|
||||||
|
} catch (error) {
|
||||||
|
setDialogError(
|
||||||
|
isStudioGatewayError(error)
|
||||||
|
? error.problem.detail
|
||||||
|
: "게시를 취소하지 못했습니다.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.heading}>
|
||||||
|
<p className={styles.eyebrow}>PUBLICATION EVENTS</p>
|
||||||
|
<h1>게시 기록</h1>
|
||||||
|
<p>
|
||||||
|
게시·재게시·게시 취소 이벤트와 각 시점의 Snapshot을 확인합니다.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className={styles.filters} onSubmit={applyFilter}>
|
||||||
|
<label>
|
||||||
|
<span>검색</span>
|
||||||
|
<input
|
||||||
|
value={queryDraft}
|
||||||
|
maxLength={100}
|
||||||
|
onChange={(event) => setQueryDraft(event.currentTarget.value)}
|
||||||
|
placeholder="제목 또는 요약"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>이벤트</span>
|
||||||
|
<select
|
||||||
|
value={typeDraft ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setTypeDraft(
|
||||||
|
(event.currentTarget.value || undefined) as ListPublicationsQuery["type"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="PUBLISHED">게시</option>
|
||||||
|
<option value="REPUBLISHED">재게시</option>
|
||||||
|
<option value="UNPUBLISHED">게시 취소</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">적용</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{announcement ? (
|
||||||
|
<p className={styles.success} role="status">
|
||||||
|
{announcement}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{state.status === "LOADING" ? (
|
||||||
|
<p className={styles.loading} role="status">
|
||||||
|
게시 기록을 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{state.status === "ERROR" ? (
|
||||||
|
<section className={styles.errorState} role="alert">
|
||||||
|
<h2>게시 기록을 불러오지 못했습니다</h2>
|
||||||
|
<p>
|
||||||
|
{isStudioGatewayError(state.problem)
|
||||||
|
? state.problem.problem.detail
|
||||||
|
: state.problem.message}
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={() => setReload((value) => value + 1)}>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{state.status === "READY" && state.page.items.length === 0 ? (
|
||||||
|
<section className={styles.emptyState}>
|
||||||
|
<h2>조건에 맞는 게시 기록이 없습니다</h2>
|
||||||
|
<p>검색어 또는 이벤트 필터를 바꿔 보세요.</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{state.status === "READY" && state.page.items.length > 0 ? (
|
||||||
|
<ol className={styles.historyList}>
|
||||||
|
{state.page.items.map((item) => {
|
||||||
|
const href = snapshotHref(item);
|
||||||
|
const source = item.availableActions.includes(
|
||||||
|
"VIEW_SOURCE_SNAPSHOT",
|
||||||
|
);
|
||||||
|
const canUnpublish = item.availableActions.includes("UNPUBLISH");
|
||||||
|
return (
|
||||||
|
<li key={item.event.publicationEventId}>
|
||||||
|
<article className={styles.historyRow}>
|
||||||
|
<p className={styles.eventType}>
|
||||||
|
{eventLabels[item.event.type]}
|
||||||
|
</p>
|
||||||
|
<div className={styles.eventMain}>
|
||||||
|
<p className={styles.kind}>
|
||||||
|
{kindLabels[item.document.kind]} · v
|
||||||
|
{item.event.publishedVersion}
|
||||||
|
</p>
|
||||||
|
<h2>{item.document.title || "제목 없는 작업본"}</h2>
|
||||||
|
<p>
|
||||||
|
이벤트: {eventLabels[item.event.type]} · 현재 상태:{" "}
|
||||||
|
{item.publication.status === "PUBLISHED"
|
||||||
|
? "게시 중"
|
||||||
|
: "게시 취소"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<time dateTime={item.event.occurredAt}>
|
||||||
|
{dateTime(item.event.occurredAt)}
|
||||||
|
</time>
|
||||||
|
<div className={styles.rowActions}>
|
||||||
|
{href ? (
|
||||||
|
<GuardedStudioLink href={href}>
|
||||||
|
{source ? "게시 취소 전 Snapshot 보기" : "Snapshot 보기"}
|
||||||
|
</GuardedStudioLink>
|
||||||
|
) : null}
|
||||||
|
{canUnpublish ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${item.document.title || "제목 없는 작업본"} 게시 취소`}
|
||||||
|
onClick={(event) => {
|
||||||
|
triggerRef.current = event.currentTarget;
|
||||||
|
setDialogError("");
|
||||||
|
setSelected(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
게시 취소
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<UnpublishDialog
|
||||||
|
item={selected}
|
||||||
|
pending={pending}
|
||||||
|
error={dialogError}
|
||||||
|
onDismiss={dismiss}
|
||||||
|
onConfirm={() => {
|
||||||
|
void confirmUnpublish();
|
||||||
|
}}
|
||||||
|
classes={styles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
/* eslint-disable react-hooks/set-state-in-effect -- a changed document or retry intentionally enters a fresh loading state. */
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isStudioGatewayError,
|
||||||
|
type StudioGatewayError,
|
||||||
|
} from "../../../application/ports/studio-gateway-error.ts";
|
||||||
|
import type {
|
||||||
|
PreviewDetail,
|
||||||
|
WorkingCopyDetail,
|
||||||
|
} from "../../../contracts/studio/contract.ts";
|
||||||
|
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
||||||
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
import {
|
||||||
|
defaultPublicationFlowClasses,
|
||||||
|
type PublicationFlowClasses,
|
||||||
|
} from "./publication-flow-classes.ts";
|
||||||
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
import { WarningAcknowledgements } from "./warning-acknowledgements.tsx";
|
||||||
|
|
||||||
|
type LoadState =
|
||||||
|
| { status: "LOADING" }
|
||||||
|
| { status: "ERROR"; problem: StudioGatewayError | Error }
|
||||||
|
| { status: "READY"; detail: WorkingCopyDetail; preview: PreviewDetail | null };
|
||||||
|
|
||||||
|
function dateTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("ko-KR", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(problem: StudioGatewayError | Error) {
|
||||||
|
return isStudioGatewayError(problem) && problem.code === "DOCUMENT_NOT_FOUND";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublishScreen({
|
||||||
|
documentId,
|
||||||
|
classes: styles = defaultPublicationFlowClasses,
|
||||||
|
}: {
|
||||||
|
documentId: string;
|
||||||
|
classes?: PublicationFlowClasses;
|
||||||
|
}) {
|
||||||
|
const studio = useStudio();
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [state, setState] = useState<LoadState>({ status: "LOADING" });
|
||||||
|
const [acknowledged, setAcknowledged] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let current = true;
|
||||||
|
setState({ status: "LOADING" });
|
||||||
|
setAcknowledged(new Set());
|
||||||
|
setActionError("");
|
||||||
|
setSuccess("");
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const detail = await studio.gateway.getDocument(documentId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const sameVersion =
|
||||||
|
detail.currentPublication?.status === "PUBLISHED" &&
|
||||||
|
detail.currentPublication.publishedVersion === detail.document.version;
|
||||||
|
let preview: PreviewDetail | null = null;
|
||||||
|
if (!sameVersion && detail.latestPreview) {
|
||||||
|
try {
|
||||||
|
preview = await studio.gateway.getCurrentPreview(documentId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
isStudioGatewayError(error) &&
|
||||||
|
error.code === "PREVIEW_NOT_FOUND"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) setState({ status: "READY", detail, preview });
|
||||||
|
})().catch((error: unknown) => {
|
||||||
|
if (!current || controller.signal.aborted) return;
|
||||||
|
setState({
|
||||||
|
status: "ERROR",
|
||||||
|
problem:
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error("게시 정보를 불러오지 못했습니다."),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
current = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [attempt, documentId, studio.gateway]);
|
||||||
|
|
||||||
|
const ready = state.status === "READY" ? state : null;
|
||||||
|
const validation = ready?.detail.currentValidation ?? null;
|
||||||
|
const warnings = useMemo(
|
||||||
|
() =>
|
||||||
|
validation?.issues.filter((issue) => issue.severity === "WARNING") ?? [],
|
||||||
|
[validation],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.status === "LOADING") {
|
||||||
|
return (
|
||||||
|
<p className={styles.loading} role="status">
|
||||||
|
게시 준비 정보를 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status === "ERROR" && notFound(state.problem)) {
|
||||||
|
return (
|
||||||
|
<section className={styles.routeState}>
|
||||||
|
<p className={styles.eyebrow}>NOT FOUND</p>
|
||||||
|
<h1>작업본을 찾을 수 없습니다</h1>
|
||||||
|
<p>이 작업본은 현재 Studio 세션에 없습니다.</p>
|
||||||
|
<GuardedStudioLink href="/studio/documents">
|
||||||
|
작업본으로 돌아가기
|
||||||
|
</GuardedStudioLink>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status === "ERROR") {
|
||||||
|
return (
|
||||||
|
<section className={styles.routeState} role="alert">
|
||||||
|
<p className={styles.eyebrow}>PUBLISH ERROR</p>
|
||||||
|
<h1>게시 정보를 불러오지 못했습니다</h1>
|
||||||
|
<p>
|
||||||
|
{isStudioGatewayError(state.problem)
|
||||||
|
? state.problem.problem.detail
|
||||||
|
: state.problem.message}
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { detail, preview } = state;
|
||||||
|
const { document, currentPublication } = detail;
|
||||||
|
const sameVersion =
|
||||||
|
currentPublication?.status === "PUBLISHED" &&
|
||||||
|
currentPublication.publishedVersion === document.version;
|
||||||
|
const validationMatches =
|
||||||
|
validation !== null &&
|
||||||
|
deriveValidationState({ ...detail, now: studio.now() }).freshness ===
|
||||||
|
"CURRENT";
|
||||||
|
const validationAllowsPreview =
|
||||||
|
validationMatches && validation?.status !== "INVALID";
|
||||||
|
const previewIsCurrent =
|
||||||
|
preview?.state === "CURRENT" &&
|
||||||
|
preview.preview.previewVersion === document.version &&
|
||||||
|
preview.currentValidationId === validation?.validationId;
|
||||||
|
const warningsDone = warnings.every((warning) =>
|
||||||
|
acknowledged.has(warning.code),
|
||||||
|
);
|
||||||
|
const canPublish =
|
||||||
|
validationAllowsPreview && previewIsCurrent && warningsDone && !pending;
|
||||||
|
const actionLabel = currentPublication ? "다시 게시" : "게시";
|
||||||
|
|
||||||
|
const toggleWarning = (code: string, checked: boolean) => {
|
||||||
|
setAcknowledged((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (checked) next.add(code);
|
||||||
|
else next.delete(code);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const publish = async () => {
|
||||||
|
if (!canPublish || !validation || !preview) return;
|
||||||
|
setPending(true);
|
||||||
|
setActionError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const result = await studio.gateway.publishDocument(
|
||||||
|
document.id,
|
||||||
|
{
|
||||||
|
expectedVersion: document.version,
|
||||||
|
validationId: validation.validationId,
|
||||||
|
previewId: preview.preview.previewId,
|
||||||
|
acknowledgedWarningCodes: [...acknowledged].sort(),
|
||||||
|
},
|
||||||
|
{ idempotencyKey: createLocalId("studio-publish") },
|
||||||
|
);
|
||||||
|
setSuccess("게시했습니다.");
|
||||||
|
studio.setRequestAnnouncement("게시했습니다.");
|
||||||
|
studio.clearEditor();
|
||||||
|
studio.navigateInternal(
|
||||||
|
`/studio/publications/${result.event.publicationEventId}/preview`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(
|
||||||
|
isStudioGatewayError(error)
|
||||||
|
? error.problem.detail
|
||||||
|
: "게시하지 못했습니다. 다시 시도해 주세요.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sameVersion && currentPublication) {
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.heading}>
|
||||||
|
<p className={styles.eyebrow}>PUBLICATION</p>
|
||||||
|
<h1>게시 준비</h1>
|
||||||
|
<p>{document.title}</p>
|
||||||
|
</header>
|
||||||
|
<section
|
||||||
|
className={styles.gateReady}
|
||||||
|
aria-labelledby="same-version-title"
|
||||||
|
>
|
||||||
|
<h2 id="same-version-title">현재 버전이 이미 게시되어 있습니다</h2>
|
||||||
|
<p>
|
||||||
|
저장 버전 v{document.version}과 게시 버전이 같습니다. 새 게시
|
||||||
|
이벤트를 만들지 않습니다.
|
||||||
|
</p>
|
||||||
|
<GuardedStudioLink
|
||||||
|
href={`/studio/publications/${currentPublication.latestEventId}/preview`}
|
||||||
|
>
|
||||||
|
현재 게시 Snapshot 보기
|
||||||
|
</GuardedStudioLink>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let blocked: {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
} | null = null;
|
||||||
|
if (!validation) {
|
||||||
|
blocked = {
|
||||||
|
title: "저장 버전 검증이 필요합니다",
|
||||||
|
detail: "게시 전에 현재 저장 버전을 검증해 주세요.",
|
||||||
|
href: `/studio/documents/${document.id}/validation`,
|
||||||
|
label: "검증하기",
|
||||||
|
};
|
||||||
|
} else if (!validationMatches) {
|
||||||
|
blocked = {
|
||||||
|
title: "검증 결과가 현재 버전과 다릅니다",
|
||||||
|
detail: "현재 저장 버전으로 다시 검증해야 합니다.",
|
||||||
|
href: `/studio/documents/${document.id}/validation`,
|
||||||
|
label: "다시 검증",
|
||||||
|
};
|
||||||
|
} else if (validation.status === "INVALID") {
|
||||||
|
blocked = {
|
||||||
|
title: "검증 오류를 먼저 수정해야 합니다",
|
||||||
|
detail: `${validation.issues.filter((issue) => issue.severity === "ERROR").length}개의 오류가 게시를 막고 있습니다.`,
|
||||||
|
href: `/studio/documents/${document.id}/validation`,
|
||||||
|
label: "검증 오류 보기",
|
||||||
|
};
|
||||||
|
} else if (!preview) {
|
||||||
|
blocked = {
|
||||||
|
title: "Public Preview가 필요합니다",
|
||||||
|
detail: "검증된 저장 버전의 공개 레이아웃을 먼저 확인해 주세요.",
|
||||||
|
href: `/studio/documents/${document.id}/preview`,
|
||||||
|
label: "Public Preview 만들기",
|
||||||
|
};
|
||||||
|
} else if (!previewIsCurrent) {
|
||||||
|
blocked = {
|
||||||
|
title:
|
||||||
|
preview.state === "EXPIRED"
|
||||||
|
? "Public Preview가 만료되었습니다"
|
||||||
|
: "Public Preview가 오래되었습니다",
|
||||||
|
detail: "현재 검증과 저장 버전으로 Preview를 다시 만들어야 합니다.",
|
||||||
|
href: `/studio/documents/${document.id}/preview`,
|
||||||
|
label: "Public Preview 다시 만들기",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.heading}>
|
||||||
|
<p className={styles.eyebrow}>PUBLICATION</p>
|
||||||
|
<h1>게시 준비</h1>
|
||||||
|
<p>{document.title || "제목 없는 작업본"}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className={styles.summary} aria-label="게시 준비 요약">
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>저장 버전</dt>
|
||||||
|
<dd>v{document.version}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>검증</dt>
|
||||||
|
<dd>
|
||||||
|
{validation
|
||||||
|
? `${validation.status} · v${validation.validatedVersion}`
|
||||||
|
: "실행 전"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Public Preview</dt>
|
||||||
|
<dd>
|
||||||
|
{preview
|
||||||
|
? `${preview.state} · v${preview.preview.previewVersion}`
|
||||||
|
: "없음"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>현재 게시</dt>
|
||||||
|
<dd>
|
||||||
|
{currentPublication
|
||||||
|
? `${currentPublication.status} · v${currentPublication.publishedVersion}`
|
||||||
|
: "게시 전"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={styles.changeSummary}
|
||||||
|
aria-labelledby="change-summary-title"
|
||||||
|
>
|
||||||
|
<p className={styles.eyebrow}>CHANGE SUMMARY</p>
|
||||||
|
<h2 id="change-summary-title">변경 요약</h2>
|
||||||
|
<p>
|
||||||
|
{currentPublication
|
||||||
|
? `게시 버전 v${currentPublication.publishedVersion}에서 저장 버전 v${document.version}으로 반영합니다.`
|
||||||
|
: `저장 버전 v${document.version}을 처음 게시합니다.`}
|
||||||
|
</p>
|
||||||
|
{preview ? (
|
||||||
|
<p>
|
||||||
|
Preview 만료:{" "}
|
||||||
|
<time dateTime={preview.preview.expiresAt}>
|
||||||
|
{dateTime(preview.preview.expiresAt)}
|
||||||
|
</time>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{blocked ? (
|
||||||
|
<section
|
||||||
|
className={styles.blocked}
|
||||||
|
aria-labelledby="publish-blocked-title"
|
||||||
|
>
|
||||||
|
<h2 id="publish-blocked-title">{blocked.title}</h2>
|
||||||
|
<p>{blocked.detail}</p>
|
||||||
|
<GuardedStudioLink href={blocked.href}>
|
||||||
|
{blocked.label}
|
||||||
|
</GuardedStudioLink>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section
|
||||||
|
className={styles.publishAction}
|
||||||
|
aria-labelledby="publish-action-title"
|
||||||
|
>
|
||||||
|
<h2 id="publish-action-title">{actionLabel}</h2>
|
||||||
|
<WarningAcknowledgements
|
||||||
|
warnings={warnings}
|
||||||
|
acknowledged={acknowledged}
|
||||||
|
onToggle={toggleWarning}
|
||||||
|
classes={styles}
|
||||||
|
/>
|
||||||
|
{actionError ? (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{success ? (
|
||||||
|
<p className={styles.success} role="status">
|
||||||
|
{success}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<GuardedStudioLink
|
||||||
|
href={`/studio/documents/${document.id}/preview`}
|
||||||
|
>
|
||||||
|
Preview로 돌아가기
|
||||||
|
</GuardedStudioLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canPublish}
|
||||||
|
onClick={() => {
|
||||||
|
void publish();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pending ? "게시 중…" : actionLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useId, useRef, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
|
import type { PublicationListItem } from "../../../contracts/studio/contract.ts";
|
||||||
|
import {
|
||||||
|
defaultPublicationFlowClasses,
|
||||||
|
type PublicationFlowClasses,
|
||||||
|
} from "./publication-flow-classes.ts";
|
||||||
|
|
||||||
|
export function UnpublishDialog({
|
||||||
|
item,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
onDismiss,
|
||||||
|
onConfirm,
|
||||||
|
classes: styles = defaultPublicationFlowClasses,
|
||||||
|
}: {
|
||||||
|
item: PublicationListItem | null;
|
||||||
|
pending: boolean;
|
||||||
|
error: string;
|
||||||
|
onDismiss(): void;
|
||||||
|
onConfirm(): void;
|
||||||
|
classes?: PublicationFlowClasses;
|
||||||
|
}) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (item && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
cancelRef.current?.focus();
|
||||||
|
} else if (!item && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (dialogRef.current?.open) dialogRef.current.close();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const trapFocus = (event: KeyboardEvent<HTMLDialogElement>) => {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = Array.from(
|
||||||
|
event.currentTarget.querySelectorAll<HTMLElement>(
|
||||||
|
"button:not([disabled])",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!controls.length) return;
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls.at(-1)!;
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className={styles.dialog}
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!pending) onDismiss();
|
||||||
|
}}
|
||||||
|
onKeyDown={trapFocus}
|
||||||
|
>
|
||||||
|
<div className={styles.dialogBody}>
|
||||||
|
<p className={styles.eyebrow}>UNPUBLISH</p>
|
||||||
|
<h2 id={titleId}>게시를 취소할까요?</h2>
|
||||||
|
<p id={descriptionId}>
|
||||||
|
{item?.document.title ?? "선택한 기록"}의 Studio 게시 상태를 중단하고
|
||||||
|
게시 취소 이벤트를 남깁니다.
|
||||||
|
</p>
|
||||||
|
<div className={styles.preservedNote}>
|
||||||
|
<strong>작업본과 이전 Snapshot은 보존됩니다.</strong>
|
||||||
|
<p>
|
||||||
|
현재 Mock에서는 기존 Public 사이트와 검색 결과를 변경하지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.dialogActions}>
|
||||||
|
<button
|
||||||
|
ref={cancelRef}
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={onDismiss}
|
||||||
|
>
|
||||||
|
계속 게시
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={pending} onClick={onConfirm}>
|
||||||
|
{pending ? "취소 처리 중…" : "게시 취소 확인"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ export function ValidationReport({
|
|||||||
report: ValidationReportModel;
|
report: ValidationReportModel;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
}) {
|
}) {
|
||||||
const issues = report.issues.toSorted((left, right) => {
|
const issues = [...report.issues].sort((left, right) => {
|
||||||
const rank = { ERROR: 0, WARNING: 1 } as const;
|
const rank = { ERROR: 0, WARNING: 1 } as const;
|
||||||
return rank[left.severity] - rank[right.severity];
|
return rank[left.severity] - rank[right.severity];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import {
|
||||||
|
defaultPublicationFlowClasses,
|
||||||
|
type PublicationFlowClasses,
|
||||||
|
} from "./publication-flow-classes.ts";
|
||||||
|
|
||||||
|
type ValidationIssue = components["schemas"]["ValidationIssue"];
|
||||||
|
|
||||||
|
export function WarningAcknowledgements({
|
||||||
|
warnings,
|
||||||
|
acknowledged,
|
||||||
|
onToggle,
|
||||||
|
classes: styles = defaultPublicationFlowClasses,
|
||||||
|
}: {
|
||||||
|
warnings: ValidationIssue[];
|
||||||
|
acknowledged: ReadonlySet<string>;
|
||||||
|
onToggle(code: string, checked: boolean): void;
|
||||||
|
classes?: PublicationFlowClasses;
|
||||||
|
}) {
|
||||||
|
const groupId = useId();
|
||||||
|
|
||||||
|
if (warnings.length === 0) {
|
||||||
|
return <p className={styles.noWarnings}>확인할 경고가 없습니다.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
className={styles.warningGroup}
|
||||||
|
aria-describedby={`${groupId}-description`}
|
||||||
|
>
|
||||||
|
<legend>게시 전 경고 확인</legend>
|
||||||
|
<p id={`${groupId}-description`}>
|
||||||
|
현재 검증의 경고를 모두 확인해야 게시할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div className={styles.warningList}>
|
||||||
|
{warnings.map((warning, index) => {
|
||||||
|
const inputId = `${groupId}-warning-${index}`;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={styles.warningItem}
|
||||||
|
htmlFor={inputId}
|
||||||
|
key={`${warning.code}:${warning.path}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="checkbox"
|
||||||
|
checked={acknowledged.has(warning.code)}
|
||||||
|
onChange={(event) =>
|
||||||
|
onToggle(warning.code, event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{warning.code}</strong>
|
||||||
|
<small>{warning.message}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
|
import { PublishScreen } from "../components/publish-screen.tsx";
|
||||||
|
|
||||||
|
export function DocumentPublishPage() {
|
||||||
|
const { params } =
|
||||||
|
useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_PUBLISH">();
|
||||||
|
return <PublishScreen documentId={String(params.id)} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
|
import { PublicationEventPreviewScreen } from "../components/publication-event-preview-screen.tsx";
|
||||||
|
|
||||||
|
export function PublicationPreviewPage() {
|
||||||
|
const { params } =
|
||||||
|
useRouteInput<"TECH_LOG_STUDIO_PUBLICATION_PREVIEW">();
|
||||||
|
return (
|
||||||
|
<PublicationEventPreviewScreen
|
||||||
|
publicationEventId={String(params.publicationEventId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PublicationList } from "../components/publication-list.tsx";
|
||||||
|
|
||||||
|
export function PublicationsPage() {
|
||||||
|
return <PublicationList />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import {
|
||||||
|
lazy,
|
||||||
|
type ComponentType,
|
||||||
|
type LazyExoticComponent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
||||||
|
type TechLogRouteId,
|
||||||
|
} from "../contracts/tech-log-route-contract.ts";
|
||||||
|
import { PublicShell } from "./public/public-shell.tsx";
|
||||||
|
import { StudioShell } from "./studio/studio-shell.tsx";
|
||||||
|
|
||||||
|
type RouteRuntime = Readonly<{
|
||||||
|
moduleId: string;
|
||||||
|
Component: LazyExoticComponent<ComponentType>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function runtime(
|
||||||
|
routeId: TechLogRouteId,
|
||||||
|
load: () => Promise<{ default: ComponentType }>,
|
||||||
|
): RouteRuntime {
|
||||||
|
return Object.freeze({
|
||||||
|
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
|
||||||
|
Component: lazy(load),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeModule<Module, Key extends keyof Module>(
|
||||||
|
load: () => Promise<Module>,
|
||||||
|
key: Key,
|
||||||
|
): () => Promise<{ default: Extract<Module[Key], ComponentType> }> {
|
||||||
|
return async () => {
|
||||||
|
const module = await load();
|
||||||
|
return { default: module[key] as Extract<Module[Key], ComponentType> };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
||||||
|
TECH_LOG_HOME: runtime(
|
||||||
|
"TECH_LOG_HOME",
|
||||||
|
routeModule(() => import("./public/pages/home-page.tsx"), "HomePage"),
|
||||||
|
),
|
||||||
|
TECH_LOG_EXPLORE: runtime(
|
||||||
|
"TECH_LOG_EXPLORE",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/explore-page.tsx"),
|
||||||
|
"ExplorePage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_EXPLORE_KIND: runtime(
|
||||||
|
"TECH_LOG_EXPLORE_KIND",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/explore-kind-page.tsx"),
|
||||||
|
"ExploreKindPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_CASE: runtime(
|
||||||
|
"TECH_LOG_CASE",
|
||||||
|
routeModule(() => import("./public/pages/case-page.tsx"), "CasePage"),
|
||||||
|
),
|
||||||
|
TECH_LOG_REFERENCE: runtime(
|
||||||
|
"TECH_LOG_REFERENCE",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/reference-page.tsx"),
|
||||||
|
"ReferencePage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_QUESTION: runtime(
|
||||||
|
"TECH_LOG_QUESTION",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/question-page.tsx"),
|
||||||
|
"QuestionPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_TOPIC: runtime(
|
||||||
|
"TECH_LOG_TOPIC",
|
||||||
|
routeModule(() => import("./public/pages/topic-page.tsx"), "TopicPage"),
|
||||||
|
),
|
||||||
|
TECH_LOG_PROJECTS: runtime(
|
||||||
|
"TECH_LOG_PROJECTS",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/projects-page.tsx"),
|
||||||
|
"ProjectsPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_PROJECT: runtime(
|
||||||
|
"TECH_LOG_PROJECT",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/project-overview-page.tsx"),
|
||||||
|
"ProjectOverviewPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_PROJECT_RECORDS: runtime(
|
||||||
|
"TECH_LOG_PROJECT_RECORDS",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/project-records-page.tsx"),
|
||||||
|
"ProjectRecordsPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_PROJECT_DECISIONS: runtime(
|
||||||
|
"TECH_LOG_PROJECT_DECISIONS",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/project-decisions-page.tsx"),
|
||||||
|
"ProjectDecisionsPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_PROJECT_ACTIVITY: runtime(
|
||||||
|
"TECH_LOG_PROJECT_ACTIVITY",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/project-activity-page.tsx"),
|
||||||
|
"ProjectActivityPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_RELEASES: runtime(
|
||||||
|
"TECH_LOG_RELEASES",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/releases-page.tsx"),
|
||||||
|
"ReleasesPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_RELEASE: runtime(
|
||||||
|
"TECH_LOG_RELEASE",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/release-page.tsx"),
|
||||||
|
"ReleasePage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_PROFILE: runtime(
|
||||||
|
"TECH_LOG_PROFILE",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/profile-page.tsx"),
|
||||||
|
"ProfilePage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_SEARCH: runtime(
|
||||||
|
"TECH_LOG_SEARCH",
|
||||||
|
routeModule(() => import("./public/pages/search-page.tsx"), "SearchPage"),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_HOME: runtime(
|
||||||
|
"TECH_LOG_STUDIO_HOME",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/studio-home-page.tsx"),
|
||||||
|
"StudioHomePage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_DOCUMENTS: runtime(
|
||||||
|
"TECH_LOG_STUDIO_DOCUMENTS",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/documents-page.tsx"),
|
||||||
|
"DocumentsPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_DOCUMENT_NEW: runtime(
|
||||||
|
"TECH_LOG_STUDIO_DOCUMENT_NEW",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/new-document-page.tsx"),
|
||||||
|
"NewDocumentPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_DOCUMENT_EDIT: runtime(
|
||||||
|
"TECH_LOG_STUDIO_DOCUMENT_EDIT",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/document-edit-page.tsx"),
|
||||||
|
"DocumentEditPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_DOCUMENT_VALIDATION: runtime(
|
||||||
|
"TECH_LOG_STUDIO_DOCUMENT_VALIDATION",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/document-validation-page.tsx"),
|
||||||
|
"DocumentValidationPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_DOCUMENT_PREVIEW: runtime(
|
||||||
|
"TECH_LOG_STUDIO_DOCUMENT_PREVIEW",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/document-preview-page.tsx"),
|
||||||
|
"DocumentPreviewPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_DOCUMENT_PUBLISH: runtime(
|
||||||
|
"TECH_LOG_STUDIO_DOCUMENT_PUBLISH",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/document-publish-page.tsx"),
|
||||||
|
"DocumentPublishPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_PUBLICATIONS: runtime(
|
||||||
|
"TECH_LOG_STUDIO_PUBLICATIONS",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/publications-page.tsx"),
|
||||||
|
"PublicationsPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: runtime(
|
||||||
|
"TECH_LOG_STUDIO_PUBLICATION_PREVIEW",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/publication-preview-page.tsx"),
|
||||||
|
"PublicationPreviewPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_NOT_FOUND: runtime(
|
||||||
|
"TECH_LOG_STUDIO_NOT_FOUND",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/studio-not-found-page.tsx"),
|
||||||
|
"StudioNotFoundPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
NOT_FOUND: runtime(
|
||||||
|
"NOT_FOUND",
|
||||||
|
routeModule(
|
||||||
|
() => import("./public/pages/public-not-found-page.tsx"),
|
||||||
|
"PublicNotFoundPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}) satisfies Readonly<Record<TechLogRouteId, RouteRuntime>>;
|
||||||
|
|
||||||
|
export const TECH_LOG_ROUTE_LAYOUTS: Readonly<
|
||||||
|
Record<"PUBLIC" | "STUDIO", ReactNode>
|
||||||
|
> = Object.freeze({
|
||||||
|
PUBLIC: (
|
||||||
|
<PublicShell>
|
||||||
|
<Outlet />
|
||||||
|
</PublicShell>
|
||||||
|
),
|
||||||
|
STUDIO: (
|
||||||
|
<StudioShell>
|
||||||
|
<Outlet />
|
||||||
|
</StudioShell>
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
import { PageHeader } from "../design-system/index.ts";
|
|
||||||
import { useSession } from "../providers/session-provider.tsx";
|
|
||||||
|
|
||||||
export default function AuthExamplePage() {
|
|
||||||
const location = useLocation();
|
|
||||||
const { sessionState, beginSignIn, signOut, recover } = useSession();
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
|
|
||||||
async function execute(action: () => Promise<unknown>): Promise<void> {
|
|
||||||
setPending(true);
|
|
||||||
setFailed(false);
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
} catch {
|
|
||||||
setFailed(true);
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="ui-page">
|
|
||||||
<PageHeader
|
|
||||||
eyebrow="예제"
|
|
||||||
title="인증 연동"
|
|
||||||
description="스켈레톤은 자격 증명을 소유하지 않고 외부 인증 구현이 연결될 포트와 화면 상태만 제공합니다."
|
|
||||||
/>
|
|
||||||
<section className="ui-panel auth-example" aria-labelledby="auth-state-title">
|
|
||||||
<div>
|
|
||||||
<h2 id="auth-state-title">현재 세션 상태</h2>
|
|
||||||
<output className="session-status" data-state={sessionState}>
|
|
||||||
{sessionState}
|
|
||||||
</output>
|
|
||||||
</div>
|
|
||||||
<div className="button-row">
|
|
||||||
<button
|
|
||||||
className="ui-button"
|
|
||||||
type="button"
|
|
||||||
disabled={pending || sessionState === "integration-failed"}
|
|
||||||
onClick={() =>
|
|
||||||
void execute(() =>
|
|
||||||
beginSignIn(`${location.pathname}${location.search}`),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
로그인 시작
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ui-button ui-button--secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={pending || sessionState !== "authenticated"}
|
|
||||||
onClick={() => void execute(signOut)}
|
|
||||||
>
|
|
||||||
로그아웃
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ui-button ui-button--secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={pending || sessionState !== "recovery-pending"}
|
|
||||||
onClick={() => void execute(recover)}
|
|
||||||
>
|
|
||||||
세션 복구
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{sessionState === "integration-failed" ? (
|
|
||||||
<p role="status">
|
|
||||||
외부 인증 소유자가 연결되지 않았습니다. 런타임 호스트의 인증
|
|
||||||
계약을 연결하세요.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{failed ? <p role="alert">인증 작업을 완료하지 못했습니다.</p> : null}
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { HTTP_EXECUTION_CEILINGS } from "../../contracts/external-contract-runtime.ts";
|
|
||||||
import { SERVER_STATE_PROFILES } from "../../contracts/server-state.ts";
|
|
||||||
import {
|
|
||||||
COMPOSED_CONTRACT_CONTRIBUTIONS,
|
|
||||||
EXPECTED_CONTRACT_SET_PACKAGES,
|
|
||||||
} from "../../features/installed-contract-contributions.ts";
|
|
||||||
import { ROUTE_REGISTRY } from "../../features/installed-feature-contracts.ts";
|
|
||||||
import type {
|
|
||||||
RuntimeCapabilityId,
|
|
||||||
RuntimeCapabilityStatus,
|
|
||||||
} from "../../contracts/runtime-capabilities.ts";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
DataTable,
|
|
||||||
EmptySurface,
|
|
||||||
PageHeader,
|
|
||||||
type DataTableColumn,
|
|
||||||
} from "../design-system/index.ts";
|
|
||||||
import { useApplication } from "../providers/application-provider.tsx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every number and row on this page is read from an installed registry at
|
|
||||||
* render time. Nothing is transcribed by hand, so deleting a feature removes
|
|
||||||
* its rows and the page keeps describing what the repository actually is.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function kilobytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return "없음";
|
|
||||||
if (bytes >= 1_048_576) return `${bytes / 1_048_576} MiB`;
|
|
||||||
return `${bytes / 1024} KiB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function seconds(milliseconds: number): string {
|
|
||||||
return milliseconds < 1000
|
|
||||||
? `${milliseconds}ms`
|
|
||||||
: `${milliseconds / 1000}초`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Metric({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
hint,
|
|
||||||
}: Readonly<{ label: string; value: string; hint?: string }>) {
|
|
||||||
return (
|
|
||||||
<div className="platform-metric">
|
|
||||||
<dt>{label}</dt>
|
|
||||||
<dd>
|
|
||||||
<span className="platform-metric__value">{value}</span>
|
|
||||||
{hint ? <span className="platform-metric__hint">{hint}</span> : null}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RouteRow = (typeof ROUTE_REGISTRY)[keyof typeof ROUTE_REGISTRY];
|
|
||||||
|
|
||||||
const ROUTE_COLUMNS: readonly DataTableColumn<RouteRow>[] = Object.freeze([
|
|
||||||
{
|
|
||||||
id: "routeId",
|
|
||||||
header: "라우트",
|
|
||||||
cell: (row) => <code>{row.routeId}</code>,
|
|
||||||
},
|
|
||||||
{ id: "path", header: "경로", cell: (row) => <code>{row.path}</code> },
|
|
||||||
{
|
|
||||||
id: "access",
|
|
||||||
header: "접근",
|
|
||||||
cell: (row) => (
|
|
||||||
<Badge variant={row.access === "public" ? "success" : "warning"}>
|
|
||||||
{row.access}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "schemas",
|
|
||||||
header: "입력 스키마",
|
|
||||||
cell: (row) =>
|
|
||||||
[row.paramsSchema, row.searchSchema].filter(Boolean).join(" · ") || "없음",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chunkId",
|
|
||||||
header: "청크",
|
|
||||||
cell: (row) => <code>{row.chunkId}</code>,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
type OperationRow = Readonly<{
|
|
||||||
operationId: string;
|
|
||||||
method: string;
|
|
||||||
pathTemplate: string;
|
|
||||||
retrySemantics: string;
|
|
||||||
retryBudget: number;
|
|
||||||
totalDeadlineMs: number;
|
|
||||||
requestByteLimit: number;
|
|
||||||
responseByteLimit: number;
|
|
||||||
effect: string;
|
|
||||||
recovery: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const OPERATION_COLUMNS: readonly DataTableColumn<OperationRow>[] =
|
|
||||||
Object.freeze([
|
|
||||||
{
|
|
||||||
id: "operationId",
|
|
||||||
header: "오퍼레이션",
|
|
||||||
cell: (row) => (
|
|
||||||
<>
|
|
||||||
<code>{row.operationId}</code>
|
|
||||||
<span className="platform-operation__path">
|
|
||||||
{row.method} {row.pathTemplate}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "retry",
|
|
||||||
header: "재시도",
|
|
||||||
cell: (row) => (
|
|
||||||
<>
|
|
||||||
<Badge variant={row.retrySemantics === "SAFE" ? "success" : "warning"}>
|
|
||||||
{row.retrySemantics}
|
|
||||||
</Badge>
|
|
||||||
<span className="platform-operation__path">
|
|
||||||
예산 {row.retryBudget}회
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "effect",
|
|
||||||
header: "효과 확정성",
|
|
||||||
cell: (row) => row.effect,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "recovery",
|
|
||||||
header: "복구",
|
|
||||||
cell: (row) => row.recovery,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "budget",
|
|
||||||
header: "예산",
|
|
||||||
cell: (row) => (
|
|
||||||
<>
|
|
||||||
<span className="platform-operation__path">
|
|
||||||
요청 {kilobytes(row.requestByteLimit)} · 응답{" "}
|
|
||||||
{kilobytes(row.responseByteLimit)}
|
|
||||||
</span>
|
|
||||||
<span className="platform-operation__path">
|
|
||||||
마감 {seconds(row.totalDeadlineMs)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
type ProfileRow = (typeof SERVER_STATE_PROFILES)[keyof typeof SERVER_STATE_PROFILES];
|
|
||||||
|
|
||||||
const PROFILE_COLUMNS: readonly DataTableColumn<ProfileRow>[] = Object.freeze([
|
|
||||||
{
|
|
||||||
id: "profileId",
|
|
||||||
header: "프로파일",
|
|
||||||
cell: (row) => <code>{row.profileId}</code>,
|
|
||||||
},
|
|
||||||
{ id: "stale", header: "stale", cell: (row) => seconds(row.staleTimeMs) },
|
|
||||||
{ id: "gc", header: "gc", cell: (row) => seconds(row.gcTimeMs) },
|
|
||||||
{
|
|
||||||
id: "refetch",
|
|
||||||
header: "재조회",
|
|
||||||
cell: (row) =>
|
|
||||||
[
|
|
||||||
row.refetchOnMount === "always"
|
|
||||||
? "mount(always)"
|
|
||||||
: row.refetchOnMount && "mount",
|
|
||||||
row.refetchOnFocus && "focus",
|
|
||||||
row.refetchOnReconnect && "reconnect",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · "),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "budget",
|
|
||||||
header: "결과 예산",
|
|
||||||
cell: (row) =>
|
|
||||||
`${row.maxResultItems}건 · ${kilobytes(row.maxEstimatedResultBytes)}`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const CAPABILITY_COPY: Readonly<
|
|
||||||
Record<RuntimeCapabilityId, Readonly<{ label: string; description: string }>>
|
|
||||||
> = Object.freeze({
|
|
||||||
REALTIME: Object.freeze({
|
|
||||||
label: "실시간 수신",
|
|
||||||
description:
|
|
||||||
"WebSocket, SSE, 경계 폴링 런타임은 구현되어 있습니다. 제품 기여물이 엔드포인트와 이벤트 서술자를 제공해야 설치됩니다.",
|
|
||||||
}),
|
|
||||||
WEB_WORKER: Object.freeze({
|
|
||||||
label: "웹 워커",
|
|
||||||
description:
|
|
||||||
"워커 실행 계약과 전용 타입 프로젝트가 준비되어 있습니다. 프로파일링으로 확인된 CPU 작업이 있어야 설치됩니다.",
|
|
||||||
}),
|
|
||||||
SERVICE_WORKER: Object.freeze({
|
|
||||||
label: "서비스 워커",
|
|
||||||
description:
|
|
||||||
"참조 런타임과 두 단계 빌드가 준비되어 있습니다. 설치하면 검증된 정적 자산 캐시와 등록 해제 경로가 함께 켜집니다.",
|
|
||||||
}),
|
|
||||||
OFFLINE_COMMANDS: Object.freeze({
|
|
||||||
label: "오프라인 명령",
|
|
||||||
description:
|
|
||||||
"명령 큐 상태 기계가 준비되어 있습니다. 복구 서술자를 가진 KEYED 오퍼레이션이 있어야 설치됩니다.",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A capability that was never selected and one an operator switched off look
|
|
||||||
* identical if both are reported as "off". The snapshot separates them, and so
|
|
||||||
* does this badge.
|
|
||||||
*/
|
|
||||||
function capabilityBadge(
|
|
||||||
status: RuntimeCapabilityStatus,
|
|
||||||
): Readonly<{ text: string; variant: "success" | "warning" | "neutral" }> {
|
|
||||||
if (status.selected === 0) return { text: "미선택", variant: "neutral" };
|
|
||||||
if (status.active === 0) {
|
|
||||||
return { text: "운영자가 비활성화함", variant: "warning" };
|
|
||||||
}
|
|
||||||
return { text: `활성 (${status.active})`, variant: "success" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOperationRows(): readonly OperationRow[] {
|
|
||||||
return Object.freeze(
|
|
||||||
[...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.values()].map(
|
|
||||||
(installed) => {
|
|
||||||
const { contract, frontend } = installed;
|
|
||||||
return Object.freeze({
|
|
||||||
operationId: contract.operationId,
|
|
||||||
method: contract.method,
|
|
||||||
pathTemplate: contract.pathTemplate,
|
|
||||||
retrySemantics: contract.retrySemantics,
|
|
||||||
retryBudget: frontend.retryBudget,
|
|
||||||
totalDeadlineMs: frontend.totalDeadlineMs,
|
|
||||||
requestByteLimit: frontend.requestByteLimit,
|
|
||||||
responseByteLimit: frontend.responseByteLimit,
|
|
||||||
effect:
|
|
||||||
contract.commandEffect === null
|
|
||||||
? "해당 없음"
|
|
||||||
: contract.commandEffect.successEffect,
|
|
||||||
recovery:
|
|
||||||
contract.commandRecovery === null
|
|
||||||
? "해당 없음"
|
|
||||||
: contract.commandRecovery.mode,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlatformOverviewPage() {
|
|
||||||
const { runtime } = useApplication();
|
|
||||||
const [release, setRelease] = useState<
|
|
||||||
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
void runtime.getReleaseSummary().then((summary) => {
|
|
||||||
if (active) setRelease(summary);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [runtime]);
|
|
||||||
|
|
||||||
const routes = Object.values(ROUTE_REGISTRY);
|
|
||||||
const operations = buildOperationRows();
|
|
||||||
const capabilities = runtime.getCapabilitySnapshot();
|
|
||||||
const activeCapabilityCount = capabilities.filter(
|
|
||||||
(status) => status.active > 0,
|
|
||||||
).length;
|
|
||||||
const fixtureContributions =
|
|
||||||
COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
|
|
||||||
(contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="ui-page">
|
|
||||||
<PageHeader
|
|
||||||
eyebrow="예제"
|
|
||||||
title="플랫폼 구성"
|
|
||||||
description="이 화면의 모든 값은 설치된 레지스트리에서 렌더 시점에 읽습니다. 손으로 옮겨 적은 숫자가 없으므로 코드가 바뀌면 이 화면도 함께 바뀝니다."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section
|
|
||||||
className="gallery-section"
|
|
||||||
aria-labelledby="platform-release-title"
|
|
||||||
>
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="platform-release-title">릴리스 신원</h2>
|
|
||||||
<p>
|
|
||||||
부팅 시 경계 검사를 통과한 런타임 설정과 릴리스 매니페스트에서 옵니다.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<dl className="platform-metric-grid" aria-live="polite">
|
|
||||||
{release ? (
|
|
||||||
<>
|
|
||||||
<Metric label="빌드" value={release.buildId} />
|
|
||||||
<Metric label="릴리스" value={release.releaseId} />
|
|
||||||
<Metric
|
|
||||||
label="설정 스키마"
|
|
||||||
value={release.configSchemaVersion}
|
|
||||||
hint={
|
|
||||||
release.apiContractVersion
|
|
||||||
? `레거시 계약 ${release.apiContractVersion}`
|
|
||||||
: "계약 집합 사용"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
label="계약 집합 다이제스트"
|
|
||||||
value={
|
|
||||||
release.contractSetDigest
|
|
||||||
? `${release.contractSetDigest.slice(0, 20)}…`
|
|
||||||
: "없음"
|
|
||||||
}
|
|
||||||
hint={
|
|
||||||
release.contractSetDigest
|
|
||||||
? "릴리스 매니페스트 V2"
|
|
||||||
: "릴리스 매니페스트 V1"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Metric label="상태" value="런타임 정보를 확인하고 있습니다." />
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
className="gallery-section"
|
|
||||||
aria-labelledby="platform-summary-title"
|
|
||||||
>
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="platform-summary-title">설치 요약</h2>
|
|
||||||
<p>레지스트리 항목 수를 그대로 센 값입니다.</p>
|
|
||||||
</header>
|
|
||||||
<dl className="platform-metric-grid">
|
|
||||||
<Metric label="라우트" value={`${routes.length}개`} />
|
|
||||||
<Metric label="HTTP 오퍼레이션" value={`${operations.length}개`} />
|
|
||||||
<Metric
|
|
||||||
label="외부 계약 패키지"
|
|
||||||
value={`${EXPECTED_CONTRACT_SET_PACKAGES.length}개`}
|
|
||||||
hint={`템플릿 픽스처 ${fixtureContributions}개`}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
label="선택적 런타임 능력"
|
|
||||||
value={`${activeCapabilityCount} / ${capabilities.length}`}
|
|
||||||
hint="런타임 오버라이드 반영"
|
|
||||||
/>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
className="gallery-section"
|
|
||||||
aria-labelledby="platform-routes-title"
|
|
||||||
>
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="platform-routes-title">설치된 라우트</h2>
|
|
||||||
<p>
|
|
||||||
라우트 레지스트리가 단일 진실 공급원입니다. 접근 정책, 코드 분할 청크,
|
|
||||||
입력 스키마가 한 항목에 함께 선언됩니다.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<DataTable
|
|
||||||
caption="설치된 라우트 목록"
|
|
||||||
columns={ROUTE_COLUMNS}
|
|
||||||
rows={routes}
|
|
||||||
rowKey={(row) => row.routeId}
|
|
||||||
empty={
|
|
||||||
<EmptySurface
|
|
||||||
title="설치된 라우트가 없습니다."
|
|
||||||
description="라우트 레지스트리가 비어 있습니다."
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
className="gallery-section"
|
|
||||||
aria-labelledby="platform-contracts-title"
|
|
||||||
>
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="platform-contracts-title">계약과 HTTP 오퍼레이션</h2>
|
|
||||||
<p>
|
|
||||||
외부 계약 패키지 {EXPECTED_CONTRACT_SET_PACKAGES.length}개가 설치되어
|
|
||||||
있습니다. 아래 오퍼레이션은 템플릿 픽스처가 제공하며 릴리스 다이제스트에
|
|
||||||
포함되지 않습니다. 제품은 픽스처를 지우고 자기 패키지를 고정합니다.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<DataTable
|
|
||||||
caption="설치된 HTTP 오퍼레이션"
|
|
||||||
columns={OPERATION_COLUMNS}
|
|
||||||
rows={operations}
|
|
||||||
rowKey={(row) => row.operationId}
|
|
||||||
empty={
|
|
||||||
<EmptySurface
|
|
||||||
title="설치된 HTTP 오퍼레이션이 없습니다."
|
|
||||||
description="계약 기여물을 추가하면 이 표에 나타납니다."
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
className="gallery-section"
|
|
||||||
aria-labelledby="platform-server-state-title"
|
|
||||||
>
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="platform-server-state-title">서버 상태와 실행 상한</h2>
|
|
||||||
<p>
|
|
||||||
조회는 네 개의 고정 프로파일 중 하나를 골라야 하고, 실행 정책은 아래
|
|
||||||
상한을 넘을 수 없습니다.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<DataTable
|
|
||||||
caption="서버 상태 프로파일"
|
|
||||||
columns={PROFILE_COLUMNS}
|
|
||||||
rows={Object.values(SERVER_STATE_PROFILES)}
|
|
||||||
rowKey={(row) => row.profileId}
|
|
||||||
empty={<EmptySurface title="프로파일이 없습니다." />}
|
|
||||||
/>
|
|
||||||
<dl className="platform-metric-grid">
|
|
||||||
<Metric
|
|
||||||
label="응답 상한"
|
|
||||||
value={kilobytes(HTTP_EXECUTION_CEILINGS.hardResponseBytes)}
|
|
||||||
hint={`기본 ${kilobytes(HTTP_EXECUTION_CEILINGS.defaultResponseBytes)}`}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
label="요청 상한"
|
|
||||||
value={kilobytes(HTTP_EXECUTION_CEILINGS.hardRequestBytes)}
|
|
||||||
hint={`기본 ${kilobytes(HTTP_EXECUTION_CEILINGS.defaultRequestBytes)}`}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
label="총 마감 상한"
|
|
||||||
value={seconds(HTTP_EXECUTION_CEILINGS.hardTotalDeadlineMs)}
|
|
||||||
hint={`기본 ${seconds(HTTP_EXECUTION_CEILINGS.defaultTotalDeadlineMs)}`}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
label="재시도 상한"
|
|
||||||
value={`${HTTP_EXECUTION_CEILINGS.hardRetryCount}회`}
|
|
||||||
hint="전송 실패에만 적용"
|
|
||||||
/>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
className="gallery-section"
|
|
||||||
aria-labelledby="platform-capabilities-title"
|
|
||||||
>
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="platform-capabilities-title">선택적 런타임 능력</h2>
|
|
||||||
<p>
|
|
||||||
정적 선택 파일이 단일 진실 공급원입니다. 런타임 설정은 이미 선택된
|
|
||||||
능력을 끌 수만 있고, 설정 문자열로 새 능력을 켜거나 모듈 경로를 만들지
|
|
||||||
못합니다. 여기 표시되는 상태는 정적 선택에 런타임 오버라이드를 적용한
|
|
||||||
결과이므로, 애초에 선택되지 않은 능력과 운영자가 끈 능력이 구분됩니다.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<div className="component-grid component-grid--two">
|
|
||||||
{capabilities.map((status) => {
|
|
||||||
const copy = CAPABILITY_COPY[status.capabilityId];
|
|
||||||
const badge = capabilityBadge(status);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={status.capabilityId}
|
|
||||||
title={copy.label}
|
|
||||||
footer={<Badge variant={badge.variant}>{badge.text}</Badge>}
|
|
||||||
>
|
|
||||||
<p>{copy.description}</p>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { deriveAsyncState } from "../../application/view-models/async-state.ts";
|
|
||||||
import { createFailure } from "../../contracts/errors.ts";
|
|
||||||
import {
|
|
||||||
AsyncSurface,
|
|
||||||
EmptySurface,
|
|
||||||
LoadingSurface,
|
|
||||||
TerminalErrorSurface,
|
|
||||||
AuthRequiredSurface,
|
|
||||||
ForbiddenSurface,
|
|
||||||
NotFoundSurface,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
PageHeader,
|
|
||||||
} from "../design-system/index.ts";
|
|
||||||
|
|
||||||
export default function StateGalleryPage() {
|
|
||||||
const [lastAction, setLastAction] = useState(
|
|
||||||
"상태 화면의 작업을 선택하면 결과가 여기에 표시됩니다.",
|
|
||||||
);
|
|
||||||
const refreshingState = deriveAsyncState({
|
|
||||||
data: ["기존 데이터"],
|
|
||||||
isFetching: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="ui-page">
|
|
||||||
<PageHeader
|
|
||||||
eyebrow="예제"
|
|
||||||
title="화면 상태"
|
|
||||||
description="로딩, 빈 화면, 오류, 인증 필요와 권한 없음 상태가 다음 행동까지 일관되게 안내합니다."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="gallery-section" aria-labelledby="async-states-title">
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="async-states-title">비동기 데이터 상태</h2>
|
|
||||||
<p>초기 로딩과 백그라운드 갱신을 구분해 기존 콘텐츠를 보존합니다.</p>
|
|
||||||
</header>
|
|
||||||
<div className="component-grid component-grid--two">
|
|
||||||
<Card title="초기 로딩">
|
|
||||||
<LoadingSurface label="예제 데이터를 불러오는 중" />
|
|
||||||
</Card>
|
|
||||||
<Card title="백그라운드 갱신">
|
|
||||||
<AsyncSurface state={refreshingState}>
|
|
||||||
<div className="state-preview-content">기존 콘텐츠는 계속 표시됩니다.</div>
|
|
||||||
</AsyncSurface>
|
|
||||||
</Card>
|
|
||||||
<Card title="빈 화면">
|
|
||||||
<EmptySurface
|
|
||||||
title="아직 표시할 항목이 없습니다."
|
|
||||||
description="첫 항목을 추가하거나 필터를 초기화할 수 있습니다."
|
|
||||||
action={
|
|
||||||
<Button onClick={() => setLastAction("빈 화면 작업을 실행했습니다.")}>
|
|
||||||
첫 작업 시작
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
<Card title="복구 가능한 오류">
|
|
||||||
<TerminalErrorSurface
|
|
||||||
userMessageKey={
|
|
||||||
createFailure("NETWORK_UNREACHABLE", "EXAMPLE", 0)
|
|
||||||
.userMessageKey
|
|
||||||
}
|
|
||||||
action="retry"
|
|
||||||
onAction={() => setLastAction("오류 요청을 다시 시도했습니다.")}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="gallery-section" aria-labelledby="access-states-title">
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="access-states-title">접근과 탐색 상태</h2>
|
|
||||||
<p>인증 여부와 서버 권한 결과를 서로 다른 상태로 전달합니다.</p>
|
|
||||||
</header>
|
|
||||||
<div className="component-grid component-grid--three">
|
|
||||||
<AuthRequiredSurface
|
|
||||||
onSignIn={() => setLastAction("로그인 연동 작업을 시작했습니다.")}
|
|
||||||
/>
|
|
||||||
<ForbiddenSurface
|
|
||||||
onNavigate={() => setLastAction("접근 가능한 화면으로 이동합니다.")}
|
|
||||||
/>
|
|
||||||
<NotFoundSurface
|
|
||||||
onNavigate={() => setLastAction("시작 화면으로 이동합니다.")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<output className="gallery-notice" aria-live="polite">
|
|
||||||
{lastAction}
|
|
||||||
</output>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
Dialog,
|
|
||||||
Menu,
|
|
||||||
PageHeader,
|
|
||||||
ProgressBar,
|
|
||||||
RadioGroup,
|
|
||||||
Select,
|
|
||||||
Switch,
|
|
||||||
Tabs,
|
|
||||||
TextArea,
|
|
||||||
TextField,
|
|
||||||
ToastProvider,
|
|
||||||
Tooltip,
|
|
||||||
useToast,
|
|
||||||
} from "../design-system/index.ts";
|
|
||||||
|
|
||||||
const COLOR_TOKENS = Object.freeze([
|
|
||||||
["Surface", "--color-surface"],
|
|
||||||
["Muted surface", "--color-surface-muted"],
|
|
||||||
["Content", "--color-content"],
|
|
||||||
["Muted content", "--color-content-muted"],
|
|
||||||
["Action", "--color-action"],
|
|
||||||
["Danger", "--color-danger"],
|
|
||||||
["Focus", "--color-focus"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default function UiGalleryPage() {
|
|
||||||
return (
|
|
||||||
<ToastProvider>
|
|
||||||
<UiGalleryContent />
|
|
||||||
</ToastProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UiGalleryContent() {
|
|
||||||
const toast = useToast();
|
|
||||||
const [projectName, setProjectName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [template, setTemplate] = useState("application");
|
|
||||||
const [reviewed, setReviewed] = useState(false);
|
|
||||||
const [notifications, setNotifications] = useState(true);
|
|
||||||
const [density, setDensity] = useState("comfortable");
|
|
||||||
const [fieldTouched, setFieldTouched] = useState(false);
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [notice, setNotice] = useState(
|
|
||||||
"구성요소를 조작하면 결과가 여기에 표시됩니다.",
|
|
||||||
);
|
|
||||||
const [alertVisible, setAlertVisible] = useState(true);
|
|
||||||
const fieldError =
|
|
||||||
fieldTouched && projectName.trim().length === 0
|
|
||||||
? "프로젝트 이름을 입력해 주세요."
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
function submitExample(event: FormEvent<HTMLFormElement>): void {
|
|
||||||
event.preventDefault();
|
|
||||||
setFieldTouched(true);
|
|
||||||
if (projectName.trim().length === 0) {
|
|
||||||
setNotice("입력값을 확인해 주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setNotice(`“${projectName.trim()}” 입력을 확인했습니다.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="ui-page">
|
|
||||||
<PageHeader
|
|
||||||
eyebrow="예제"
|
|
||||||
title="UI 구성요소"
|
|
||||||
description="제품 도메인과 독립적인 공통 컨트롤, 피드백, 표면과 디자인 토큰을 직접 조작할 수 있습니다."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="gallery-section" aria-labelledby="controls-title">
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="controls-title">버튼과 입력</h2>
|
|
||||||
<p>키보드, 비활성 상태, 오류 설명을 포함한 기본 상호작용입니다.</p>
|
|
||||||
</header>
|
|
||||||
<div className="component-grid component-grid--two">
|
|
||||||
<Card title="버튼" description="의미와 위험도에 따라 변형을 선택합니다.">
|
|
||||||
<div className="button-row">
|
|
||||||
<Button onClick={() => setNotice("기본 작업을 실행했습니다.")}>
|
|
||||||
기본 작업
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setNotice("보조 작업을 실행했습니다.")}
|
|
||||||
>
|
|
||||||
보조 작업
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onClick={() => setNotice("위험 작업 예제를 선택했습니다.")}
|
|
||||||
>
|
|
||||||
위험 작업
|
|
||||||
</Button>
|
|
||||||
<Button disabled>사용 불가</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card title="입력창" description="레이블과 도움말, 오류가 입력에 연결됩니다.">
|
|
||||||
<form className="example-form" noValidate onSubmit={submitExample}>
|
|
||||||
<TextField
|
|
||||||
label="프로젝트 이름"
|
|
||||||
description="새 도메인을 연결할 때 사용할 중립적인 예제입니다."
|
|
||||||
error={fieldError}
|
|
||||||
value={projectName}
|
|
||||||
required
|
|
||||||
onChange={(event) => setProjectName(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Button type="submit">입력 확인</Button>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="gallery-section" aria-labelledby="form-controls-title">
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="form-controls-title">폼과 선택 컨트롤</h2>
|
|
||||||
<p>native semantics, 설명·오류 연결과 controlled 상태를 제공합니다.</p>
|
|
||||||
</header>
|
|
||||||
<div className="component-grid component-grid--two">
|
|
||||||
<Card title="긴 입력과 선택">
|
|
||||||
<div className="component-stack">
|
|
||||||
<TextArea
|
|
||||||
label="설명"
|
|
||||||
maxLength={120}
|
|
||||||
value={description}
|
|
||||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="시작 템플릿"
|
|
||||||
options={[
|
|
||||||
{ value: "application", label: "Application" },
|
|
||||||
{ value: "library", label: "Library" },
|
|
||||||
]}
|
|
||||||
value={template}
|
|
||||||
onChange={(event) => setTemplate(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
checked={reviewed}
|
|
||||||
label="접근성 계약을 확인했습니다."
|
|
||||||
onChange={(event) => setReviewed(event.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card title="단일 선택과 설정">
|
|
||||||
<div className="component-stack">
|
|
||||||
<RadioGroup
|
|
||||||
label="화면 밀도"
|
|
||||||
name="density"
|
|
||||||
onChange={setDensity}
|
|
||||||
options={[
|
|
||||||
{ value: "comfortable", label: "여유롭게" },
|
|
||||||
{ value: "compact", label: "조밀하게" },
|
|
||||||
]}
|
|
||||||
value={density}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
checked={notifications}
|
|
||||||
description="boolean 설정에만 switch를 사용합니다."
|
|
||||||
label="알림 사용"
|
|
||||||
onChange={setNotifications}
|
|
||||||
/>
|
|
||||||
<ProgressBar label="설정 준비도" max={4} value={3} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="gallery-section" aria-labelledby="feedback-title">
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="feedback-title">피드백과 모달</h2>
|
|
||||||
<p>상태 전달은 색에만 의존하지 않으며, 모든 제어에는 이름이 있습니다.</p>
|
|
||||||
</header>
|
|
||||||
<div className="component-grid component-grid--two">
|
|
||||||
<Card title="알림과 배지" description="짧은 상태와 문맥형 피드백입니다.">
|
|
||||||
<div className="component-stack">
|
|
||||||
{alertVisible ? (
|
|
||||||
<Alert
|
|
||||||
title="설정이 저장되었습니다."
|
|
||||||
variant="success"
|
|
||||||
onDismiss={() => setAlertVisible(false)}
|
|
||||||
>
|
|
||||||
<p>운영 환경에는 실제 저장 포트를 연결하세요.</p>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setAlertVisible(true)}
|
|
||||||
>
|
|
||||||
알림 다시 표시
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="badge-row" aria-label="배지 변형">
|
|
||||||
<Badge>중립</Badge>
|
|
||||||
<Badge variant="info">정보</Badge>
|
|
||||||
<Badge variant="success">준비됨</Badge>
|
|
||||||
<Badge variant="warning">확인 필요</Badge>
|
|
||||||
<Badge variant="danger">실패</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card title="모달" description="배경과 키보드 Esc로 닫고 포커스를 복원합니다.">
|
|
||||||
<div className="button-row">
|
|
||||||
<Button onClick={() => setDialogOpen(true)}>모달 열기</Button>
|
|
||||||
<Menu
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
id: "inspect",
|
|
||||||
label: "상태 확인",
|
|
||||||
onSelect: () => setNotice("메뉴 작업을 실행했습니다."),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notify",
|
|
||||||
label: "Toast 표시",
|
|
||||||
onSelect: () =>
|
|
||||||
toast.push({
|
|
||||||
id: "gallery-saved",
|
|
||||||
title: "예제가 저장되었습니다.",
|
|
||||||
tone: "success",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
triggerLabel="작업 메뉴"
|
|
||||||
/>
|
|
||||||
<Tooltip content="이 설명은 필수 정보가 아닙니다.">
|
|
||||||
<Button variant="ghost">도움말</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onClose={() => setDialogOpen(false)}
|
|
||||||
title="연동 확인"
|
|
||||||
description="도메인 작업을 실행하기 전 확인 화면의 기본 구조입니다."
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setDialogOpen(false)}
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setNotice("모달의 확인 작업을 실행했습니다.");
|
|
||||||
setDialogOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
확인
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p>민감한 값이나 구현 세부정보는 확인 문구에 포함하지 않습니다.</p>
|
|
||||||
</Dialog>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="gallery-section" aria-labelledby="navigation-title">
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="navigation-title">탐색 패턴</h2>
|
|
||||||
<p>화살표 키와 명시적인 활성화 정책을 갖는 탭 예제입니다.</p>
|
|
||||||
</header>
|
|
||||||
<Tabs
|
|
||||||
activation="manual"
|
|
||||||
label="디자인 시스템 계층"
|
|
||||||
tabs={[
|
|
||||||
{
|
|
||||||
id: "tokens",
|
|
||||||
label: "토큰",
|
|
||||||
panel: <p>원시 값에서 의미와 컴포넌트 토큰을 파생합니다.</p>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "primitives",
|
|
||||||
label: "프리미티브",
|
|
||||||
panel: <p>native semantics와 interaction을 닫습니다.</p>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "patterns",
|
|
||||||
label: "패턴",
|
|
||||||
panel: <p>반복되는 사용자 문제를 조합으로 해결합니다.</p>,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="gallery-section" aria-labelledby="tokens-title">
|
|
||||||
<header className="gallery-section__header">
|
|
||||||
<h2 id="tokens-title">디자인 토큰</h2>
|
|
||||||
<p>구성요소가 사용하는 의미 기반 색상과 형태 토큰입니다.</p>
|
|
||||||
</header>
|
|
||||||
<div className="token-grid">
|
|
||||||
{COLOR_TOKENS.map(([label, token]) => (
|
|
||||||
<article className="token-swatch" key={token}>
|
|
||||||
<span
|
|
||||||
className="token-swatch__color"
|
|
||||||
style={{ backgroundColor: `var(${token})` }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<strong>{label}</strong>
|
|
||||||
<code>{token}</code>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<output className="gallery-notice" aria-live="polite">
|
|
||||||
{notice}
|
|
||||||
</output>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -88,7 +88,7 @@ export function AppShell() {
|
|||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
|
<NavLink className="app-shell__brand" to={routePath("TECH_LOG_HOME")}>
|
||||||
{message("common.appName")}
|
{message("common.appName")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<div className="app-shell__session">
|
<div className="app-shell__session">
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { routePath } from "../../features/installed-feature-contracts.ts";
|
|
||||||
import { PageHeader } from "../design-system/index.ts";
|
|
||||||
import { useApplication } from "../providers/application-provider.tsx";
|
|
||||||
|
|
||||||
const READINESS_ITEMS = Object.freeze([
|
|
||||||
{
|
|
||||||
title: "실행 계약",
|
|
||||||
description: "런타임 설정, 릴리스 정합성, 오류 경계가 마운트 전에 검증됩니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "교체 가능한 연동",
|
|
||||||
description: "인증, HTTP, 캐시, 저장소, 텔레메트리가 포트 뒤에 분리되어 있습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "접근 가능한 화면",
|
|
||||||
description: "키보드 탐색, 포커스 이동, 반응형 앱 셸의 기본 동작이 준비되어 있습니다.",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
const { runtime } = useApplication();
|
|
||||||
const [release, setRelease] = useState<
|
|
||||||
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
void runtime.getReleaseSummary().then((summary) => {
|
|
||||||
if (active) setRelease(summary);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [runtime]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="ui-page">
|
|
||||||
<PageHeader
|
|
||||||
eyebrow="프로젝트 시작점"
|
|
||||||
title="Tech Log"
|
|
||||||
description="도메인을 추가하기 전에 실행 구조와 범용 사용자 경험을 확인할 수 있는 중립적인 스켈레톤입니다."
|
|
||||||
/>
|
|
||||||
<div className="readiness-grid" aria-label="구현 준비 상태">
|
|
||||||
{READINESS_ITEMS.map((item) => (
|
|
||||||
<article className="ui-panel" key={item.title}>
|
|
||||||
<h2>{item.title}</h2>
|
|
||||||
<p>{item.description}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="ui-runtime-summary" aria-live="polite">
|
|
||||||
{release
|
|
||||||
? `빌드 ${release.buildId} · 릴리스 ${release.releaseId}`
|
|
||||||
: "검증된 런타임 정보를 확인하고 있습니다."}
|
|
||||||
</p>
|
|
||||||
<section className="ui-panel starter-actions" aria-labelledby="starter-title">
|
|
||||||
<div>
|
|
||||||
<h2 id="starter-title">준비된 화면 살펴보기</h2>
|
|
||||||
<p>
|
|
||||||
설치된 라우트와 계약, 런타임 능력은 플랫폼 구성 화면에서, 공통
|
|
||||||
구성요소와 비동기 화면 상태는 예제 라우트에서 확인하세요.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="button-row">
|
|
||||||
<Link className="ui-button" to={routePath("EXAMPLES_PLATFORM")}>
|
|
||||||
플랫폼 구성 보기
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
className="ui-button ui-button--secondary"
|
|
||||||
to={routePath("EXAMPLES_UI")}
|
|
||||||
>
|
|
||||||
UI 구성요소 보기
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
className="ui-button ui-button--secondary"
|
|
||||||
to={routePath("EXAMPLES_STATES")}
|
|
||||||
>
|
|
||||||
화면 상태 보기
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { routePath } from "../../features/installed-feature-contracts.ts";
|
|
||||||
import { PageHeader } from "../design-system/index.ts";
|
|
||||||
import { useLocale } from "../i18n/index.ts";
|
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
|
||||||
const { message } = useLocale();
|
|
||||||
return (
|
|
||||||
<section className="ui-page">
|
|
||||||
<PageHeader
|
|
||||||
title={message("page.notFound.title")}
|
|
||||||
description={message("page.notFound.description")}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Link className="ui-button" to={routePath("APP_HOME")}>
|
|
||||||
{message("action.goHome")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
Outlet,
|
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
type RouteObject,
|
type RouteObject,
|
||||||
useLocation,
|
useLocation,
|
||||||
@@ -30,7 +29,7 @@ import {
|
|||||||
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.tsx";
|
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.tsx";
|
||||||
import { Button, PageHeader } from "../design-system/index.ts";
|
import { Button, PageHeader } from "../design-system/index.ts";
|
||||||
import { LocaleProvider, useLocale } from "../i18n/index.ts";
|
import { LocaleProvider, useLocale } from "../i18n/index.ts";
|
||||||
import { AppShell } from "../layouts/app-shell.tsx";
|
import { TECH_LOG_ROUTE_LAYOUTS } from "../../features/tech-log/presentation/tech-log-route-runtime.tsx";
|
||||||
import { useApplication } from "../providers/application-provider.tsx";
|
import { useApplication } from "../providers/application-provider.tsx";
|
||||||
import { SessionProvider, useSession } from "../providers/session-provider.tsx";
|
import { SessionProvider, useSession } from "../providers/session-provider.tsx";
|
||||||
import { ThemeProvider } from "../providers/theme-provider.tsx";
|
import { ThemeProvider } from "../providers/theme-provider.tsx";
|
||||||
@@ -459,10 +458,7 @@ export function AppRouter({
|
|||||||
createGroupedRouteObjects(
|
createGroupedRouteObjects(
|
||||||
ROUTE_REGISTRY,
|
ROUTE_REGISTRY,
|
||||||
ROUTE_RUNTIME,
|
ROUTE_RUNTIME,
|
||||||
{
|
TECH_LOG_ROUTE_LAYOUTS,
|
||||||
PUBLIC: <AppShell />,
|
|
||||||
STUDIO: <Outlet />,
|
|
||||||
},
|
|
||||||
buildId,
|
buildId,
|
||||||
ROUTE_CODECS,
|
ROUTE_CODECS,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const PLATFORM_ROUTE_CODECS = {
|
export const PLATFORM_ROUTE_CODECS = Object.freeze({
|
||||||
none: z.object({}).strict(),
|
none: z.object({}).strict(),
|
||||||
NotFoundSplat: z.object({ "*": z.string().min(1).optional() }).strict(),
|
NotFoundSplat: z.object({ "*": z.string().min(1).optional() }).strict(),
|
||||||
} as const;
|
});
|
||||||
|
|||||||
@@ -1,47 +1 @@
|
|||||||
import {
|
export const PLATFORM_ROUTE_RUNTIME = Object.freeze({});
|
||||||
lazy,
|
|
||||||
type ComponentType,
|
|
||||||
type LazyExoticComponent,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.ts";
|
|
||||||
|
|
||||||
type RouteModule = Readonly<{ default: ComponentType }>;
|
|
||||||
type RouteRuntime = Readonly<{
|
|
||||||
moduleId: string;
|
|
||||||
Component: LazyExoticComponent<ComponentType>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
function runtime(
|
|
||||||
routeId: keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT,
|
|
||||||
load: () => Promise<RouteModule>,
|
|
||||||
): RouteRuntime {
|
|
||||||
return Object.freeze({
|
|
||||||
moduleId: PLATFORM_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
|
|
||||||
Component: lazy(load),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PLATFORM_ROUTE_RUNTIME = {
|
|
||||||
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.tsx")),
|
|
||||||
EXAMPLES_PLATFORM: runtime(
|
|
||||||
"EXAMPLES_PLATFORM",
|
|
||||||
() => import("../examples/platform-overview-page.tsx"),
|
|
||||||
),
|
|
||||||
EXAMPLES_UI: runtime(
|
|
||||||
"EXAMPLES_UI",
|
|
||||||
() => import("../examples/ui-gallery-page.tsx"),
|
|
||||||
),
|
|
||||||
EXAMPLES_STATES: runtime(
|
|
||||||
"EXAMPLES_STATES",
|
|
||||||
() => import("../examples/state-gallery-page.tsx"),
|
|
||||||
),
|
|
||||||
EXAMPLES_AUTH: runtime(
|
|
||||||
"EXAMPLES_AUTH",
|
|
||||||
() => import("../examples/auth-example-page.tsx"),
|
|
||||||
),
|
|
||||||
NOT_FOUND: runtime(
|
|
||||||
"NOT_FOUND",
|
|
||||||
() => import("../pages/not-found-page.tsx"),
|
|
||||||
),
|
|
||||||
} satisfies Record<keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT, RouteRuntime>;
|
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
|
|
||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts";
|
|
||||||
import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../../src/features/installed-contract-contributions.ts";
|
|
||||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
|
||||||
import PlatformOverviewPage from "../../src/presentation/examples/platform-overview-page.tsx";
|
|
||||||
import { LocaleProvider } from "../../src/presentation/i18n/index.ts";
|
|
||||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
|
||||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
|
||||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The page must stay a projection of the installed registries. Every assertion
|
|
||||||
* below derives its expectation from the same registry the page reads, so a
|
|
||||||
* template that removes its sample feature still satisfies this suite.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function renderPage() {
|
|
||||||
return render(
|
|
||||||
<ApplicationProvider application={createTestApplication()}>
|
|
||||||
<LocaleProvider>
|
|
||||||
<PlatformOverviewPage />
|
|
||||||
</LocaleProvider>
|
|
||||||
</ApplicationProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tableByCaption(caption: string): HTMLElement {
|
|
||||||
return screen.getByRole("table", { name: caption });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A metric is a `dt`/`dd` pair, which carries no ARIA role to query by. */
|
|
||||||
function metricByLabel(scope: HTMLElement, label: string): HTMLElement {
|
|
||||||
const term = within(scope).getByText(label).closest(".platform-metric");
|
|
||||||
if (!(term instanceof HTMLElement)) {
|
|
||||||
throw new Error(`No metric is labelled ${label}`);
|
|
||||||
}
|
|
||||||
return term;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("platform overview page", () => {
|
|
||||||
it("renders one route row per installed route registry entry", () => {
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
const table = tableByCaption("설치된 라우트 목록");
|
|
||||||
const dataRows = within(table).getAllByRole("row").slice(1);
|
|
||||||
|
|
||||||
expect(dataRows).toHaveLength(Object.keys(ROUTE_REGISTRY).length);
|
|
||||||
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
|
||||||
expect(
|
|
||||||
within(table).getByText(definition.routeId),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders one operation row per installed HTTP contract", () => {
|
|
||||||
const operationIds = [
|
|
||||||
...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.keys(),
|
|
||||||
];
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
if (operationIds.length === 0) {
|
|
||||||
expect(
|
|
||||||
screen.getByRole("heading", {
|
|
||||||
name: "설치된 HTTP 오퍼레이션이 없습니다.",
|
|
||||||
}),
|
|
||||||
).toBeVisible();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const table = tableByCaption("설치된 HTTP 오퍼레이션");
|
|
||||||
expect(within(table).getAllByRole("row").slice(1)).toHaveLength(
|
|
||||||
operationIds.length,
|
|
||||||
);
|
|
||||||
for (const operationId of operationIds) {
|
|
||||||
expect(within(table).getByText(operationId)).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders every fixed server-state profile with its own budget", () => {
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
const table = tableByCaption("서버 상태 프로파일");
|
|
||||||
for (const profile of Object.values(SERVER_STATE_PROFILES)) {
|
|
||||||
expect(within(table).getByText(profile.profileId)).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
expect(within(table).getAllByRole("row").slice(1)).toHaveLength(
|
|
||||||
Object.keys(SERVER_STATE_PROFILES).length,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("labels a capability that was never selected as unselected", () => {
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
const region = screen.getByRole("region", { name: "선택적 런타임 능력" });
|
|
||||||
|
|
||||||
expect(within(region).getAllByText("미선택")).toHaveLength(4);
|
|
||||||
expect(within(region).queryByText("운영자가 비활성화함")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("separates an operator disable from a capability that was never selected", () => {
|
|
||||||
render(
|
|
||||||
<ApplicationProvider
|
|
||||||
application={createTestApplication({
|
|
||||||
runtimeCapabilities: createRuntimeCapabilitiesStub({
|
|
||||||
SERVICE_WORKER: { selected: 1, active: 0, override: "DISABLED" },
|
|
||||||
OFFLINE_COMMANDS: { selected: 1, active: 1 },
|
|
||||||
}),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<LocaleProvider>
|
|
||||||
<PlatformOverviewPage />
|
|
||||||
</LocaleProvider>
|
|
||||||
</ApplicationProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const region = screen.getByRole("region", { name: "선택적 런타임 능력" });
|
|
||||||
|
|
||||||
expect(within(region).getByText("운영자가 비활성화함")).toBeVisible();
|
|
||||||
expect(within(region).getByText("활성 (1)")).toBeVisible();
|
|
||||||
expect(within(region).getAllByText("미선택")).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts installed contract packages separately from template fixtures", () => {
|
|
||||||
const fixtures = COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
|
|
||||||
(contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
|
|
||||||
).length;
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
const summary = screen.getByRole("region", { name: "설치 요약" });
|
|
||||||
const packages = metricByLabel(summary, "외부 계약 패키지");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
within(packages).getByText(
|
|
||||||
`${COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages.length}개`,
|
|
||||||
),
|
|
||||||
).toBeVisible();
|
|
||||||
expect(
|
|
||||||
within(packages).getByText(`템플릿 픽스처 ${fixtures}개`),
|
|
||||||
).toBeVisible();
|
|
||||||
expect(
|
|
||||||
within(metricByLabel(summary, "라우트")).getByText(
|
|
||||||
`${Object.keys(ROUTE_REGISTRY).length}개`,
|
|
||||||
),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the verified release identity once the runtime resolves it", async () => {
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
const region = screen.getByRole("region", { name: "릴리스 신원" });
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(
|
|
||||||
within(metricByLabel(region, "빌드")).getByText("test-build"),
|
|
||||||
).toBeVisible(),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
within(metricByLabel(region, "릴리스")).getByText("test-release"),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
|
||||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||||
|
import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
|
import { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts";
|
||||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||||
import {
|
import {
|
||||||
AppRouter,
|
AppRouter,
|
||||||
@@ -54,7 +56,7 @@ const groupedFixtureCodecs = Object.freeze({
|
|||||||
|
|
||||||
const reviewFixtureRegistry = Object.freeze({
|
const reviewFixtureRegistry = Object.freeze({
|
||||||
REVIEW_FIXTURE: Object.freeze({
|
REVIEW_FIXTURE: Object.freeze({
|
||||||
...ROUTE_REGISTRY.APP_HOME,
|
...ROUTE_REGISTRY.TECH_LOG_HOME,
|
||||||
routeId: "REVIEW_FIXTURE",
|
routeId: "REVIEW_FIXTURE",
|
||||||
path: "/review/:reviewId",
|
path: "/review/:reviewId",
|
||||||
paramsSchema: "ReviewFixtureParams",
|
paramsSchema: "ReviewFixtureParams",
|
||||||
@@ -100,10 +102,12 @@ function compileTimeGroupedRouteContract() {
|
|||||||
void compileTimeGroupedRouteContract;
|
void compileTimeGroupedRouteContract;
|
||||||
|
|
||||||
function renderRouter() {
|
function renderRouter() {
|
||||||
|
const techLog = createTechLogFeatureInstalledInput();
|
||||||
return render(
|
return render(
|
||||||
<ApplicationProvider
|
<ApplicationProvider
|
||||||
application={createTestApplication({
|
application={createTestApplication({
|
||||||
session: createAnonymousSessionAdapter(),
|
session: createAnonymousSessionAdapter(),
|
||||||
|
featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input },
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
@@ -159,7 +163,7 @@ describe("generic application router", () => {
|
|||||||
|
|
||||||
it("assembles generic Public and Studio parents with Studio catch-all precedence", () => {
|
it("assembles generic Public and Studio parents with Studio catch-all precedence", () => {
|
||||||
const registry = {
|
const registry = {
|
||||||
APP_HOME: ROUTE_REGISTRY.APP_HOME,
|
TECH_LOG_HOME: ROUTE_REGISTRY.TECH_LOG_HOME,
|
||||||
STUDIO_FIXTURE: {
|
STUDIO_FIXTURE: {
|
||||||
...ROUTE_REGISTRY.NOT_FOUND,
|
...ROUTE_REGISTRY.NOT_FOUND,
|
||||||
routeId: "STUDIO_FIXTURE",
|
routeId: "STUDIO_FIXTURE",
|
||||||
@@ -169,7 +173,7 @@ describe("generic application router", () => {
|
|||||||
NOT_FOUND: ROUTE_REGISTRY.NOT_FOUND,
|
NOT_FOUND: ROUTE_REGISTRY.NOT_FOUND,
|
||||||
};
|
};
|
||||||
const runtime = {
|
const runtime = {
|
||||||
APP_HOME: ROUTE_RUNTIME.APP_HOME,
|
TECH_LOG_HOME: ROUTE_RUNTIME.TECH_LOG_HOME,
|
||||||
STUDIO_FIXTURE: ROUTE_RUNTIME.NOT_FOUND,
|
STUDIO_FIXTURE: ROUTE_RUNTIME.NOT_FOUND,
|
||||||
NOT_FOUND: ROUTE_RUNTIME.NOT_FOUND,
|
NOT_FOUND: ROUTE_RUNTIME.NOT_FOUND,
|
||||||
};
|
};
|
||||||
@@ -220,7 +224,7 @@ describe("generic application router", () => {
|
|||||||
"test-build",
|
"test-build",
|
||||||
groupedFixtureCodecs,
|
groupedFixtureCodecs,
|
||||||
),
|
),
|
||||||
).toThrow("Missing route runtime: APP_HOME");
|
).toThrow("Missing route runtime: TECH_LOG_HOME");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a non-installed Studio wildcard leaf without rewriting its URL", async () => {
|
it("renders a non-installed Studio wildcard leaf without rewriting its URL", async () => {
|
||||||
@@ -277,10 +281,11 @@ describe("generic application router", () => {
|
|||||||
const routes = createGroupedRouteObjects(
|
const routes = createGroupedRouteObjects(
|
||||||
{
|
{
|
||||||
PROTECTED_FIXTURE: {
|
PROTECTED_FIXTURE: {
|
||||||
...ROUTE_REGISTRY.APP_HOME,
|
...ROUTE_REGISTRY.TECH_LOG_HOME,
|
||||||
routeId: "PROTECTED_FIXTURE",
|
routeId: "PROTECTED_FIXTURE",
|
||||||
path: "/private-fixture",
|
path: "/private-fixture",
|
||||||
access: "session-required",
|
access: "session-required",
|
||||||
|
searchSchema: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -317,89 +322,67 @@ describe("generic application router", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reaches the platform overview from the home starter actions", async () => {
|
it("renders the installed TechLog home in the Public layout", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
window.history.pushState({}, "", "/");
|
window.history.pushState({}, "", "/");
|
||||||
renderRouter();
|
renderRouter();
|
||||||
|
|
||||||
// The starter action lives inside the lazily loaded home chunk, so this is
|
expect(
|
||||||
// the first wait in the file that has to outlast a chunk load rather than
|
await screen.findByRole("heading", { name: "TechLog", level: 1 }),
|
||||||
// an already-mounted shell element.
|
).toBeVisible();
|
||||||
|
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||||
await user.click(
|
await user.click(
|
||||||
await screen.findByRole(
|
within(screen.getByRole("navigation", { name: "주요 탐색" })).getByRole(
|
||||||
"link",
|
"link",
|
||||||
{ name: "플랫폼 구성 보기" },
|
{ name: "프로젝트" },
|
||||||
{ timeout: 5000 },
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
await waitFor(() =>
|
await screen.findByRole("heading", { name: "프로젝트", level: 1 }),
|
||||||
expect(
|
).toBeVisible();
|
||||||
screen.getByRole("heading", { name: "플랫폼 구성", level: 1 }),
|
expect(window.location.pathname).toBe("/projects");
|
||||||
).toHaveFocus(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the app shell and not-found route without a feature input", async () => {
|
it("renders the Public shell and TechLog not-found route", async () => {
|
||||||
window.history.pushState({}, "", "/missing");
|
window.history.pushState({}, "", "/missing");
|
||||||
renderRouter();
|
renderRouter();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", {
|
await screen.findByRole("heading", {
|
||||||
name: "페이지를 찾을 수 없습니다.",
|
name: "404",
|
||||||
|
level: 1,
|
||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
|
||||||
expect(screen.getByRole("main")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves authorization around grouped registered leaves", async () => {
|
|
||||||
window.history.pushState({}, "", "/examples/reference-resources");
|
|
||||||
renderRouter();
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "세션이 필요합니다." }),
|
screen.getByRole("heading", { name: "This page could not be found." }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("navigates between registry-backed platform routes", async () => {
|
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
||||||
const user = userEvent.setup();
|
window.history.pushState({}, "", "/studio");
|
||||||
window.history.pushState({}, "", "/");
|
|
||||||
renderRouter();
|
renderRouter();
|
||||||
|
|
||||||
await user.click(
|
|
||||||
await screen.findByRole("link", { name: "UI 구성요소" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
|
await screen.findByRole("heading", { name: "작업 흐름" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(window.location.pathname).toBe("/examples/ui");
|
expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
|
||||||
expect(document.title).toBe("UI 구성요소 · Tech Log");
|
expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute(
|
||||||
await waitFor(() =>
|
"href",
|
||||||
expect(
|
"/",
|
||||||
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
|
|
||||||
).toHaveFocus(),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focuses the route heading again when the lazy chunk is already cached", async () => {
|
it("gives the Studio wildcard precedence over the Public not-found route", async () => {
|
||||||
// §9.7. The first visit resolves the route module asynchronously, so the
|
window.history.pushState({}, "", "/studio/missing");
|
||||||
// router lifecycle and the page header commit separately. A later visit
|
|
||||||
// renders the cached module in the same commit, which is the ordering that
|
|
||||||
// must still hand focus to the heading rather than leaving it on main.
|
|
||||||
const user = userEvent.setup();
|
|
||||||
window.history.pushState({}, "", "/");
|
|
||||||
renderRouter();
|
renderRouter();
|
||||||
|
|
||||||
for (const label of ["UI 구성요소", "화면 상태", "UI 구성요소"]) {
|
expect(
|
||||||
await user.click(await screen.findByRole("link", { name: label }));
|
await screen.findByRole("heading", {
|
||||||
await waitFor(() =>
|
name: "Studio 화면을 찾을 수 없습니다",
|
||||||
expect(
|
}),
|
||||||
screen.getByRole("heading", { name: label, level: 1 }),
|
).toBeVisible();
|
||||||
).toHaveFocus(),
|
expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
|
||||||
);
|
expect(window.location.pathname).toBe("/studio/missing");
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,10 +31,32 @@ const releaseManifest = {
|
|||||||
releaseId: "local-release",
|
releaseId: "local-release",
|
||||||
builtAt: "2026-07-26T00:00:00.000Z",
|
builtAt: "2026-07-26T00:00:00.000Z",
|
||||||
routeChunks: {
|
routeChunks: {
|
||||||
"route-home": "assets/home.js",
|
"route-tech-log-home": "assets/tech-log-home.js",
|
||||||
"route-examples-ui": "assets/ui.js",
|
"route-tech-log-explore": "assets/tech-log-explore.js",
|
||||||
"route-examples-states": "assets/states.js",
|
"route-tech-log-explore-kind": "assets/tech-log-explore-kind.js",
|
||||||
"route-examples-auth": "assets/auth.js",
|
"route-tech-log-case": "assets/tech-log-case.js",
|
||||||
|
"route-tech-log-reference": "assets/tech-log-reference.js",
|
||||||
|
"route-tech-log-question": "assets/tech-log-question.js",
|
||||||
|
"route-tech-log-topic": "assets/tech-log-topic.js",
|
||||||
|
"route-tech-log-projects": "assets/tech-log-projects.js",
|
||||||
|
"route-tech-log-project": "assets/tech-log-project.js",
|
||||||
|
"route-tech-log-project-records": "assets/tech-log-project-records.js",
|
||||||
|
"route-tech-log-project-decisions": "assets/tech-log-project-decisions.js",
|
||||||
|
"route-tech-log-project-activity": "assets/tech-log-project-activity.js",
|
||||||
|
"route-tech-log-releases": "assets/tech-log-releases.js",
|
||||||
|
"route-tech-log-release": "assets/tech-log-release.js",
|
||||||
|
"route-tech-log-profile": "assets/tech-log-profile.js",
|
||||||
|
"route-tech-log-search": "assets/tech-log-search.js",
|
||||||
|
"route-tech-log-studio-home": "assets/tech-log-studio-home.js",
|
||||||
|
"route-tech-log-studio-documents": "assets/tech-log-studio-documents.js",
|
||||||
|
"route-tech-log-studio-document-new": "assets/tech-log-studio-document-new.js",
|
||||||
|
"route-tech-log-studio-document-edit": "assets/tech-log-studio-document-edit.js",
|
||||||
|
"route-tech-log-studio-document-validation": "assets/tech-log-studio-document-validation.js",
|
||||||
|
"route-tech-log-studio-document-preview": "assets/tech-log-studio-document-preview.js",
|
||||||
|
"route-tech-log-studio-document-publish": "assets/tech-log-studio-document-publish.js",
|
||||||
|
"route-tech-log-studio-publications": "assets/tech-log-studio-publications.js",
|
||||||
|
"route-tech-log-studio-publication-preview": "assets/tech-log-studio-publication-preview.js",
|
||||||
|
"route-tech-log-studio-not-found": "assets/tech-log-studio-not-found.js",
|
||||||
"route-not-found": "assets/not-found.js",
|
"route-not-found": "assets/not-found.js",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -62,12 +84,14 @@ describe("production runtime application tree", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", {
|
await screen.findByRole("heading", {
|
||||||
name: "Tech Log",
|
name: "TechLog",
|
||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("빌드 local-build · 릴리스 local-release"),
|
await screen.findAllByText(
|
||||||
).toBeVisible();
|
"문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.",
|
||||||
|
),
|
||||||
|
).toHaveLength(2);
|
||||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
expect(composition).not.toHaveProperty("ports");
|
expect(composition).not.toHaveProperty("ports");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
|
||||||
import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts";
|
|
||||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The overview is a projection of the installed registries. These assertions
|
|
||||||
* read the same registries the shipped bundle was built from, so they keep
|
|
||||||
* meaning after a feature is added or removed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
test("projects the installed route registry into the shipped bundle", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/examples/platform");
|
|
||||||
await expect(
|
|
||||||
page.getByRole("heading", { name: "플랫폼 구성", level: 1 }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
const table = page.getByRole("table", { name: "설치된 라우트 목록" });
|
|
||||||
await expect(table.locator("tbody tr")).toHaveCount(
|
|
||||||
Object.keys(ROUTE_REGISTRY).length,
|
|
||||||
);
|
|
||||||
for (const routeId of Object.keys(ROUTE_REGISTRY)) {
|
|
||||||
await expect(table.getByText(routeId, { exact: true })).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows every fixed server-state profile", async ({ page }) => {
|
|
||||||
await page.goto("/examples/platform");
|
|
||||||
|
|
||||||
const table = page.getByRole("table", { name: "서버 상태 프로파일" });
|
|
||||||
await expect(table.locator("tbody tr")).toHaveCount(
|
|
||||||
Object.keys(SERVER_STATE_PROFILES).length,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("states the release contract identity verified at boot", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/examples/platform");
|
|
||||||
|
|
||||||
const release = page.locator(
|
|
||||||
"section[aria-labelledby='platform-release-title']",
|
|
||||||
);
|
|
||||||
await expect(release.getByText(/^sha256:/)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reports an unselected capability without claiming it was disabled", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/examples/platform");
|
|
||||||
|
|
||||||
const capabilities = page.locator(
|
|
||||||
"section[aria-labelledby='platform-capabilities-title']",
|
|
||||||
);
|
|
||||||
await expect(capabilities.getByText("미선택")).toHaveCount(4);
|
|
||||||
await expect(capabilities.getByText("운영자가 비활성화함")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
|
||||||
|
|
||||||
async function openReferenceForm(page: Page) {
|
|
||||||
await page.goto("/examples/reference-resources/new");
|
|
||||||
await page.getByRole("button", { name: "로그인 시작" }).click();
|
|
||||||
await expect(
|
|
||||||
page.getByRole("heading", { name: "Reference resource 만들기" }),
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("validates a reference form and focuses the first invalid field", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await openReferenceForm(page);
|
|
||||||
await page.getByRole("button", { name: "저장" }).click();
|
|
||||||
|
|
||||||
const firstField = page.getByRole("textbox", { name: /새 항목 이름/ });
|
|
||||||
await expect(firstField).toBeFocused();
|
|
||||||
await expect(firstField).toHaveAttribute("aria-invalid", "true");
|
|
||||||
await expect(page.getByRole("alert")).toContainText("입력 내용을 확인해 주세요.");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("guards dirty cancellation and restores focus when writing continues", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await openReferenceForm(page);
|
|
||||||
await page
|
|
||||||
.getByRole("textbox", { name: /새 항목 이름/ })
|
|
||||||
.fill("Unsaved reference");
|
|
||||||
const cancel = page.getByRole("button", { name: "취소" });
|
|
||||||
await cancel.click();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
|
|
||||||
).toBeVisible();
|
|
||||||
await page.getByRole("button", { name: "계속 작성" }).click();
|
|
||||||
await expect(cancel).toBeFocused();
|
|
||||||
await expect(page).toHaveURL(/\/examples\/reference-resources\/new$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps the form template within a 320px viewport", async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 320, height: 720 });
|
|
||||||
await openReferenceForm(page);
|
|
||||||
const viewport = await page.evaluate(() => ({
|
|
||||||
clientWidth: document.documentElement.clientWidth,
|
|
||||||
scrollWidth: document.documentElement.scrollWidth,
|
|
||||||
}));
|
|
||||||
expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth);
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
|
||||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
|
||||||
|
|
||||||
test("opens the protected integration route through the local demo seam", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const protectedRoute = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST;
|
|
||||||
await page.route(
|
|
||||||
"http://localhost:8080/api/reference-resources?*",
|
|
||||||
(route) =>
|
|
||||||
route.fulfill({
|
|
||||||
json: [
|
|
||||||
{
|
|
||||||
id: "browser-reference",
|
|
||||||
name: "Browser reference",
|
|
||||||
createdAt: "2026-07-26T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await page.goto(protectedRoute.path);
|
|
||||||
await expect(
|
|
||||||
page.getByRole("heading", { name: "세션이 필요합니다." }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "로그인 시작" }).click();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByRole("heading", { name: protectedRoute.title }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByText("인증됨")).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { FIXTURE_IDS } from "../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||||
|
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||||
|
|
||||||
|
test("validates, previews, publishes, and unpublishes one Studio working copy", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(
|
||||||
|
`/studio/documents/${FIXTURE_IDS.stateNonceReference}/validation`,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByText("현재 저장 버전의 검증을 통과했습니다"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "Public Preview 만들기" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Public Preview 만들기" })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("현재 저장 버전의 Public Preview입니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "게시 준비로 이동" }).click();
|
||||||
|
await page.getByRole("button", { name: "게시" }).click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
/\/studio\/publications\/[0-9a-f-]+\/preview$/,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", {
|
||||||
|
level: 1,
|
||||||
|
name: "Authorization Code Flow에서 state와 nonce의 경계",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "게시 기록으로 돌아가기" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", {
|
||||||
|
name: /Authorization Code Flow에서 state와 nonce의 경계 게시 취소/,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "게시를 취소할까요?" }),
|
||||||
|
).toContainText("작업본과 이전 Snapshot은 보존됩니다.");
|
||||||
|
await page.getByRole("button", { name: "게시 취소 확인" }).click();
|
||||||
|
await expect(page.getByRole("status")).toContainText(
|
||||||
|
"게시를 취소했습니다.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders an immutable historical snapshot and Studio-local missing event", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(
|
||||||
|
`/studio/publications/${FIXTURE_IDS.fetchPublishedEvent}/preview`,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByText(/반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정/),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("게시 후 본문 측정값을 보완한 저장본입니다."),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
"/studio/publications/99999999-9999-4999-8999-999999999999/preview",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "게시 기록을 찾을 수 없습니다" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("navigation", { name: "Studio 주 탐색" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
|
||||||
|
|
||||||
test("validates and reports the common text-field flow", async ({ page }) => {
|
|
||||||
await page.goto("/examples/ui");
|
|
||||||
await page.getByRole("button", { name: "입력 확인" }).click();
|
|
||||||
|
|
||||||
const field = page.getByRole("textbox", { name: "프로젝트 이름" });
|
|
||||||
await expect(field).toHaveAttribute("aria-invalid", "true");
|
|
||||||
await expect(field).toHaveAccessibleDescription(
|
|
||||||
/프로젝트 이름을 입력해 주세요/,
|
|
||||||
);
|
|
||||||
|
|
||||||
await field.fill("Starter");
|
|
||||||
await page.getByRole("button", { name: "입력 확인" }).click();
|
|
||||||
await expect(page.getByText("“Starter” 입력을 확인했습니다.")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("traps modal interaction and restores focus to the trigger", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/examples/ui");
|
|
||||||
const trigger = page.getByRole("button", { name: "모달 열기" });
|
|
||||||
await trigger.click();
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog", { name: "연동 확인" });
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
dialog.getByRole("button", { name: "연동 확인 닫기" }),
|
|
||||||
).toBeFocused();
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(dialog).toBeHidden();
|
|
||||||
await expect(trigger).toBeFocused();
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
|
||||||
buildRouteUrl,
|
|
||||||
parseRouteInput,
|
|
||||||
} from "../../../src/presentation/routes/route-codecs.ts";
|
|
||||||
import {
|
import {
|
||||||
mapReferenceOperation,
|
mapReferenceOperation,
|
||||||
toReferenceView,
|
toReferenceView,
|
||||||
@@ -47,35 +43,7 @@ describe("reference feature boundary contracts", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips one canonical filter through the URL codec", () => {
|
|
||||||
const filters = {
|
|
||||||
tags: ["open", "new"],
|
|
||||||
cursor: "a/b",
|
|
||||||
limit: 5,
|
|
||||||
};
|
|
||||||
const url = buildRouteUrl("REFERENCE_RESOURCE_LIST", { search: filters });
|
|
||||||
expect(url).toBe(
|
|
||||||
"/examples/reference-resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
|
|
||||||
);
|
|
||||||
const parsed = parseRouteInput(
|
|
||||||
"REFERENCE_RESOURCE_LIST",
|
|
||||||
{},
|
|
||||||
new URL(url, "https://app.test").searchParams,
|
|
||||||
);
|
|
||||||
expect(parsed).toMatchObject({
|
|
||||||
success: true,
|
|
||||||
data: { search: filters },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown search and malformed DTO before mapping", () => {
|
it("rejects unknown search and malformed DTO before mapping", () => {
|
||||||
expect(
|
|
||||||
parseRouteInput(
|
|
||||||
"REFERENCE_RESOURCE_LIST",
|
|
||||||
{},
|
|
||||||
new URLSearchParams("unknown=value"),
|
|
||||||
),
|
|
||||||
).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" });
|
|
||||||
expect(
|
expect(
|
||||||
validateReferencePayload("ReferenceResourceListPayload", [
|
validateReferencePayload("ReferenceResourceListPayload", [
|
||||||
{ id: "unsafe", name: 42 },
|
{ id: "unsafe", name: 42 },
|
||||||
@@ -109,13 +77,7 @@ describe("reference feature boundary contracts", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("owns route and operation contributions in one removable contract", () => {
|
it("owns the retained API operation fixture contract", () => {
|
||||||
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([
|
|
||||||
"REFERENCE_RESOURCE_LIST",
|
|
||||||
"REFERENCE_RESOURCE_DETAIL",
|
|
||||||
"REFERENCE_RESOURCE_FORM",
|
|
||||||
"REFERENCE_RESOURCE_STATUS",
|
|
||||||
]);
|
|
||||||
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
|
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
|
||||||
"LIST_REFERENCE_RESOURCES",
|
"LIST_REFERENCE_RESOURCES",
|
||||||
"CREATE_REFERENCE_RESOURCE",
|
"CREATE_REFERENCE_RESOURCE",
|
||||||
|
|||||||
@@ -1,490 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
|
|
||||||
import {
|
|
||||||
QueryClient,
|
|
||||||
QueryClientProvider,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
|
||||||
import type { AuthSessionPort } from "../../../src/application/ports/auth-session-port.ts";
|
|
||||||
import type { MutationIntentFactory } from "../../../src/application/ports/mutation-intent-factory.ts";
|
|
||||||
import type {
|
|
||||||
ReferenceFeatureInput,
|
|
||||||
ReferenceResult,
|
|
||||||
} from "../../../src/features/reference-feature/application/reference-feature-api.ts";
|
|
||||||
import {
|
|
||||||
REFERENCE_FEATURE_ID,
|
|
||||||
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
|
||||||
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
|
||||||
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
|
|
||||||
import { INVALIDATION_REGISTRY } from "../../../src/features/installed-feature-contracts.ts";
|
|
||||||
import type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
|
|
||||||
import { createFailure } from "../../../src/contracts/errors.ts";
|
|
||||||
import type { QueryInvalidationCoordinator } from "../../../src/contracts/query-invalidation.ts";
|
|
||||||
import { QueryInvalidationProvider } from "../../../src/presentation/adapters/query/query-invalidation-provider.tsx";
|
|
||||||
import { MutationIntentProvider } from "../../../src/presentation/adapters/query/mutation-intent-provider.tsx";
|
|
||||||
import { ServerStateScopeProvider } from "../../../src/presentation/adapters/query/server-state-scope-provider.tsx";
|
|
||||||
import { createServerStateScopeRuntime } from "../../../src/adapters/query-cache/server-state-scope-runtime.ts";
|
|
||||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
|
||||||
import { AppRouter } from "../../../src/presentation/routes/app-router.tsx";
|
|
||||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
|
||||||
|
|
||||||
function renderReference(
|
|
||||||
input: ReferenceFeatureInput,
|
|
||||||
url = "/examples/reference-resources?limit=5",
|
|
||||||
session: AuthSessionPort = createDemoSessionAdapter("authenticated"),
|
|
||||||
) {
|
|
||||||
window.history.pushState({}, "", url);
|
|
||||||
const client = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: { retry: false, gcTime: Infinity },
|
|
||||||
mutations: { retry: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const invalidation: QueryInvalidationCoordinator = Object.freeze({
|
|
||||||
async invalidate() {},
|
|
||||||
beginMutation() {
|
|
||||||
return Object.freeze({
|
|
||||||
async release() {},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async resetLocal() {},
|
|
||||||
dispose() {},
|
|
||||||
});
|
|
||||||
const serverStateScope = createServerStateScopeRuntime({
|
|
||||||
session,
|
|
||||||
queryInvalidation: invalidation,
|
|
||||||
});
|
|
||||||
let intentSequence = 0;
|
|
||||||
const mutationIntentFactory: MutationIntentFactory = Object.freeze({
|
|
||||||
create(input) {
|
|
||||||
intentSequence += 1;
|
|
||||||
return Object.freeze({
|
|
||||||
intentId: `reference-page-intent-${intentSequence}`,
|
|
||||||
operationId: input.operationId,
|
|
||||||
canonicalInputIdentity: input.canonicalInputIdentity,
|
|
||||||
...(input.requiresIdempotencyKey
|
|
||||||
? { idempotencyKey: `reference-page-key-${intentSequence}` }
|
|
||||||
: {}),
|
|
||||||
createdAtMonotonicMs: intentSequence,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return Object.assign(render(
|
|
||||||
<MutationIntentProvider factory={mutationIntentFactory}>
|
|
||||||
<QueryClientProvider client={client}>
|
|
||||||
<ServerStateScopeProvider runtime={serverStateScope}>
|
|
||||||
<QueryInvalidationProvider coordinator={invalidation}>
|
|
||||||
<ApplicationProvider
|
|
||||||
application={createTestApplication({
|
|
||||||
session,
|
|
||||||
featureInputs: { [REFERENCE_FEATURE_ID]: input },
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AppRouter />
|
|
||||||
</ApplicationProvider>
|
|
||||||
</QueryInvalidationProvider>
|
|
||||||
</ServerStateScopeProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</MutationIntentProvider>,
|
|
||||||
), { client });
|
|
||||||
}
|
|
||||||
|
|
||||||
function inputWith(
|
|
||||||
overrides: Partial<ReferenceFeatureInput> = {},
|
|
||||||
): ReferenceFeatureInput {
|
|
||||||
return {
|
|
||||||
listResources: async () => ({ ok: true, value: [] }),
|
|
||||||
createResource: async ({ name }) => ({
|
|
||||||
ok: true,
|
|
||||||
value: {
|
|
||||||
resourceId: "created",
|
|
||||||
title: name,
|
|
||||||
createdAt: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getResource: async (resourceId) => ({
|
|
||||||
ok: true,
|
|
||||||
value: {
|
|
||||||
resourceId,
|
|
||||||
title: "Detail",
|
|
||||||
createdAt: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("reference feature page states", () => {
|
|
||||||
it("mounts list and detail keys under the installed governed namespace", async () => {
|
|
||||||
expect(REFERENCE_RESOURCE_QUERY_NAMESPACE).toEqual({
|
|
||||||
namespaceId: "reference-resource",
|
|
||||||
namespaceVersion: 1,
|
|
||||||
});
|
|
||||||
const installedEdge = INVALIDATION_REGISTRY.edges.find(
|
|
||||||
(edge) => edge.topicId === REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
|
||||||
);
|
|
||||||
expect(installedEdge?.namespace).toEqual(
|
|
||||||
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
|
||||||
);
|
|
||||||
|
|
||||||
const list = renderReference(inputWith());
|
|
||||||
await screen.findByRole("heading", { name: "표시할 항목이 없습니다." });
|
|
||||||
const listKey = list.client.getQueryCache().getAll()[0]?.queryKey;
|
|
||||||
expect(listKey?.slice(0, 4)).toEqual([
|
|
||||||
"query",
|
|
||||||
2,
|
|
||||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
|
|
||||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
|
|
||||||
]);
|
|
||||||
list.unmount();
|
|
||||||
|
|
||||||
const detail = renderReference(
|
|
||||||
inputWith(),
|
|
||||||
"/examples/reference-resources/reference-1",
|
|
||||||
);
|
|
||||||
await screen.findByText("Detail");
|
|
||||||
const detailKey = detail.client.getQueryCache().getAll()[0]?.queryKey;
|
|
||||||
expect(detailKey?.slice(0, 4)).toEqual([
|
|
||||||
"query",
|
|
||||||
2,
|
|
||||||
installedEdge?.namespace.namespaceId,
|
|
||||||
installedEdge?.namespace.namespaceVersion,
|
|
||||||
]);
|
|
||||||
detail.unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders loading, success and empty states through the installed route", async () => {
|
|
||||||
let resolveList:
|
|
||||||
| ((result: ReferenceResult<readonly ReferenceResourceView[]>) => void)
|
|
||||||
| undefined;
|
|
||||||
const pending = new Promise<
|
|
||||||
ReferenceResult<readonly ReferenceResourceView[]>
|
|
||||||
>((resolve) => {
|
|
||||||
resolveList = resolve;
|
|
||||||
});
|
|
||||||
const loaded = renderReference(
|
|
||||||
inputWith({ listResources: async () => pending }),
|
|
||||||
);
|
|
||||||
expect(await screen.findByLabelText("불러오는 중")).toBeVisible();
|
|
||||||
resolveList?.({
|
|
||||||
ok: true,
|
|
||||||
value: [
|
|
||||||
{
|
|
||||||
resourceId: "reference-1",
|
|
||||||
title: "Loaded",
|
|
||||||
createdAt: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(await screen.findByText("Loaded")).toBeVisible();
|
|
||||||
loaded.unmount();
|
|
||||||
|
|
||||||
renderReference(inputWith(), "/examples/reference-resources?limit=10");
|
|
||||||
expect(
|
|
||||||
await screen.findByRole("heading", {
|
|
||||||
name: "표시할 항목이 없습니다.",
|
|
||||||
}),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid URL input before the feature application input", async () => {
|
|
||||||
const listResources = vi.fn();
|
|
||||||
renderReference(
|
|
||||||
inputWith({ listResources }),
|
|
||||||
"/examples/reference-resources?limit=invalid",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
await screen.findByRole("heading", {
|
|
||||||
name: "올바르지 않은 주소입니다.",
|
|
||||||
}),
|
|
||||||
).toBeVisible();
|
|
||||||
expect(listResources).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders backend forbidden even when the client access hint allowed entry", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderReference(
|
|
||||||
inputWith({
|
|
||||||
listResources: async () => ({
|
|
||||||
ok: false,
|
|
||||||
error: createFailure(
|
|
||||||
"FORBIDDEN",
|
|
||||||
"LIST_REFERENCE_RESOURCES",
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
|
||||||
"이 작업을 수행할 권한이 없습니다.",
|
|
||||||
);
|
|
||||||
await user.click(
|
|
||||||
screen.getByRole("button", { name: "안전한 화면으로 이동" }),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
await screen.findByRole("heading", {
|
|
||||||
level: 1,
|
|
||||||
name: "Tech Log",
|
|
||||||
}),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts sign-in with the current route for a backend auth failure", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const demoSession = createDemoSessionAdapter("authenticated");
|
|
||||||
const beginSignIn = vi.fn(async () => {});
|
|
||||||
const session = { ...demoSession, beginSignIn };
|
|
||||||
renderReference(
|
|
||||||
inputWith({
|
|
||||||
listResources: async () => ({
|
|
||||||
ok: false,
|
|
||||||
error: createFailure(
|
|
||||||
"AUTH_REQUIRED",
|
|
||||||
"LIST_REFERENCE_RESOURCES",
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
"/examples/reference-resources?limit=5",
|
|
||||||
session,
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole("button", { name: "로그인" }));
|
|
||||||
expect(beginSignIn).toHaveBeenCalledWith(
|
|
||||||
"/examples/reference-resources?limit=5",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to a public route when starting sign-in fails", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const demoSession = createDemoSessionAdapter("authenticated");
|
|
||||||
const beginSignIn = vi.fn(async () => {
|
|
||||||
throw new Error("identity provider unavailable");
|
|
||||||
});
|
|
||||||
renderReference(
|
|
||||||
inputWith({
|
|
||||||
listResources: async () => ({
|
|
||||||
ok: false,
|
|
||||||
error: createFailure(
|
|
||||||
"AUTH_REQUIRED",
|
|
||||||
"LIST_REFERENCE_RESOURCES",
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
"/examples/reference-resources?limit=5",
|
|
||||||
{ ...demoSession, beginSignIn },
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole("button", { name: "로그인" }));
|
|
||||||
expect(
|
|
||||||
await screen.findByRole("heading", {
|
|
||||||
level: 1,
|
|
||||||
name: "Tech Log",
|
|
||||||
}),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deduplicates create, preserves input and surfaces a conflict", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
let finish:
|
|
||||||
| ((result: ReferenceResult<ReferenceResourceView>) => void)
|
|
||||||
| undefined;
|
|
||||||
const createResource = vi.fn(
|
|
||||||
() =>
|
|
||||||
new Promise<ReferenceResult<ReferenceResourceView>>((resolve) => {
|
|
||||||
finish = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
renderReference(
|
|
||||||
inputWith({ createResource }),
|
|
||||||
"/examples/reference-resources/new",
|
|
||||||
);
|
|
||||||
await screen.findByRole("heading", {
|
|
||||||
name: "Reference resource 만들기",
|
|
||||||
});
|
|
||||||
await user.type(
|
|
||||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
|
||||||
"Conflicting",
|
|
||||||
);
|
|
||||||
await user.type(screen.getByLabelText("설명"), "Keep this input");
|
|
||||||
const submit = screen.getByRole("button", { name: "저장" });
|
|
||||||
await user.dblClick(submit);
|
|
||||||
|
|
||||||
await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
|
|
||||||
expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled();
|
|
||||||
|
|
||||||
finish?.({
|
|
||||||
ok: false,
|
|
||||||
error: createFailure(
|
|
||||||
"CONFLICT",
|
|
||||||
"CREATE_REFERENCE_RESOURCE",
|
|
||||||
0,
|
|
||||||
{ effect: "NOT_APPLIED" },
|
|
||||||
),
|
|
||||||
});
|
|
||||||
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
|
||||||
).toHaveValue("Conflicting");
|
|
||||||
expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks resubmit and exposes only reconciliation for an unknown create effect", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const createResource = vi.fn(async () => ({
|
|
||||||
ok: false as const,
|
|
||||||
error: createFailure(
|
|
||||||
"SERVER_FAILURE",
|
|
||||||
"CREATE_REFERENCE_RESOURCE",
|
|
||||||
0,
|
|
||||||
{ effect: "MAYBE_APPLIED" },
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
renderReference(
|
|
||||||
inputWith({ createResource }),
|
|
||||||
"/examples/reference-resources/new",
|
|
||||||
);
|
|
||||||
await user.type(
|
|
||||||
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
|
|
||||||
"Unknown result",
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByText("변경 결과를 확인할 수 없습니다."),
|
|
||||||
).toBeVisible();
|
|
||||||
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
|
||||||
expect(
|
|
||||||
screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
|
||||||
expect(createResource).toHaveBeenCalledOnce();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
|
||||||
).toHaveValue("Unknown result");
|
|
||||||
|
|
||||||
await user.click(
|
|
||||||
screen.getByRole("button", { name: "변경되지 않음으로 확인" }),
|
|
||||||
);
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled(),
|
|
||||||
);
|
|
||||||
expect(createResource).toHaveBeenCalledOnce();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
|
||||||
).toHaveValue("Unknown result");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("settles the form after confirming an unknown create was applied", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const createResource = vi.fn(async () => ({
|
|
||||||
ok: false as const,
|
|
||||||
error: createFailure(
|
|
||||||
"SERVER_FAILURE",
|
|
||||||
"CREATE_REFERENCE_RESOURCE",
|
|
||||||
0,
|
|
||||||
{ effect: "MAYBE_APPLIED" },
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
renderReference(
|
|
||||||
inputWith({ createResource }),
|
|
||||||
"/examples/reference-resources/new",
|
|
||||||
);
|
|
||||||
const name = await screen.findByRole("textbox", {
|
|
||||||
name: /새 항목 이름/,
|
|
||||||
});
|
|
||||||
await user.type(name, "Already created");
|
|
||||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
|
||||||
await user.click(
|
|
||||||
await screen.findByRole("button", { name: "변경됨으로 확인" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
|
|
||||||
expect(name).toHaveValue("");
|
|
||||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
|
||||||
expect(createResource).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats an applied-confirmed failure as a settled create", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const createResource = vi.fn(async () => ({
|
|
||||||
ok: false as const,
|
|
||||||
error: createFailure(
|
|
||||||
"SERVER_FAILURE",
|
|
||||||
"CREATE_REFERENCE_RESOURCE",
|
|
||||||
0,
|
|
||||||
{ effect: "APPLIED_CONFIRMED" },
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
renderReference(
|
|
||||||
inputWith({ createResource }),
|
|
||||||
"/examples/reference-resources/new",
|
|
||||||
);
|
|
||||||
const name = await screen.findByRole("textbox", {
|
|
||||||
name: /새 항목 이름/,
|
|
||||||
});
|
|
||||||
await user.type(name, "Committed despite response");
|
|
||||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
|
||||||
|
|
||||||
expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
|
|
||||||
expect(name).toHaveValue("");
|
|
||||||
expect(
|
|
||||||
screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
|
||||||
expect(createResource).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const listResources = vi
|
|
||||||
.fn<ReferenceFeatureInput["listResources"]>()
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
value: [
|
|
||||||
{
|
|
||||||
resourceId: "existing",
|
|
||||||
title: "Existing",
|
|
||||||
createdAt: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
error: createFailure(
|
|
||||||
"SERVER_FAILURE",
|
|
||||||
"LIST_REFERENCE_RESOURCES",
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
value: [
|
|
||||||
{
|
|
||||||
resourceId: "recovered",
|
|
||||||
title: "Recovered",
|
|
||||||
createdAt: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
renderReference(inputWith({ listResources }));
|
|
||||||
await screen.findByText("Existing");
|
|
||||||
await user.click(screen.getByRole("button", { name: "새로고침" }));
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByText("기존 정보를 표시하고 있습니다."),
|
|
||||||
).toBeVisible();
|
|
||||||
expect(screen.getByText("Existing")).toBeVisible();
|
|
||||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
|
||||||
expect(await screen.findByText("Recovered")).toBeVisible();
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.queryByText("stale-degraded")).not.toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
expect(listResources).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import {
|
|
||||||
afterAll,
|
|
||||||
afterEach,
|
|
||||||
beforeAll,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from "vitest";
|
|
||||||
|
|
||||||
import { createRuntimeComposition } from "../../../src/bootstrap/create-runtime-composition.ts";
|
|
||||||
import { RuntimeApplication } from "../../../src/bootstrap/runtime-application.tsx";
|
|
||||||
import { createBootstrapHandlers } from "../../mocks/handlers/bootstrap.ts";
|
|
||||||
import { createReferenceScenarioHandlers } from "../../mocks/handlers/reference-resources.ts";
|
|
||||||
import { createStrictMockServer } from "../../mocks/server.ts";
|
|
||||||
|
|
||||||
const runtimeConfig = {
|
|
||||||
APP_ENV: "local",
|
|
||||||
API_BASE_URL: "https://api.test",
|
|
||||||
REQUEST_TIMEOUT_MS: 10_000,
|
|
||||||
MAX_RETRY_ATTEMPTS: 0,
|
|
||||||
TELEMETRY_ENABLED: false,
|
|
||||||
AUTH_MODE: "demo",
|
|
||||||
CONFIG_SCHEMA_VERSION: "1",
|
|
||||||
API_CONTRACT_VERSION: "1",
|
|
||||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
|
||||||
BUILD_ID: "local-build",
|
|
||||||
RELEASE_ID: "local-release",
|
|
||||||
};
|
|
||||||
const releaseManifest = {
|
|
||||||
schemaVersion: 1,
|
|
||||||
appVersion: "0.1.0",
|
|
||||||
buildId: "local-build",
|
|
||||||
commitSha: "local",
|
|
||||||
configSchemaVersion: "1",
|
|
||||||
apiContractVersion: "1",
|
|
||||||
assetManifestHash: "test-hash",
|
|
||||||
releaseId: "local-release",
|
|
||||||
builtAt: "2026-07-26T00:00:00.000Z",
|
|
||||||
routeChunks: {
|
|
||||||
"route-home": "assets/home.js",
|
|
||||||
"route-examples-ui": "assets/ui.js",
|
|
||||||
"route-examples-states": "assets/states.js",
|
|
||||||
"route-examples-auth": "assets/auth.js",
|
|
||||||
"route-reference-resources": "assets/reference.js",
|
|
||||||
"route-reference-resource-detail": "assets/reference-detail.js",
|
|
||||||
"route-reference-resource-form": "assets/reference-form.js",
|
|
||||||
"route-reference-resource-status": "assets/reference-status.js",
|
|
||||||
"route-not-found": "assets/not-found.js",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const listRequests = vi.fn();
|
|
||||||
const createRequests = vi.fn();
|
|
||||||
const resources = [{ id: "reference-1", name: "Existing" }];
|
|
||||||
const mockApi = createStrictMockServer(
|
|
||||||
...createBootstrapHandlers(runtimeConfig, releaseManifest),
|
|
||||||
...createReferenceScenarioHandlers({
|
|
||||||
resources,
|
|
||||||
onList: listRequests,
|
|
||||||
onCreate: createRequests,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeAll(mockApi.listen);
|
|
||||||
afterEach(() => {
|
|
||||||
mockApi.reset();
|
|
||||||
listRequests.mockClear();
|
|
||||||
createRequests.mockClear();
|
|
||||||
resources.splice(1);
|
|
||||||
});
|
|
||||||
afterAll(mockApi.close);
|
|
||||||
|
|
||||||
const absoluteFetch: typeof fetch = (input, init) => {
|
|
||||||
if (input instanceof Request) return fetch(input, init);
|
|
||||||
const url = new URL(
|
|
||||||
input instanceof URL ? input.href : input,
|
|
||||||
"http://app.test",
|
|
||||||
);
|
|
||||||
return fetch(url, init);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("reference feature production vertical path", () => {
|
|
||||||
it("traverses bootstrap, router, application, HTTP schema/mapper and query cache", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const composition = await createRuntimeComposition({
|
|
||||||
fetcher: absoluteFetch,
|
|
||||||
host: {},
|
|
||||||
});
|
|
||||||
window.history.pushState(
|
|
||||||
{},
|
|
||||||
"",
|
|
||||||
"/examples/reference-resources?tags=open&tags=new&limit=5",
|
|
||||||
);
|
|
||||||
render(<RuntimeApplication composition={composition} />);
|
|
||||||
|
|
||||||
await user.click(
|
|
||||||
await screen.findByRole("button", { name: "로그인 시작" }),
|
|
||||||
);
|
|
||||||
expect(await screen.findByText("Existing")).toBeVisible();
|
|
||||||
expect(listRequests).toHaveBeenCalledWith(
|
|
||||||
"?limit=5&tags=open&tags=new",
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "새 항목 만들기" }));
|
|
||||||
await user.type(
|
|
||||||
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
|
|
||||||
" Created ",
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
|
||||||
expect(await screen.findByText("저장했습니다.")).toBeVisible();
|
|
||||||
await user.click(screen.getByRole("button", { name: "목록으로 돌아가기" }));
|
|
||||||
expect(await screen.findByText("Created")).toBeVisible();
|
|
||||||
expect(createRequests).toHaveBeenCalledWith({ name: "Created" });
|
|
||||||
expect(listRequests.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -27,9 +27,9 @@ import { ExploreKindPage } from "../../../src/features/tech-log/presentation/pub
|
|||||||
import { ExplorePage } from "../../../src/features/tech-log/presentation/public/pages/explore-page.tsx";
|
import { ExplorePage } from "../../../src/features/tech-log/presentation/public/pages/explore-page.tsx";
|
||||||
import { HomePage } from "../../../src/features/tech-log/presentation/public/pages/home-page.tsx";
|
import { HomePage } from "../../../src/features/tech-log/presentation/public/pages/home-page.tsx";
|
||||||
import { SearchPage } from "../../../src/features/tech-log/presentation/public/pages/search-page.tsx";
|
import { SearchPage } from "../../../src/features/tech-log/presentation/public/pages/search-page.tsx";
|
||||||
|
import { PublicNotFoundPage as NotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-page.tsx";
|
||||||
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
||||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||||
import NotFoundPage from "../../../src/presentation/pages/not-found-page.tsx";
|
|
||||||
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
|
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
|
||||||
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
||||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ import { CasePage } from "../../../src/features/tech-log/presentation/public/pag
|
|||||||
import { QuestionPage } from "../../../src/features/tech-log/presentation/public/pages/question-page.tsx";
|
import { QuestionPage } from "../../../src/features/tech-log/presentation/public/pages/question-page.tsx";
|
||||||
import { ReferencePage } from "../../../src/features/tech-log/presentation/public/pages/reference-page.tsx";
|
import { ReferencePage } from "../../../src/features/tech-log/presentation/public/pages/reference-page.tsx";
|
||||||
import { TopicPage } from "../../../src/features/tech-log/presentation/public/pages/topic-page.tsx";
|
import { TopicPage } from "../../../src/features/tech-log/presentation/public/pages/topic-page.tsx";
|
||||||
|
import { PublicNotFoundPage as NotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-page.tsx";
|
||||||
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
|
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
|
||||||
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
||||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||||
import NotFoundPage from "../../../src/presentation/pages/not-found-page.tsx";
|
|
||||||
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
|
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
|
||||||
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
||||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||||
|
|||||||
@@ -197,9 +197,11 @@ describe("TechLog route boundary contract", () => {
|
|||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not install unfinished TechLog route or runtime entries", () => {
|
it("atomically installs the complete TechLog route and runtime inventories", () => {
|
||||||
expect(Object.keys(ROUTE_REGISTRY).some((routeId) => routeId.startsWith("TECH_LOG_"))).toBe(false);
|
expect(Object.keys(ROUTE_REGISTRY)).toEqual(
|
||||||
expect(Object.keys(ROUTE_RUNTIME).some((routeId) => routeId.startsWith("TECH_LOG_"))).toBe(false);
|
expectedRoutes.map(([routeId]) => routeId),
|
||||||
expect(Object.keys(ROUTE_REGISTRY)).toEqual(Object.keys(ROUTE_RUNTIME));
|
);
|
||||||
|
expect(Object.keys(ROUTE_RUNTIME)).toEqual(Object.keys(ROUTE_REGISTRY));
|
||||||
|
expect(ROUTE_REGISTRY).toEqual(TECH_LOG_ROUTE_REGISTRY);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
afterEach,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
|
||||||
|
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
|
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||||
|
import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||||
|
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
||||||
|
import type { WorkingCopyInput } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||||
|
import { PublicationEventPreviewScreen } from "../../../src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx";
|
||||||
|
import { PublicationList } from "../../../src/features/tech-log/presentation/studio/components/publication-list.tsx";
|
||||||
|
import { PublishScreen } from "../../../src/features/tech-log/presentation/studio/components/publish-screen.tsx";
|
||||||
|
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||||
|
|
||||||
|
const originalShowModal = HTMLDialogElement.prototype.showModal;
|
||||||
|
const originalClose = HTMLDialogElement.prototype.close;
|
||||||
|
|
||||||
|
class NoopIntersectionObserver implements IntersectionObserver {
|
||||||
|
readonly root = null;
|
||||||
|
readonly rootMargin = "0px";
|
||||||
|
readonly scrollMargin = "0px";
|
||||||
|
readonly thresholds = [0];
|
||||||
|
|
||||||
|
disconnect() {}
|
||||||
|
observe() {}
|
||||||
|
takeRecords(): IntersectionObserverEntry[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
unobserve() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLDialogElement) {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLDialogElement) {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalShowModal,
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalClose,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderInStudio(
|
||||||
|
node: React.ReactNode,
|
||||||
|
gateway: StudioGateway = createTechLogFeatureInstalledInput().input.createStudioGateway(),
|
||||||
|
navigate: (href: string) => void = () => undefined,
|
||||||
|
) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={["/studio"]}>
|
||||||
|
<StudioProvider createGateway={() => gateway} navigate={navigate}>
|
||||||
|
{node}
|
||||||
|
</StudioProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warningReadyDocument(gateway: StudioGateway) {
|
||||||
|
const input: WorkingCopyInput = {
|
||||||
|
kind: "CASE",
|
||||||
|
title: "게시 경고 예시",
|
||||||
|
slug: "publish-warning-example",
|
||||||
|
summary: "경고 확인 뒤 게시합니다.",
|
||||||
|
topicId: FIXTURE_IDS.topicJpa,
|
||||||
|
projectId: null,
|
||||||
|
relations: [],
|
||||||
|
problem: "경고가 있습니다.",
|
||||||
|
conclusion: "확인 뒤 게시합니다.",
|
||||||
|
environment: "Studio",
|
||||||
|
reproduction: "Mock",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown: "게시할 본문",
|
||||||
|
};
|
||||||
|
const document = await gateway.createDocument(input, {
|
||||||
|
idempotencyKey: "publication-test-create",
|
||||||
|
});
|
||||||
|
const validation = await gateway.validateDocument(
|
||||||
|
document.id,
|
||||||
|
{ expectedVersion: 1 },
|
||||||
|
{ idempotencyKey: "publication-test-validation" },
|
||||||
|
);
|
||||||
|
await gateway.createPreview(
|
||||||
|
document.id,
|
||||||
|
{ expectedVersion: 1, validationId: validation.validationId },
|
||||||
|
{ idempotencyKey: "publication-test-preview" },
|
||||||
|
);
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||||
|
resolve = nextResolve;
|
||||||
|
reject = nextReject;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TechLog Studio publication flow", () => {
|
||||||
|
it("blocks invalid and stale saved versions before a publish command can start", async () => {
|
||||||
|
const invalid = renderInStudio(
|
||||||
|
<PublishScreen documentId={FIXTURE_IDS.edgeTokenQuestion} />,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("검증 오류를 먼저 수정해야 합니다")).toBeVisible();
|
||||||
|
expect(screen.queryByRole("button", { name: "게시" })).not.toBeInTheDocument();
|
||||||
|
invalid.unmount();
|
||||||
|
|
||||||
|
renderInStudio(<PublishScreen documentId={FIXTURE_IDS.fetchJoinCase} />);
|
||||||
|
expect(await screen.findByText("검증 결과가 현재 버전과 다릅니다")).toBeVisible();
|
||||||
|
expect(screen.getByRole("link", { name: "다시 검증" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
`/studio/documents/${FIXTURE_IDS.fetchJoinCase}/validation`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes a current warning preview only after every warning is acknowledged", async () => {
|
||||||
|
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
const document = await warningReadyDocument(gateway);
|
||||||
|
const destinations: string[] = [];
|
||||||
|
renderInStudio(
|
||||||
|
<PublishScreen documentId={document.id} />,
|
||||||
|
gateway,
|
||||||
|
(href) => destinations.push(href),
|
||||||
|
);
|
||||||
|
|
||||||
|
const publish = await screen.findByRole("button", { name: "게시" });
|
||||||
|
expect(publish).toBeDisabled();
|
||||||
|
await userEvent.click(screen.getByRole("checkbox", { name: /PROJECT_MISSING/ }));
|
||||||
|
expect(publish).toBeEnabled();
|
||||||
|
await userEvent.click(publish);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(destinations[0]).toMatch(
|
||||||
|
/^\/studio\/publications\/[0-9a-f-]+\/preview$/,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("status", { name: "" })).toHaveTextContent("게시했습니다.");
|
||||||
|
expect((await gateway.listPublications({ limit: 100 })).items[0]).toMatchObject({
|
||||||
|
event: { type: "PUBLISHED", publishedVersion: 1 },
|
||||||
|
publication: { status: "PUBLISHED", publicPath: "/cases/publish-warning-example" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the publish pending state, preserves gateway command order, and retries with a new key", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
const document = await warningReadyDocument(base);
|
||||||
|
const first = deferred<never>();
|
||||||
|
const calls: string[] = [];
|
||||||
|
const keys: string[] = [];
|
||||||
|
const publishDocument = vi
|
||||||
|
.fn<StudioGateway["publishDocument"]>()
|
||||||
|
.mockImplementationOnce((_id, _command, options) => {
|
||||||
|
calls.push("publishDocument");
|
||||||
|
keys.push(options.idempotencyKey);
|
||||||
|
return first.promise;
|
||||||
|
})
|
||||||
|
.mockImplementation((...args) => {
|
||||||
|
calls.push("publishDocument");
|
||||||
|
keys.push(args[2].idempotencyKey);
|
||||||
|
return base.publishDocument(...args);
|
||||||
|
});
|
||||||
|
const gateway = {
|
||||||
|
...base,
|
||||||
|
getDocument(...args: Parameters<StudioGateway["getDocument"]>) {
|
||||||
|
calls.push("getDocument");
|
||||||
|
return base.getDocument(...args);
|
||||||
|
},
|
||||||
|
getCurrentPreview(...args: Parameters<StudioGateway["getCurrentPreview"]>) {
|
||||||
|
calls.push("getCurrentPreview");
|
||||||
|
return base.getCurrentPreview(...args);
|
||||||
|
},
|
||||||
|
publishDocument,
|
||||||
|
} satisfies StudioGateway;
|
||||||
|
renderInStudio(<PublishScreen documentId={document.id} />, gateway);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("checkbox", { name: /PROJECT_MISSING/ }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "게시" }));
|
||||||
|
expect(screen.getByRole("button", { name: "게시 중…" })).toBeDisabled();
|
||||||
|
expect(calls.slice(0, 3)).toEqual([
|
||||||
|
"getDocument",
|
||||||
|
"getCurrentPreview",
|
||||||
|
"publishDocument",
|
||||||
|
]);
|
||||||
|
|
||||||
|
first.reject(new StudioGatewayError({
|
||||||
|
type: "https://techlog.local/problems/studio-unavailable",
|
||||||
|
title: "STUDIO_UNAVAILABLE",
|
||||||
|
status: 503,
|
||||||
|
detail: "Studio가 잠시 응답하지 않습니다.",
|
||||||
|
code: "STUDIO_UNAVAILABLE",
|
||||||
|
retryable: true,
|
||||||
|
}));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
"Studio가 잠시 응답하지 않습니다.",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "게시" }));
|
||||||
|
await waitFor(() => expect(publishDocument).toHaveBeenCalledTimes(2));
|
||||||
|
expect(keys[1]).not.toBe(keys[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters publication events and recovers a failed history read", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
const listPublications = vi
|
||||||
|
.fn<StudioGateway["listPublications"]>()
|
||||||
|
.mockRejectedValueOnce(new Error("offline"))
|
||||||
|
.mockImplementation((query, options) => base.listPublications(query, options));
|
||||||
|
renderInStudio(<PublicationList />, { ...base, listPublications });
|
||||||
|
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
"게시 기록을 불러오지 못했습니다offline",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||||
|
expect(await screen.findByRole("heading", { name: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유" })).toBeVisible();
|
||||||
|
|
||||||
|
await user.selectOptions(screen.getByLabelText("이벤트"), "UNPUBLISHED");
|
||||||
|
await user.type(screen.getByLabelText("검색"), "Fetch 전략");
|
||||||
|
await user.click(screen.getByRole("button", { name: "적용" }));
|
||||||
|
expect(await screen.findByRole("heading", { name: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준" })).toBeVisible();
|
||||||
|
expect(screen.queryByRole("heading", { name: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unpublishes only the selected current row after the source confirmation", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
renderInStudio(<PublicationList />, gateway);
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: /Redis Adapter.*게시 취소/ }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog", { name: "게시를 취소할까요?" })).toHaveTextContent(
|
||||||
|
"Studio 게시 상태를 중단하고 게시 취소 이벤트를 남깁니다.",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("작업본과 이전 Snapshot은 보존됩니다.")).toBeVisible();
|
||||||
|
await user.click(screen.getByRole("button", { name: "게시 취소 확인" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByText("게시를 취소했습니다.").length).toBeGreaterThanOrEqual(1));
|
||||||
|
expect(await screen.findAllByRole("link", { name: "게시 취소 전 Snapshot 보기" })).not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the event's immutable snapshot instead of a newer working copy", async () => {
|
||||||
|
const view = renderInStudio(
|
||||||
|
<PublicationEventPreviewScreen publicationEventId={FIXTURE_IDS.fetchPublishedEvent} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { level: 1, name: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가" })).toBeVisible();
|
||||||
|
expect(screen.getByText(/반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정/)).toBeVisible();
|
||||||
|
expect(screen.queryByText("게시 후 본문 측정값을 보완한 저장본입니다.")).not.toBeInTheDocument();
|
||||||
|
expect(view.container.querySelectorAll("main")).toHaveLength(0);
|
||||||
|
expect(view.container.querySelector(".public-record-embedded")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unknown publication events inside the Studio not-found screen", async () => {
|
||||||
|
renderInStudio(
|
||||||
|
<PublicationEventPreviewScreen publicationEventId="99999999-9999-4999-8999-999999999999" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { level: 1, name: "게시 기록을 찾을 수 없습니다" })).toBeVisible();
|
||||||
|
expect(screen.getByRole("link", { name: "게시 기록으로 돌아가기" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/studio/publications",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ describe("installed route registry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("allows public routes without consulting a product permission", () => {
|
it("allows public routes without consulting a product permission", () => {
|
||||||
expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({
|
expect(decideRouteAccess("TECH_LOG_HOME", "unauthenticated")).toEqual({
|
||||||
allowed: true,
|
allowed: true,
|
||||||
action: "none",
|
action: "none",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,10 +4,54 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
|
|
||||||
import { viteModuleInventoryPlugin } from "./scripts/lib/vite-module-inventory.ts";
|
import { viteModuleInventoryPlugin } from "./scripts/lib/vite-module-inventory.ts";
|
||||||
|
|
||||||
|
const techLogRouteChunks = Object.freeze({
|
||||||
|
"/public/pages/home-page.tsx": "route-tech-log-home",
|
||||||
|
"/public/pages/explore-page.tsx": "route-tech-log-explore",
|
||||||
|
"/public/pages/explore-kind-page.tsx": "route-tech-log-explore-kind",
|
||||||
|
"/public/pages/case-page.tsx": "route-tech-log-case",
|
||||||
|
"/public/pages/reference-page.tsx": "route-tech-log-reference",
|
||||||
|
"/public/pages/question-page.tsx": "route-tech-log-question",
|
||||||
|
"/public/pages/topic-page.tsx": "route-tech-log-topic",
|
||||||
|
"/public/pages/projects-page.tsx": "route-tech-log-projects",
|
||||||
|
"/public/pages/project-overview-page.tsx": "route-tech-log-project",
|
||||||
|
"/public/pages/project-records-page.tsx": "route-tech-log-project-records",
|
||||||
|
"/public/pages/project-decisions-page.tsx": "route-tech-log-project-decisions",
|
||||||
|
"/public/pages/project-activity-page.tsx": "route-tech-log-project-activity",
|
||||||
|
"/public/pages/releases-page.tsx": "route-tech-log-releases",
|
||||||
|
"/public/pages/release-page.tsx": "route-tech-log-release",
|
||||||
|
"/public/pages/profile-page.tsx": "route-tech-log-profile",
|
||||||
|
"/public/pages/search-page.tsx": "route-tech-log-search",
|
||||||
|
"/studio/pages/studio-home-page.tsx": "route-tech-log-studio-home",
|
||||||
|
"/studio/pages/documents-page.tsx": "route-tech-log-studio-documents",
|
||||||
|
"/studio/pages/new-document-page.tsx": "route-tech-log-studio-document-new",
|
||||||
|
"/studio/pages/document-edit-page.tsx": "route-tech-log-studio-document-edit",
|
||||||
|
"/studio/pages/document-validation-page.tsx": "route-tech-log-studio-document-validation",
|
||||||
|
"/studio/pages/document-preview-page.tsx": "route-tech-log-studio-document-preview",
|
||||||
|
"/studio/pages/document-publish-page.tsx": "route-tech-log-studio-document-publish",
|
||||||
|
"/studio/pages/publications-page.tsx": "route-tech-log-studio-publications",
|
||||||
|
"/studio/pages/publication-preview-page.tsx": "route-tech-log-studio-publication-preview",
|
||||||
|
"/studio/pages/studio-not-found-page.tsx": "route-tech-log-studio-not-found",
|
||||||
|
"/public/pages/public-not-found-page.tsx": "route-not-found",
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
function routeChunkName(moduleId: string): string | undefined {
|
||||||
|
const normalized = moduleId.replaceAll("\\", "/");
|
||||||
|
const featurePath = "/src/features/tech-log/presentation";
|
||||||
|
const featureIndex = normalized.lastIndexOf(featurePath);
|
||||||
|
if (featureIndex < 0) return undefined;
|
||||||
|
const routePath = normalized.slice(featureIndex + featurePath.length);
|
||||||
|
return techLogRouteChunks[routePath as keyof typeof techLogRouteChunks];
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss(), viteModuleInventoryPlugin()],
|
plugins: [react(), tailwindcss(), viteModuleInventoryPlugin()],
|
||||||
build: {
|
build: {
|
||||||
manifest: true,
|
manifest: true,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: routeChunkName,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user