Compare commits
12
Commits
ab67eb912c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2420fcee9f | ||
|
|
2632850322 | ||
|
|
ad8f322b43 | ||
|
|
ab4d822956 | ||
|
|
344a163d84 | ||
|
|
60c8c82097 | ||
|
|
b3119952d5 | ||
|
|
7211dd1a92 | ||
|
|
3036b8d788 | ||
|
|
fd73bc88a1 | ||
|
|
e5770dfbc8 | ||
|
|
a7fe069a6d |
@@ -38,28 +38,28 @@
|
|||||||
},
|
},
|
||||||
"contractSet": {
|
"contractSet": {
|
||||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||||
"setDigest": "sha256:b729150c56b64ba01d7a71a7442c14bb04d9e573b852d1d1879e748854c20174",
|
"setDigest": "sha256:cdcfb628a502d71596f1162726eb395aad0f5f92cf05fd77d304f8e51c81b2fc",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/management-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "06ae075"
|
"sourceRevision": "ef49d3a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/public-contract",
|
"packageId": "@tech-log/public-contract",
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
|
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "06ae075"
|
"sourceRevision": "ef49d3a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "06ae075"
|
"sourceRevision": "ef49d3a"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ import {
|
|||||||
decisionItemToDecision,
|
decisionItemToDecision,
|
||||||
flattenRelations,
|
flattenRelations,
|
||||||
knowledgeListItemToRecord,
|
knowledgeListItemToRecord,
|
||||||
markdownLines,
|
|
||||||
markdownSections,
|
markdownSections,
|
||||||
questionListItemToRecord,
|
questionListItemToRecord,
|
||||||
releaseDetailToRelease,
|
releaseDetailToRelease,
|
||||||
searchItemToEntity,
|
searchItemToEntity,
|
||||||
} from "./public-content-mapping.ts";
|
} from "./public-content-mapping.ts";
|
||||||
|
import type { components } from "../../contracts/public/generated.ts";
|
||||||
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
||||||
|
|
||||||
const ROUTE_ID = "TECH_LOG_PUBLIC";
|
const ROUTE_ID = "TECH_LOG_PUBLIC";
|
||||||
@@ -142,13 +142,19 @@ export function createHttpPublicContentGateway(
|
|||||||
|
|
||||||
const canonicalPath = String(detail.canonicalPath ?? "");
|
const canonicalPath = String(detail.canonicalPath ?? "");
|
||||||
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
|
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
|
||||||
|
const projectOf = (entry: Readonly<{ title?: string; path?: string }> | undefined) =>
|
||||||
|
entry?.path
|
||||||
|
? { name: entry.title ?? "", slug: entry.path.split("/").filter(Boolean).pop() ?? "", path: entry.path }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (kind === "CASE") {
|
if (kind === "CASE") {
|
||||||
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
|
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...baseOf("CASE", slug, {
|
...baseOf("CASE", slug, {
|
||||||
title: body.title as string,
|
title: body.title as string,
|
||||||
summary: body.problemSummary as string,
|
// 제목 바로 아래에 오는 것은 문서의 요약이다. 유형별 요약(문제/범위)을 쓰면 바로 아래
|
||||||
|
// 블록과 같은 글을 두 번 말한다.
|
||||||
|
summary: (body.summary as string) ?? "",
|
||||||
path: canonicalPath,
|
path: canonicalPath,
|
||||||
primaryTopic: body.primaryTopic as never,
|
primaryTopic: body.primaryTopic as never,
|
||||||
primaryProject: body.primaryProject as never,
|
primaryProject: body.primaryProject as never,
|
||||||
@@ -193,7 +199,7 @@ export function createHttpPublicContentGateway(
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...baseOf("REFERENCE", slug, {
|
...baseOf("REFERENCE", slug, {
|
||||||
title: body.title as string,
|
title: body.title as string,
|
||||||
summary: body.purposeSummary as string,
|
summary: (body.summary as string) ?? "",
|
||||||
path: canonicalPath,
|
path: canonicalPath,
|
||||||
primaryTopic: body.primaryTopic as never,
|
primaryTopic: body.primaryTopic as never,
|
||||||
primaryProject: body.primaryProject as never,
|
primaryProject: body.primaryProject as never,
|
||||||
@@ -205,48 +211,90 @@ export function createHttpPublicContentGateway(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
kind: "REFERENCE",
|
kind: "REFERENCE",
|
||||||
purpose: (body.purposeSummary as string) ?? "",
|
/*
|
||||||
|
여기서 읽는 이름은 계약이 실제로 주는 이름이어야 한다. 한때 `purposeSummary`,
|
||||||
|
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown` 을 읽었는데 계약에는 그런
|
||||||
|
칸이 없다 — 전부 undefined 로 떨어져 공개 Reference 화면이 통째로 비었다. Studio 에서는
|
||||||
|
같은 글이 다 보이므로 "공개 쪽만 안 나온다" 로 드러났다.
|
||||||
|
|
||||||
|
규칙과 예시는 `content` 마크다운을 잘라 만드는 것이 아니라 계약이 구조로 준다. Studio 의
|
||||||
|
편집기가 제목과 본문을 따로 받기 때문이다.
|
||||||
|
*/
|
||||||
|
purpose: (body.scopeSummary as string) ?? "",
|
||||||
rules: Object.freeze(
|
rules: Object.freeze(
|
||||||
markdownSections(body.content as string).map((section) => ({
|
((body.rules as readonly Readonly<Record<string, unknown>>[] | undefined) ?? []).map(
|
||||||
title: section.title,
|
(rule) => ({
|
||||||
body: section.paragraphs.join("\n"),
|
title: String(rule.title ?? ""),
|
||||||
})),
|
body: String(rule.body ?? ""),
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
applyWhen: Object.freeze(markdownLines(body.applyWhenMarkdown as string)),
|
applyWhen: Object.freeze(((body.appliesTo as readonly string[] | undefined) ?? []).map(String)),
|
||||||
exceptions: Object.freeze(markdownLines(body.exceptionsMarkdown as string)),
|
exceptions: Object.freeze(
|
||||||
examples: Object.freeze(markdownLines(body.examplesMarkdown as string)),
|
((body.excludedScope as readonly string[] | undefined) ?? []).map(String),
|
||||||
|
),
|
||||||
|
examples: Object.freeze(((body.examples as readonly string[] | undefined) ?? []).map(String)),
|
||||||
verifiedAt: dateLabel(body.lastVerifiedAt as string),
|
verifiedAt: dateLabel(body.lastVerifiedAt as string),
|
||||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
|
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
|
||||||
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | undefined) ?? [];
|
/*
|
||||||
const pointsOf = (group: string) =>
|
`points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는
|
||||||
Object.freeze(
|
`{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세
|
||||||
points
|
화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기
|
||||||
.filter((point) => point.group === group)
|
때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다.
|
||||||
.flatMap((point) => (point.items as readonly string[] | undefined) ?? []),
|
|
||||||
);
|
`as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. 계약의 타입을 그대로 쓰면 다음에 모양이
|
||||||
|
바뀔 때 컴파일이 먼저 막는다.
|
||||||
|
*/
|
||||||
|
type QuestionPoints = components["schemas"]["QuestionPointGroup"];
|
||||||
|
const points = body.points as QuestionPoints | undefined;
|
||||||
|
const pointsOf = (group: keyof QuestionPoints) =>
|
||||||
|
Object.freeze([...(points?.[group] ?? [])].map(String));
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...baseOf("QUESTION", slug, {
|
...baseOf("QUESTION", slug, {
|
||||||
title: body.question as string,
|
title: body.question as string,
|
||||||
summary: body.summary as string,
|
summary: body.summary as string,
|
||||||
path: canonicalPath,
|
path: canonicalPath,
|
||||||
primaryTopic: body.primaryTopic as never,
|
primaryTopic: body.primaryTopic as never,
|
||||||
primaryProject: body.primaryProject as never,
|
/*
|
||||||
|
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
|
||||||
|
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트
|
||||||
|
칸이 늘 비어 있었다.
|
||||||
|
|
||||||
|
그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
|
||||||
|
`name`/`slug` 이므로 여기서 옮겨 준다 — slug 는 경로의 마지막 마디다.
|
||||||
|
*/
|
||||||
|
primaryProject: projectOf(groups.primaryProject) as never,
|
||||||
publishedAt: body.updatedAt as string,
|
publishedAt: body.updatedAt as string,
|
||||||
relations: flattenRelations(groups, {
|
/*
|
||||||
derivedCases: "이 질문에서 나온 기록",
|
계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다.
|
||||||
projectDecisions: "이 질문이 이끈 결정",
|
여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고,
|
||||||
relatedQuestions: "관련 질문",
|
하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다.
|
||||||
}),
|
|
||||||
|
`primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미
|
||||||
|
보여 주므로 관계 목록에 넣지 않는다.
|
||||||
|
*/
|
||||||
|
relations: flattenRelations(
|
||||||
|
{
|
||||||
|
resultCase: groups.resultCase,
|
||||||
|
producedDecision: groups.producedDecision,
|
||||||
|
derivedReferences: groups.derivedReferences,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resultCase: "이 질문에서 나온 기록",
|
||||||
|
producedDecision: "이 질문이 이끈 결정",
|
||||||
|
derivedReferences: "이 질문에서 정리된 기준",
|
||||||
|
},
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
kind: "QUESTION",
|
kind: "QUESTION",
|
||||||
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
||||||
facts: pointsOf("KNOWN_FACT"),
|
facts: pointsOf("facts"),
|
||||||
assumptions: pointsOf("ASSUMPTION"),
|
assumptions: pointsOf("assumptions"),
|
||||||
unknowns: pointsOf("UNRESOLVED"),
|
unknowns: pointsOf("unknowns"),
|
||||||
constraints: pointsOf("CONSTRAINT"),
|
constraints: pointsOf("constraints"),
|
||||||
options: Object.freeze([]),
|
options: Object.freeze([]),
|
||||||
nextValidation: (body.nextVerification as string) ?? "",
|
nextValidation: (body.nextVerification as string) ?? "",
|
||||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
|
|||||||
.filter(
|
.filter(
|
||||||
(record) =>
|
(record) =>
|
||||||
!hasTopicFilter ||
|
!hasTopicFilter ||
|
||||||
|
record.topicSlug.toLocaleLowerCase("ko-KR") === requestedTopic ||
|
||||||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
|
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -142,17 +143,12 @@ export function getLatestEntries(): LatestRecordEntry[] {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const releaseEntries = releases.map((release) => ({
|
/*
|
||||||
id: `release-${release.version}`,
|
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
|
||||||
entryType: "RELEASE" as const,
|
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
|
||||||
title: release.title,
|
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
|
||||||
summary: release.summary,
|
*/
|
||||||
path: release.path,
|
return [...activities].sort((left, right) =>
|
||||||
publishedAt: release.publishedAt,
|
|
||||||
topic: "TechLog",
|
|
||||||
project: "TechLog",
|
|
||||||
}));
|
|
||||||
return [...activities, ...releaseEntries].sort((left, right) =>
|
|
||||||
right.publishedAt.localeCompare(left.publishedAt),
|
right.publishedAt.localeCompare(left.publishedAt),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export type PublicTopic = {
|
|||||||
*/
|
*/
|
||||||
export type LatestRecordEntry = {
|
export type LatestRecordEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
|
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"packageId": "@tech-log/management-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||||
"sourceRevision": "06ae075",
|
"sourceRevision": "ef49d3a",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"createCaseDraft",
|
"createCaseDraft",
|
||||||
"getCaseForEdit",
|
"getCaseForEdit",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"packageId": "@tech-log/public-contract",
|
"packageId": "@tech-log/public-contract",
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
|
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
|
||||||
"sourceRevision": "06ae075",
|
"sourceRevision": "ef49d3a",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getPublicSite",
|
"getPublicSite",
|
||||||
"getPublicHome",
|
"getPublicHome",
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
LatestEntry: {
|
LatestEntry: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
|
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -536,6 +536,7 @@ export interface components {
|
|||||||
indexable: boolean;
|
indexable: boolean;
|
||||||
case: {
|
case: {
|
||||||
title: string;
|
title: string;
|
||||||
|
summary?: string;
|
||||||
problemSummary: string;
|
problemSummary: string;
|
||||||
conclusionSummary: string;
|
conclusionSummary: string;
|
||||||
environmentSummary?: string[];
|
environmentSummary?: string[];
|
||||||
@@ -571,9 +572,15 @@ export interface components {
|
|||||||
indexable: boolean;
|
indexable: boolean;
|
||||||
reference: {
|
reference: {
|
||||||
title: string;
|
title: string;
|
||||||
|
summary?: string;
|
||||||
scopeSummary: string;
|
scopeSummary: string;
|
||||||
appliesTo: string[];
|
appliesTo: string[];
|
||||||
excludedScope: string[];
|
excludedScope: string[];
|
||||||
|
rules?: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}[];
|
||||||
|
examples?: string[];
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
freshnessStatus: "CURRENT" | "REVIEW_DUE" | "HISTORICAL";
|
freshnessStatus: "CURRENT" | "REVIEW_DUE" | "HISTORICAL";
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
@@ -1022,6 +1022,7 @@ components:
|
|||||||
enum:
|
enum:
|
||||||
- CASE
|
- CASE
|
||||||
- REFERENCE
|
- REFERENCE
|
||||||
|
- QUESTION
|
||||||
- PROJECT_ACTIVITY
|
- PROJECT_ACTIVITY
|
||||||
- RELEASE
|
- RELEASE
|
||||||
title:
|
title:
|
||||||
@@ -1239,6 +1240,12 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
|
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
|
||||||
|
#
|
||||||
|
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
|
||||||
|
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
problemSummary:
|
problemSummary:
|
||||||
type: string
|
type: string
|
||||||
conclusionSummary:
|
conclusionSummary:
|
||||||
@@ -1325,6 +1332,12 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
|
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
|
||||||
|
#
|
||||||
|
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
|
||||||
|
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
scopeSummary:
|
scopeSummary:
|
||||||
type: string
|
type: string
|
||||||
appliesTo:
|
appliesTo:
|
||||||
@@ -1335,6 +1348,23 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
# Reference 의 본문은 `content` 마크다운이 아니라 이 두 칸에 있다. Studio 의 Reference
|
||||||
|
# 편집기는 규칙(제목+본문)과 예시를 따로 받고 body_markdown 은 비워 두므로, 이것을
|
||||||
|
# 내보내지 않으면 공개 화면에 판단 기준과 예시가 통째로 빠진다.
|
||||||
|
rules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [title, body]
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
examples:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
freshnessStatus:
|
freshnessStatus:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
|
||||||
"sourceRevision": "06ae075",
|
"sourceRevision": "ef49d3a",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getStudioSession",
|
"getStudioSession",
|
||||||
"getStudioDashboard",
|
"getStudioDashboard",
|
||||||
|
|||||||
@@ -1167,7 +1167,7 @@ export interface components {
|
|||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "PROPOSED" | "ADOPTED";
|
status: "PROPOSED" | "ADOPTED";
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
decidedOn: string;
|
decidedOn: string | null;
|
||||||
statement: string;
|
statement: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
consequences: components["schemas"]["OrderedText"][];
|
consequences: components["schemas"]["OrderedText"][];
|
||||||
|
|||||||
@@ -1572,7 +1572,10 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
kind: { type: string, enum: [PROJECT_DECISION] }
|
kind: { type: string, enum: [PROJECT_DECISION] }
|
||||||
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
||||||
decidedOn: { type: string, format: date }
|
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
|
||||||
|
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
|
||||||
|
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
|
||||||
|
decidedOn: { type: [string, "null"], format: date }
|
||||||
statement: { type: string, maxLength: 100000 }
|
statement: { type: string, maxLength: 100000 }
|
||||||
rationale: { type: string, maxLength: 100000 }
|
rationale: { type: string, maxLength: 100000 }
|
||||||
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
|
|||||||
@@ -131,7 +131,18 @@ function renderContext(
|
|||||||
|
|
||||||
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
|
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
|
||||||
if (input.kind === "PROJECT_DECISION") {
|
if (input.kind === "PROJECT_DECISION") {
|
||||||
if (!project?.publicPath) fail("PROJECT public path is required");
|
/*
|
||||||
|
Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 프로젝트가
|
||||||
|
공개되어 있지 않으면 이 문서에는 아직 주소가 없다.
|
||||||
|
|
||||||
|
예전 문구는 "PROJECT public path is required" 였다. 사실이지만 작성자가 할 일을 말해 주지
|
||||||
|
않는다 — 무엇을 어디서 눌러야 하는지 적는다.
|
||||||
|
*/
|
||||||
|
if (!project?.publicPath) {
|
||||||
|
fail(
|
||||||
|
"이 Decision 의 공개 주소는 프로젝트 주소 아래에 있습니다. 주제·프로젝트 화면에서 이 프로젝트를 먼저 게시해 주세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return `${project.publicPath}/decisions#${input.slug}`;
|
return `${project.publicPath}/decisions#${input.slug}`;
|
||||||
}
|
}
|
||||||
const prefix =
|
const prefix =
|
||||||
@@ -186,7 +197,7 @@ export function projectWorkingCopy(
|
|||||||
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
||||||
if (!topic) fail("TOPIC catalog entry is required");
|
if (!topic) fail("TOPIC catalog entry is required");
|
||||||
if (input.kind === "PROJECT_DECISION" && !project) {
|
if (input.kind === "PROJECT_DECISION" && !project) {
|
||||||
fail("PROJECT catalog entry is required");
|
fail("Decision 은 프로젝트에 속합니다. 기본 정보에서 프로젝트를 골라 주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
@@ -273,12 +284,16 @@ export function projectWorkingCopy(
|
|||||||
|
|
||||||
case "PROJECT_DECISION":
|
case "PROJECT_DECISION":
|
||||||
if (!input.decisionStatus) fail("Decision status is required");
|
if (!input.decisionStatus) fail("Decision status is required");
|
||||||
if (!input.decidedOn) fail("Decision date is required");
|
/*
|
||||||
|
결정일은 비어 있어도 모델을 만든다. 검증이 그것을 경고로만 다루므로 날짜 없이 게시할 수
|
||||||
|
있는데, 여기서 막으면 그 문서는 미리보기조차 열리지 않는다 — 작성자는 "경고라면서 왜
|
||||||
|
안 되냐"를 만난다. 화면이 "미정"이라고 말하면 된다.
|
||||||
|
*/
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
kind: "PROJECT_DECISION",
|
kind: "PROJECT_DECISION",
|
||||||
status: input.decisionStatus,
|
status: input.decisionStatus,
|
||||||
decidedOn: input.decidedOn,
|
decidedOn: input.decidedOn ?? null,
|
||||||
statement: input.statement,
|
statement: input.statement,
|
||||||
rationale: input.rationale,
|
rationale: input.rationale,
|
||||||
consequences: ordered(input.consequences),
|
consequences: ordered(input.consequences),
|
||||||
|
|||||||
@@ -37,7 +37,18 @@ export function ExploreFilterForm({
|
|||||||
);
|
);
|
||||||
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
|
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
|
||||||
return {
|
return {
|
||||||
topics: [...new Set(records.map((record) => record.topic))].sort(),
|
// 주제는 이름이 아니라 slug 로 거른다 — 프로젝트와 같다. 이름을 실었을 때는
|
||||||
|
// `topic=OAuth/OIDC 인증 경계` 가 나갔고, slug 로 거르는 API 는 0건을 돌려줬다.
|
||||||
|
// 화면에 보일 이름과 보낼 slug 가 다르므로 짝으로 들고 있어야 한다.
|
||||||
|
topics: [
|
||||||
|
...new Map(
|
||||||
|
records
|
||||||
|
.filter((record) => record.topicSlug)
|
||||||
|
.map((record) => [record.topicSlug, record.topic] as const),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map(([slug, name]) => ({ slug, name }))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, "ko-KR")),
|
||||||
projects: resolved
|
projects: resolved
|
||||||
.filter((item) => item !== undefined)
|
.filter((item) => item !== undefined)
|
||||||
.map((item) => ({ slug: item.slug, title: item.title })),
|
.map((item) => ({ slug: item.slug, title: item.title })),
|
||||||
@@ -47,8 +58,10 @@ export function ExploreFilterForm({
|
|||||||
const projects = view.data?.projects ?? [];
|
const projects = view.data?.projects ?? [];
|
||||||
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
||||||
const selectedTopic = topics.find(
|
const selectedTopic = topics.find(
|
||||||
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
(item) =>
|
||||||
);
|
item.slug.toLocaleLowerCase("ko-KR") === normalizedTopic ||
|
||||||
|
item.name.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
||||||
|
)?.slug;
|
||||||
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
|
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
|
||||||
const selectedProject = projects.find(
|
const selectedProject = projects.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -67,7 +80,7 @@ export function ExploreFilterForm({
|
|||||||
const data = new FormData(event.currentTarget);
|
const data = new FormData(event.currentTarget);
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of data) {
|
for (const [key, value] of data) {
|
||||||
if (typeof value === "string") search.append(key, value);
|
if (typeof value === "string" && value !== "") search.append(key, value);
|
||||||
}
|
}
|
||||||
void navigate(`${action}?${search.toString()}`);
|
void navigate(`${action}?${search.toString()}`);
|
||||||
}
|
}
|
||||||
@@ -83,7 +96,7 @@ export function ExploreFilterForm({
|
|||||||
{showType ? (
|
{showType ? (
|
||||||
<label><span>유형</span><select name="type" defaultValue={kind ?? ""}><option value="">전체</option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
|
<label><span>유형</span><select name="type" defaultValue={kind ?? ""}><option value="">전체</option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
|
||||||
) : null}
|
) : null}
|
||||||
<label><span>주제</span><select name="topic" defaultValue={selectedTopic ?? ""}><option value="">전체</option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label>
|
<label><span>주제</span><select name="topic" defaultValue={selectedTopic ?? ""}><option value="">전체</option>{topics.map((item) => <option value={item.slug} key={item.slug}>{item.name}</option>)}</select></label>
|
||||||
<label><span>프로젝트</span><select name="project" defaultValue={selectedProject ?? ""}><option value="">전체</option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
|
<label><span>프로젝트</span><select name="project" defaultValue={selectedProject ?? ""}><option value="">전체</option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
|
||||||
<button type="submit">적용</button>
|
<button type="submit">적용</button>
|
||||||
{hasActiveFilter ? <Link to={action}>필터 초기화</Link> : null}
|
{hasActiveFilter ? <Link to={action}>필터 초기화</Link> : null}
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export function PublicDocumentHeader({ record }: { record: PublicRecord }) {
|
|||||||
{kindLabels[record.kind]}
|
{kindLabels[record.kind]}
|
||||||
</Link>
|
</Link>
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
<Link to={`/topics/${record.topicSlug}`}>{record.topic}</Link>
|
{/*
|
||||||
|
주제 페이지가 아니라 그 주제로 거른 탐색으로 보낸다. `/topics/:slug` 는 세 개를
|
||||||
|
하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 되고, 설명·범위·대표 기록이 전부
|
||||||
|
비어 있어 지금 채울 내용도 없다. 독자가 여기서 기대하는 것 — 같은 주제의 기록 목록 —
|
||||||
|
은 탐색 필터가 그대로 준다.
|
||||||
|
*/}
|
||||||
|
<Link to={`/explore?topic=${encodeURIComponent(record.topicSlug)}`}>{record.topic}</Link>
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
<Link to={`/projects/${record.projectSlug}`}>
|
<Link to={`/projects/${record.projectSlug}`}>
|
||||||
{record.projectTitle}
|
{record.projectTitle}
|
||||||
@@ -57,7 +63,9 @@ export function publicRenderModelBase(
|
|||||||
topic: {
|
topic: {
|
||||||
id: `topic-${record.topicSlug}`,
|
id: `topic-${record.topicSlug}`,
|
||||||
label: record.topic,
|
label: record.topic,
|
||||||
publicPath: `/topics/${record.topicSlug}`,
|
// 공개 문서의 머리말이 실제로 그리는 주제 링크는 이 값이다 — 위의 breadcrumb 과 같은
|
||||||
|
// 이유로 탐색 필터를 가리킨다. 둘 중 하나만 고치면 화면에서는 그대로 404 로 간다.
|
||||||
|
publicPath: `/explore?topic=${encodeURIComponent(record.topicSlug)}`,
|
||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
id: `project-${record.projectSlug}`,
|
id: `project-${record.projectSlug}`,
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ function optionalString(value: unknown): string | undefined {
|
|||||||
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
|
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
|
||||||
* 시간순으로 합친다.
|
* 시간순으로 합친다.
|
||||||
*/
|
*/
|
||||||
|
const latestTypeLabels: Readonly<Record<string, string>> = {
|
||||||
|
PROJECT_ACTIVITY: "PROJECT ACTIVITY",
|
||||||
|
QUESTION: "OPEN QUESTION",
|
||||||
|
};
|
||||||
|
|
||||||
async function getLatestEntries(
|
async function getLatestEntries(
|
||||||
publicContent: PublicContentQueries,
|
publicContent: PublicContentQueries,
|
||||||
): Promise<LatestEntry[]> {
|
): Promise<LatestEntry[]> {
|
||||||
@@ -60,7 +65,8 @@ async function getLatestEntries(
|
|||||||
|
|
||||||
const recordTimeline: LatestEntry[] = records.map((entry) => ({
|
const recordTimeline: LatestEntry[] = records.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
typeLabel: entry.entryType === "PROJECT_ACTIVITY" ? "PROJECT ACTIVITY" : entry.entryType,
|
// 목록의 다른 이름들과 같은 자리에 놓이므로 표기도 같은 규칙을 쓴다 — 대문자에 공백.
|
||||||
|
typeLabel: latestTypeLabels[entry.entryType] ?? entry.entryType,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
summary: entry.summary,
|
summary: entry.summary,
|
||||||
date: dateLabel(entry.publishedAt),
|
date: dateLabel(entry.publishedAt),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
@@ -21,28 +19,33 @@ export function ProjectActivityPage() {
|
|||||||
const { project, activity } = view.data;
|
const { project, activity } = view.data;
|
||||||
if (!project) return <RegisteredNotFoundRoute />;
|
if (!project) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
|
/*
|
||||||
|
활동은 로그다 — 언제 무엇을 올렸는지만 적는다.
|
||||||
|
|
||||||
|
예전에는 각 줄에 그 기록으로 가는 링크가 있었다. 그러면 같은 글에 닿는 길이 둘이 되고,
|
||||||
|
읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다. 글을 읽는 자리는
|
||||||
|
기록 화면 하나로 둔다.
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell project-page">
|
<main id="main-content" className="shell project-page">
|
||||||
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
||||||
<ol className="project-activity-list">
|
{activity.length === 0 ? (
|
||||||
{activity.map((item) => (
|
<p className="public-empty-note">아직 이 프로젝트에서 공개한 기록이 없습니다.</p>
|
||||||
<li key={item.id}>
|
) : (
|
||||||
<article id={item.id}>
|
<ol className="project-activity-list">
|
||||||
<div>
|
{activity.map((item) => (
|
||||||
<span>{item.type}</span>
|
<li key={item.id}>
|
||||||
<time dateTime={item.dateTime}>{item.date}</time>
|
<article id={item.id}>
|
||||||
</div>
|
<div>
|
||||||
<h2>{item.title}</h2>
|
<span>{item.type}</span>
|
||||||
<p>{item.summary}</p>
|
<time dateTime={item.dateTime}>{item.date}</time>
|
||||||
<Link to={item.recordPath ?? item.path}>
|
</div>
|
||||||
{item.recordPath
|
<h2>{item.title}</h2>
|
||||||
? "연결된 공개 기록 읽기"
|
</article>
|
||||||
: "이 활동 위치 열기"}
|
</li>
|
||||||
</Link>
|
))}
|
||||||
</article>
|
</ol>
|
||||||
</li>
|
)}
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,11 @@ export function TopicPage() {
|
|||||||
const topic = topicConfig(params.slug);
|
const topic = topicConfig(params.slug);
|
||||||
// Hooks run unconditionally, so the unknown-topic case is handled by the
|
// Hooks run unconditionally, so the unknown-topic case is handled by the
|
||||||
// loader and the not-found route is chosen after it.
|
// loader and the not-found route is chosen after it.
|
||||||
|
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
const view = usePublicContent(
|
const view = usePublicContent(
|
||||||
["tech-log", "topic", topic?.title],
|
["tech-log", "topic", slug],
|
||||||
async (queries) =>
|
async (queries) =>
|
||||||
topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] },
|
topic ? { records: await queries.listRecords({ topic: slug }) } : { records: [] },
|
||||||
);
|
);
|
||||||
if (!topic) return <RegisteredNotFoundRoute />;
|
if (!topic) return <RegisteredNotFoundRoute />;
|
||||||
if (!view.ready) return view.fallback;
|
if (!view.ready) return view.fallback;
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
|||||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
/*
|
||||||
|
`IntersectionObserver` 가 없는 환경이 있다 — jsdom 이 그렇고, 오래된 브라우저도 그렇다.
|
||||||
|
없으면 "지금 읽는 절" 표시만 못 할 뿐 목차 자체는 쓸 수 있으므로, 없다고 문서를 통째로
|
||||||
|
못 그리게 두지 않는다.
|
||||||
|
|
||||||
|
한동안 이 컴포넌트는 픽스처 Case 하나에서만 쓰여 그 환경을 만난 적이 없었다. 모든 Case 가
|
||||||
|
목차를 받게 되면서 드러났다.
|
||||||
|
*/
|
||||||
|
if (typeof IntersectionObserver === "undefined") return undefined;
|
||||||
|
|
||||||
const elements = headings
|
const elements = headings
|
||||||
.map((heading) => document.getElementById(heading.id))
|
.map((heading) => document.getElementById(heading.id))
|
||||||
.filter((element): element is HTMLElement => Boolean(element));
|
.filter((element): element is HTMLElement => Boolean(element));
|
||||||
|
|||||||
@@ -435,7 +435,11 @@ function ProjectDecisionDocument({
|
|||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span>{model.status}</span>
|
<span>{model.status}</span>
|
||||||
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
{model.decidedOn ? (
|
||||||
|
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
||||||
|
) : (
|
||||||
|
<span>결정일 미정</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h2>{model.title}</h2>
|
<h2>{model.title}</h2>
|
||||||
<p><PlainText text={model.statement} /></p>
|
<p><PlainText text={model.statement} /></p>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function CaseFields({
|
|||||||
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
|
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
|
||||||
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
|
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
|
<div className="studio-asset-panel" role="group" aria-labelledby="studio-asset-panel-title">
|
||||||
<p className="studio-eyebrow">EVIDENCE</p>
|
<p className="studio-eyebrow">EVIDENCE</p>
|
||||||
<h3 id="studio-asset-panel-title">본문에 Asset 삽입</h3>
|
<h3 id="studio-asset-panel-title">본문에 Asset 삽입</h3>
|
||||||
<p>목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.</p>
|
<p>목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.</p>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { DocumentEditor } from "./document-editor.tsx";
|
|||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
||||||
import { slugFromName } from "./slug-from-name.ts";
|
import { slugFromName } from "./slug-from-name.ts";
|
||||||
|
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
@@ -176,6 +177,12 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
}
|
}
|
||||||
}, [begin, editor, setStatus, studio]);
|
}, [begin, editor, setStatus, studio]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
`save` 가 스스로 CLEAN·SAVING·CONFLICT 를 걸러 내므로 단축키는 늘 열어 둔다 — 저장할 것이
|
||||||
|
없을 때 눌러도 아무 일도 일어나지 않는다.
|
||||||
|
*/
|
||||||
|
useSaveShortcut(save);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다.
|
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
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 { Asset } from "../../../contracts/studio/contract.ts";
|
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
|
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
|
||||||
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
|
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
|
||||||
import { DocumentStatusRail } from "./document-status-rail.tsx";
|
import { DocumentStatusBar } from "./document-status-bar.tsx";
|
||||||
import { InstantPreview } from "./instant-preview.tsx";
|
import { InstantPreview } from "./instant-preview.tsx";
|
||||||
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
|
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
|
||||||
import { issuesOutside } from "./field-issues.tsx";
|
import { issuesOutside } from "./field-issues.tsx";
|
||||||
@@ -28,22 +26,6 @@ export function DocumentEditor({
|
|||||||
onAssetsObserved: (assets: readonly Asset[]) => void;
|
onAssetsObserved: (assets: readonly Asset[]) => void;
|
||||||
onAssetUploaded: (asset: Asset) => void;
|
onAssetUploaded: (asset: Asset) => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
|
|
||||||
const editTab = useRef<HTMLButtonElement>(null);
|
|
||||||
const previewTab = useRef<HTMLButtonElement>(null);
|
|
||||||
const selectTab = (next: "EDIT" | "PREVIEW") => {
|
|
||||||
setTab(next);
|
|
||||||
(next === "EDIT" ? editTab : previewTab).current?.focus();
|
|
||||||
};
|
|
||||||
const keyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
||||||
let next: "EDIT" | "PREVIEW" | null = null;
|
|
||||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") next = tab === "EDIT" ? "PREVIEW" : "EDIT";
|
|
||||||
if (event.key === "Home") next = "EDIT";
|
|
||||||
if (event.key === "End") next = "PREVIEW";
|
|
||||||
if (!next) return;
|
|
||||||
event.preventDefault();
|
|
||||||
selectTab(next);
|
|
||||||
};
|
|
||||||
const topics = catalog.filter(({ type }) => type === "TOPIC");
|
const topics = catalog.filter(({ type }) => type === "TOPIC");
|
||||||
const projects = catalog.filter(({ type }) => type === "PROJECT");
|
const projects = catalog.filter(({ type }) => type === "PROJECT");
|
||||||
const relations = catalog.filter(({ type }) => type === "RELATION");
|
const relations = catalog.filter(({ type }) => type === "RELATION");
|
||||||
@@ -66,35 +48,47 @@ export function DocumentEditor({
|
|||||||
: DECISION_FIELD_PATHS;
|
: DECISION_FIELD_PATHS;
|
||||||
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
|
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
편집과 미리보기를 한 화면에 나란히 둔다. 예전에는 탭이었고, 고친 것이 어떻게 보이는지
|
||||||
|
확인하려면 탭을 옮겨야 했다 — 옮기는 동안 편집 중이던 칸은 화면에서 사라졌고, 돌아오면
|
||||||
|
스크롤 위치도 잃었다. 두 패널을 동시에 두면 그 왕복이 통째로 없어진다.
|
||||||
|
|
||||||
|
탭을 없앴으므로 각 패널은 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을 가리켜
|
||||||
|
landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를 대신한다.
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<div className="studio-editor-page">
|
<div className="studio-editor-page">
|
||||||
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
|
<div className="studio-editor-split">
|
||||||
<button id="studio-edit-tab" ref={editTab} type="button" role="tab" aria-selected={tab === "EDIT"} aria-controls="studio-edit-panel" tabIndex={tab === "EDIT" ? 0 : -1} onClick={() => selectTab("EDIT")} onKeyDown={keyDown}>편집</button>
|
<section className="studio-editor-workspace" aria-labelledby="studio-edit-title">
|
||||||
<button id="studio-preview-tab" ref={previewTab} type="button" role="tab" aria-selected={tab === "PREVIEW"} aria-controls="studio-preview-panel" tabIndex={tab === "PREVIEW" ? 0 : -1} onClick={() => selectTab("PREVIEW")} onKeyDown={keyDown}>즉시 미리보기</button>
|
<header className="studio-editor-heading">
|
||||||
</div>
|
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
|
||||||
<div className="studio-editor-layout">
|
<h1 id="studio-edit-title">문서 편집</h1>
|
||||||
<div className="studio-editor-workspace">
|
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
||||||
<div id="studio-edit-panel" role="tabpanel" aria-labelledby="studio-edit-tab" hidden={tab !== "EDIT"}>
|
</header>
|
||||||
<header className="studio-editor-heading">
|
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
|
||||||
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
|
{controller.draft.kind === "CASE"
|
||||||
<h1>문서 편집</h1>
|
? <CaseFields draft={controller.draft} issues={issues} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
|
||||||
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
: controller.draft.kind === "REFERENCE"
|
||||||
</header>
|
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
|
||||||
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
|
: controller.draft.kind === "QUESTION"
|
||||||
{controller.draft.kind === "CASE"
|
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
|
||||||
? <CaseFields draft={controller.draft} issues={issues} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
|
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
|
||||||
: controller.draft.kind === "REFERENCE"
|
</section>
|
||||||
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
|
{/*
|
||||||
: controller.draft.kind === "QUESTION"
|
미리보기는 편집 칸보다 훨씬 짧을 수도, 길 수도 있다. 그대로 두면 본문을 스크롤하는
|
||||||
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
|
동안 화면 밖으로 나가 버리므로 붙잡아 두고 자기 높이 안에서 따로 스크롤한다.
|
||||||
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
|
*/}
|
||||||
</div>
|
<section className="studio-editor-preview" aria-labelledby="studio-preview-title">
|
||||||
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
|
<header className="studio-editor-preview__heading">
|
||||||
|
<p className="studio-eyebrow">LIVE</p>
|
||||||
|
<h2 id="studio-preview-title">즉시 미리보기</h2>
|
||||||
|
</header>
|
||||||
|
<div className="studio-editor-preview__body">
|
||||||
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<DocumentStatusRail controller={controller} unplacedIssues={unplaced} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<DocumentStatusBar controller={controller} unplacedIssues={unplaced} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
|
import type { FieldIssue } from "./field-issues.tsx";
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
CLEAN: "저장됨",
|
||||||
|
DIRTY: "저장되지 않음",
|
||||||
|
SAVING: "저장 중…",
|
||||||
|
CONFLICT: "저장 충돌",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const kindLabels = {
|
||||||
|
CASE: "CASE",
|
||||||
|
REFERENCE: "REFERENCE",
|
||||||
|
QUESTION: "QUESTION",
|
||||||
|
PROJECT_DECISION: "Decision",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 상태와 저장·게시를 화면 아래에 고정한다.
|
||||||
|
*
|
||||||
|
* 예전에는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께 쓸 가로 폭을 가져갔고,
|
||||||
|
* 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다. 아래에 고정하면 폭을 돌려주면서
|
||||||
|
* 스크롤 위치와 무관하게 저장·게시에 손이 닿는다 — 릴리즈 편집에서 버튼이 아래로 밀려나던
|
||||||
|
* 것과 같은 문제를 여기서는 만들지 않는다.
|
||||||
|
*
|
||||||
|
* `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이 묶음이 무엇인지가
|
||||||
|
* 바뀐 것은 아니고, 보조 기술이 이 영역을 부르던 이름도 그대로여야 한다.
|
||||||
|
*/
|
||||||
|
export function DocumentStatusBar({
|
||||||
|
controller,
|
||||||
|
unplacedIssues,
|
||||||
|
}: {
|
||||||
|
controller: DocumentEditorController;
|
||||||
|
/**
|
||||||
|
* 어느 칸에도 붙지 못한 지적. 화면에 없는 칸을 가리키는 것들이며, 여기 남기지 않으면 이유를
|
||||||
|
* 말해 주지 않는 실패만 남는다.
|
||||||
|
*/
|
||||||
|
unplacedIssues: readonly FieldIssue[];
|
||||||
|
}) {
|
||||||
|
const busy = controller.status === "SAVING" || controller.publishing;
|
||||||
|
return (
|
||||||
|
<aside className="studio-document-status-bar" aria-labelledby="studio-document-status-title">
|
||||||
|
<h2 id="studio-document-status-title" className="studio-visually-hidden">작업 상태</h2>
|
||||||
|
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||||
|
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||||
|
{/*
|
||||||
|
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||||
|
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||||
|
두 번 말하게 된다.
|
||||||
|
*/}
|
||||||
|
{controller.status === "CONFLICT" ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
||||||
|
) : controller.saveError ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
||||||
|
) : controller.publishError ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="studio-document-status-bar__note">불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.</p>
|
||||||
|
)}
|
||||||
|
<div className="studio-document-status-bar__actions">
|
||||||
|
<button type="button" title="Ctrl+S" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||||
|
{/*
|
||||||
|
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
|
||||||
|
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
|
||||||
|
확인했다는 목록을 요구하기 때문이다(`PublishStudioDocumentUseCase`).
|
||||||
|
|
||||||
|
그 셋은 작성자에게 물어볼 것이 없다. 검증은 서버가 판정하고, 미리보기는 그 판정으로부터
|
||||||
|
만들어지며, 경고는 게시를 막지 않는다. 그래서 세 요청을 이 버튼 뒤로 옮겼다. 계약도
|
||||||
|
백엔드도 그대로다 — 사라진 것은 작성자가 밟던 화면이지 서버가 지키던 불변식이 아니다.
|
||||||
|
*/}
|
||||||
|
<button
|
||||||
|
className="studio-primary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void controller.publish(); }}
|
||||||
|
disabled={busy || controller.status === "CONFLICT"}
|
||||||
|
>
|
||||||
|
{controller.publishing ? "게시 중…" : "게시"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{unplacedIssues.length ? (
|
||||||
|
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
|
||||||
|
{unplacedIssues.map((issue) => (
|
||||||
|
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
|
||||||
|
{issue.message} <code>{issue.path}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
|
||||||
import type { FieldIssue } from "./field-issues.tsx";
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
CLEAN: "저장됨",
|
|
||||||
DIRTY: "저장되지 않음",
|
|
||||||
SAVING: "저장 중…",
|
|
||||||
CONFLICT: "저장 충돌",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const kindLabels = {
|
|
||||||
CASE: "CASE",
|
|
||||||
REFERENCE: "REFERENCE",
|
|
||||||
QUESTION: "QUESTION",
|
|
||||||
PROJECT_DECISION: "Decision",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function DocumentStatusRail({
|
|
||||||
controller,
|
|
||||||
unplacedIssues,
|
|
||||||
}: {
|
|
||||||
controller: DocumentEditorController;
|
|
||||||
/**
|
|
||||||
* 어느 칸에도 붙지 못한 지적. 화면에 없는 칸을 가리키는 것들이며, 여기 남기지 않으면 이유를
|
|
||||||
* 말해 주지 않는 실패만 남는다.
|
|
||||||
*/
|
|
||||||
unplacedIssues: readonly FieldIssue[];
|
|
||||||
}) {
|
|
||||||
const busy = controller.status === "SAVING" || controller.publishing;
|
|
||||||
return (
|
|
||||||
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
|
|
||||||
<p className="studio-eyebrow">WORKING COPY</p>
|
|
||||||
<h2 id="studio-document-status-title">작업 상태</h2>
|
|
||||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
|
||||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
|
||||||
<button type="button" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
|
||||||
{/*
|
|
||||||
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
|
|
||||||
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
|
|
||||||
확인했다는 목록을 요구하기 때문이다(`PublishStudioDocumentUseCase`).
|
|
||||||
|
|
||||||
그 셋은 작성자에게 물어볼 것이 없다. 검증은 서버가 판정하고, 미리보기는 그 판정으로부터
|
|
||||||
만들어지며, 경고는 게시를 막지 않는다. 그래서 세 요청을 이 버튼 뒤로 옮겼다. 계약도
|
|
||||||
백엔드도 그대로다 — 사라진 것은 작성자가 밟던 화면이지 서버가 지키던 불변식이 아니다.
|
|
||||||
*/}
|
|
||||||
<button
|
|
||||||
className="studio-primary-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => { void controller.publish(); }}
|
|
||||||
disabled={busy || controller.status === "CONFLICT"}
|
|
||||||
>
|
|
||||||
{controller.publishing ? "게시 중…" : "게시"}
|
|
||||||
</button>
|
|
||||||
{/*
|
|
||||||
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
|
||||||
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
|
||||||
두 번 말하게 된다.
|
|
||||||
*/}
|
|
||||||
{controller.status === "CONFLICT" ? (
|
|
||||||
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
|
||||||
) : controller.saveError ? (
|
|
||||||
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
|
||||||
) : controller.publishError ? (
|
|
||||||
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
|
||||||
) : (
|
|
||||||
<p>불완전한 초안도 저장할 수 있습니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.</p>
|
|
||||||
)}
|
|
||||||
{unplacedIssues.length ? (
|
|
||||||
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
|
|
||||||
{unplacedIssues.map((issue) => (
|
|
||||||
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
|
|
||||||
{issue.message} <code>{issue.path}</code>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -41,12 +41,19 @@ export function HomeFocusEditor() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void Promise.all([
|
/*
|
||||||
managementGateway.getHomeFocus(),
|
try/catch 로 감싼다. `Promise.all([gateway.foo()])` 은 `foo` 가 거절하는 것만 잡는다 —
|
||||||
managementGateway.listProjects(0, 50),
|
호출 자체가 동기적으로 던지면(예: 그 표면을 갖추지 않은 게이트웨이) 배열을 만드는 중에
|
||||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
터지므로 rejection handler 를 지나지 못한다. 그러면 이 섹션이 아니라 대시보드 전체가
|
||||||
]).then(
|
빈 화면이 된다. 한 칸의 실패가 화면을 통째로 날리지 않게 한다.
|
||||||
([current, projectPage, catalog]) => {
|
*/
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [current, projectPage, catalog] = await Promise.all([
|
||||||
|
managementGateway.getHomeFocus(),
|
||||||
|
managementGateway.listProjects(0, 50),
|
||||||
|
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||||
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setFocus(current);
|
setFocus(current);
|
||||||
setProjects(projectPage.items ?? []);
|
setProjects(projectPage.items ?? []);
|
||||||
@@ -55,11 +62,10 @@ export function HomeFocusEditor() {
|
|||||||
setQuestionId(current.openQuestionId ?? "");
|
setQuestionId(current.openQuestionId ?? "");
|
||||||
setDecisionId(current.recentDecisionId ?? "");
|
setDecisionId(current.recentDecisionId ?? "");
|
||||||
setError("");
|
setError("");
|
||||||
},
|
} catch {
|
||||||
() => {
|
|
||||||
if (!cancelled) setError("홈 설정을 불러오지 못했습니다.");
|
if (!cancelled) setError("홈 설정을 불러오지 못했습니다.");
|
||||||
},
|
}
|
||||||
);
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { managementFailureMessage } from "../../../application/ports/management-
|
|||||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
import { slugFromName } from "./slug-from-name.ts";
|
import { slugFromName } from "./slug-from-name.ts";
|
||||||
|
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
@@ -114,14 +115,6 @@ export function ProjectEditor() {
|
|||||||
const [records, setRecords] = useState<CatalogEntry[]>([]);
|
const [records, setRecords] = useState<CatalogEntry[]>([]);
|
||||||
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
|
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
|
||||||
const [draft, setDraft] = useState<Draft | null>(null);
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
const [newActivity, setNewActivity] = useState({
|
|
||||||
activityType: "MILESTONE_REACHED",
|
|
||||||
title: "",
|
|
||||||
summary: "",
|
|
||||||
visibility: "PUBLIC",
|
|
||||||
relatedId: "",
|
|
||||||
occurredAt: toLocalInput(new Date().toISOString()),
|
|
||||||
});
|
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [generation, setGeneration] = useState(0);
|
const [generation, setGeneration] = useState(0);
|
||||||
@@ -130,13 +123,15 @@ export function ProjectEditor() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void Promise.all([
|
// 동기적으로 던지는 호출도 잡는다 — `home-focus-editor` 와 같은 이유다.
|
||||||
managementGateway.getProject(projectId),
|
void (async () => {
|
||||||
managementGateway.listProjectActivities(projectId),
|
try {
|
||||||
managementGateway.listTopics(),
|
const [loaded, activityList, topicList, catalog] = await Promise.all([
|
||||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
managementGateway.getProject(projectId),
|
||||||
]).then(
|
managementGateway.listProjectActivities(projectId),
|
||||||
([loaded, activityList, topicList, catalog]) => {
|
managementGateway.listTopics(),
|
||||||
|
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||||
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setProject(loaded);
|
setProject(loaded);
|
||||||
setDraft(toDraft(loaded));
|
setDraft(toDraft(loaded));
|
||||||
@@ -144,11 +139,10 @@ export function ProjectEditor() {
|
|||||||
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
|
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
|
||||||
setRecords(catalog.items ?? []);
|
setRecords(catalog.items ?? []);
|
||||||
setError("");
|
setError("");
|
||||||
},
|
} catch {
|
||||||
() => {
|
|
||||||
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
|
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
|
||||||
},
|
}
|
||||||
);
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
@@ -218,87 +212,11 @@ export function ProjectEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addActivity = async (event: FormEvent) => {
|
// 폼 제출과 같은 일을 하므로 submit 이벤트를 만들어 보낸다 — 검증·기본값 처리가 한 곳에 남는다.
|
||||||
event.preventDefault();
|
useSaveShortcut(
|
||||||
if (pending || !project) return;
|
() => void save({ preventDefault: () => {} } as FormEvent),
|
||||||
const title = newActivity.title.trim();
|
Boolean(project && draft) && !pending,
|
||||||
if (!title) {
|
);
|
||||||
setError("활동에는 제목이 필요합니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPending(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const related = records.find((entry) => entry.id === newActivity.relatedId);
|
|
||||||
await managementGateway.createProjectActivity(project.id, {
|
|
||||||
expectedProjectVersion: project.version,
|
|
||||||
activityType: newActivity.activityType,
|
|
||||||
title,
|
|
||||||
summary: newActivity.summary,
|
|
||||||
visibility: newActivity.visibility === "PUBLIC" ? "PUBLIC" : "PRIVATE",
|
|
||||||
/*
|
|
||||||
공개 화면의 "이 활동 위치 열기" 는 이 자리를 따라간다. 비워 두면 그 링크는 갈 곳이 없다 —
|
|
||||||
예전에는 고를 방법 자체가 없어 모든 활동의 그 버튼이 눌리지 않았다.
|
|
||||||
*/
|
|
||||||
relatedResourceType: related?.kind ?? undefined,
|
|
||||||
relatedResourceId: related?.id ?? undefined,
|
|
||||||
occurredAt: fromLocalInput(newActivity.occurredAt),
|
|
||||||
});
|
|
||||||
setNewActivity({
|
|
||||||
activityType: "MILESTONE_REACHED",
|
|
||||||
title: "",
|
|
||||||
summary: "",
|
|
||||||
visibility: "PUBLIC",
|
|
||||||
relatedId: "",
|
|
||||||
occurredAt: toLocalInput(new Date().toISOString()),
|
|
||||||
});
|
|
||||||
setRequestAnnouncement(`활동 ${title} 을(를) 추가했습니다.`);
|
|
||||||
reload();
|
|
||||||
} catch (failure) {
|
|
||||||
setError(managementFailureMessage(failure, "활동을 추가하지 못했습니다."));
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveActivity = async (
|
|
||||||
activity: ProjectActivityResponse,
|
|
||||||
changes: Partial<Pick<ProjectActivityResponse, "title" | "summary" | "visibility" | "occurredAt">>,
|
|
||||||
) => {
|
|
||||||
if (pending || !project) return;
|
|
||||||
setPending(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const saved = await managementGateway.updateProjectActivity(project.id, activity.id, {
|
|
||||||
expectedVersion: activity.version,
|
|
||||||
title: changes.title ?? activity.title,
|
|
||||||
summary: changes.summary ?? activity.summary ?? "",
|
|
||||||
visibility: (changes.visibility ?? activity.visibility) === "PUBLIC" ? "PUBLIC" : "PRIVATE",
|
|
||||||
occurredAt: changes.occurredAt ?? activity.occurredAt,
|
|
||||||
});
|
|
||||||
setActivities((current) => current.map((item) => (item.id === saved.id ? saved : item)));
|
|
||||||
setRequestAnnouncement("활동을 저장했습니다.");
|
|
||||||
} catch (failure) {
|
|
||||||
setError(managementFailureMessage(failure, "활동을 저장하지 못했습니다."));
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeActivity = async (activity: ProjectActivityResponse) => {
|
|
||||||
if (pending || !project) return;
|
|
||||||
setPending(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
await managementGateway.deleteProjectActivity(project.id, activity.id, activity.version);
|
|
||||||
setActivities((current) => current.filter((item) => item.id !== activity.id));
|
|
||||||
setRequestAnnouncement(`활동 ${activity.title} 을(를) 삭제했습니다.`);
|
|
||||||
} catch (failure) {
|
|
||||||
setError(managementFailureMessage(failure, "활동을 삭제하지 못했습니다."));
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const published = project ? project.targetVisibility !== "PRIVATE" : false;
|
const published = project ? project.targetVisibility !== "PRIVATE" : false;
|
||||||
|
|
||||||
@@ -445,9 +363,19 @@ export function ProjectEditor() {
|
|||||||
>
|
>
|
||||||
<div className="studio-editor-section-heading">
|
<div className="studio-editor-section-heading">
|
||||||
<p className="studio-eyebrow">NARRATIVE</p>
|
<p className="studio-eyebrow">NARRATIVE</p>
|
||||||
<h2 id="studio-project-narrative-title">맥락</h2>
|
<h2 id="studio-project-narrative-title">목적</h2>
|
||||||
<p>프로젝트 상세 화면의 본문입니다. 마크다운을 씁니다.</p>
|
<p>
|
||||||
|
프로젝트 화면의 “프로젝트가 지키는 기준”이 이 글을 그립니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/*
|
||||||
|
경계와 시스템 개요 칸이 여기 함께 있었다. 저장은 되지만 공개 화면 어디에도
|
||||||
|
나오지 않는다 — 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 쓴 글이
|
||||||
|
어딘가 실릴 것처럼 보이는 칸이었고, 실제로는 아무도 읽지 않았다.
|
||||||
|
|
||||||
|
열의 데이터는 남겨 둔다(계약이 요구하고, 이미 적어 둔 글이 있을 수 있다). 저장할
|
||||||
|
때 서버가 가진 값을 그대로 돌려보내므로 지워지지 않는다.
|
||||||
|
*/}
|
||||||
<div className="studio-field-grid">
|
<div className="studio-field-grid">
|
||||||
<label className="studio-field studio-field--wide">
|
<label className="studio-field studio-field--wide">
|
||||||
<span>목적</span>
|
<span>목적</span>
|
||||||
@@ -456,30 +384,13 @@ export function ProjectEditor() {
|
|||||||
value={draft.purposeMarkdown}
|
value={draft.purposeMarkdown}
|
||||||
onChange={(event) => update({ purposeMarkdown: event.currentTarget.value })}
|
onChange={(event) => update({ purposeMarkdown: event.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
<small>마크다운을 씁니다.</small>
|
||||||
<label className="studio-field studio-field--wide">
|
|
||||||
<span>경계</span>
|
|
||||||
<textarea
|
|
||||||
className="studio-markdown"
|
|
||||||
value={draft.boundaryMarkdown}
|
|
||||||
onChange={(event) => update({ boundaryMarkdown: event.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="studio-field studio-field--wide">
|
|
||||||
<span>시스템 개요</span>
|
|
||||||
<textarea
|
|
||||||
className="studio-markdown"
|
|
||||||
value={draft.systemOverviewMarkdown}
|
|
||||||
onChange={(event) =>
|
|
||||||
update({ systemOverviewMarkdown: event.currentTarget.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="studio-editor-footer">
|
<div className="studio-editor-footer">
|
||||||
<button className="studio-primary-button" type="submit" disabled={pending}>
|
<button className="studio-primary-button" type="submit" title="Ctrl+S" disabled={pending}>
|
||||||
{pending ? "저장하는 중" : "저장"}
|
{pending ? "저장하는 중" : "저장"}
|
||||||
</button>
|
</button>
|
||||||
<p className="studio-field-note">
|
<p className="studio-field-note">
|
||||||
@@ -490,117 +401,22 @@ export function ProjectEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section className="studio-editor-section" aria-labelledby="studio-project-activity-title">
|
<section
|
||||||
|
className="studio-editor-section"
|
||||||
|
aria-labelledby="studio-project-activity-title"
|
||||||
|
>
|
||||||
<div className="studio-editor-section-heading">
|
<div className="studio-editor-section-heading">
|
||||||
<p className="studio-eyebrow">ACTIVITY</p>
|
<p className="studio-eyebrow">ACTIVITY</p>
|
||||||
<h2 id="studio-project-activity-title">활동</h2>
|
<h2 id="studio-project-activity-title">활동</h2>
|
||||||
<p>공개로 둔 줄만 프로젝트 화면의 타임라인에 나옵니다.</p>
|
<p>
|
||||||
|
기록을 게시하면 이 목록에 한 줄이 자동으로 남습니다. 손으로 적는 자리가 아닙니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="studio-field-grid" onSubmit={addActivity}>
|
|
||||||
<label className="studio-field">
|
|
||||||
<span>유형</span>
|
|
||||||
<select
|
|
||||||
value={newActivity.activityType}
|
|
||||||
onChange={(event) => {
|
|
||||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
|
||||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
setNewActivity((current) => ({ ...current, activityType: value }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ACTIVITY_TYPES.map((type) => (
|
|
||||||
<option key={type.value} value={type.value}>
|
|
||||||
{type.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="studio-field">
|
|
||||||
<span>일시</span>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={newActivity.occurredAt}
|
|
||||||
onChange={(event) => {
|
|
||||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
|
||||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
setNewActivity((current) => ({ ...current, occurredAt: value }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="studio-field studio-field--wide">
|
|
||||||
<span>제목</span>
|
|
||||||
<input
|
|
||||||
value={newActivity.title}
|
|
||||||
maxLength={200}
|
|
||||||
onChange={(event) => {
|
|
||||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
|
||||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
setNewActivity((current) => ({ ...current, title: value }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="studio-field studio-field--wide">
|
|
||||||
<span>요약</span>
|
|
||||||
<input
|
|
||||||
value={newActivity.summary}
|
|
||||||
maxLength={300}
|
|
||||||
onChange={(event) => {
|
|
||||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
|
||||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
setNewActivity((current) => ({ ...current, summary: value }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="studio-field">
|
|
||||||
<span>연결할 기록</span>
|
|
||||||
<select
|
|
||||||
value={newActivity.relatedId}
|
|
||||||
onChange={(event) => {
|
|
||||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
|
||||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
setNewActivity((current) => ({ ...current, relatedId: value }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">연결하지 않음</option>
|
|
||||||
{records.map((entry) => (
|
|
||||||
<option key={entry.id} value={entry.id}>
|
|
||||||
{entry.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<small>
|
|
||||||
공개 화면의 “이 활동 위치 열기”가 이 기록으로 갑니다.
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
<label className="studio-field">
|
|
||||||
<span>노출</span>
|
|
||||||
<select
|
|
||||||
value={newActivity.visibility}
|
|
||||||
onChange={(event) => {
|
|
||||||
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
|
|
||||||
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
setNewActivity((current) => ({ ...current, visibility: value }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="PUBLIC">공개</option>
|
|
||||||
<option value="PRIVATE">비공개</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div className="studio-field studio-field--wide">
|
|
||||||
<button className="studio-secondary-button" type="submit" disabled={pending}>
|
|
||||||
활동 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{activities.length === 0 ? (
|
{activities.length === 0 ? (
|
||||||
<p className="studio-empty-inline">아직 활동이 없습니다.</p>
|
<p className="studio-empty-inline">
|
||||||
|
아직 이 프로젝트에서 게시한 기록이 없습니다.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="studio-document-list">
|
<div className="studio-document-list">
|
||||||
{activities.map((activity) => (
|
{activities.map((activity) => (
|
||||||
@@ -609,61 +425,27 @@ export function ProjectEditor() {
|
|||||||
<div className="studio-document-title">
|
<div className="studio-document-title">
|
||||||
<h2>{activity.title}</h2>
|
<h2>{activity.title}</h2>
|
||||||
<p>
|
<p>
|
||||||
{activity.summary || "요약 없음"}
|
{activity.visibility === "PUBLIC"
|
||||||
{activity.relatedResourceId ? " · 기록 연결됨" : " · 연결된 기록 없음"}
|
? "공개 타임라인에 나옵니다"
|
||||||
|
: "비공개 — 타임라인에 나오지 않습니다"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
<div>
|
<div>
|
||||||
<dt>일시</dt>
|
<dt>게시</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<input
|
<time dateTime={activity.occurredAt}>
|
||||||
type="datetime-local"
|
{new Date(activity.occurredAt).toLocaleDateString("ko-KR", {
|
||||||
aria-label={`${activity.title} 일시`}
|
timeZone: "Asia/Seoul",
|
||||||
defaultValue={toLocalInput(activity.occurredAt)}
|
})}
|
||||||
onBlur={(event) =>
|
</time>
|
||||||
void saveActivity(activity, {
|
|
||||||
occurredAt: fromLocalInput(event.currentTarget.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>노출</dt>
|
|
||||||
<dd>
|
|
||||||
<select
|
|
||||||
aria-label={`${activity.title} 노출`}
|
|
||||||
value={activity.visibility}
|
|
||||||
onChange={(event) =>
|
|
||||||
void saveActivity(activity, {
|
|
||||||
visibility: event.currentTarget.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="PUBLIC">공개</option>
|
|
||||||
<option value="PRIVATE">비공개</option>
|
|
||||||
</select>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>기록</dt>
|
<dt>기록</dt>
|
||||||
<dd>{activity.origin === "MANUAL" ? "손으로 적음" : "자동 기록"}</dd>
|
<dd>{activity.origin === "AUTO" ? "게시가 남김" : "손으로 적음"}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<button
|
|
||||||
className="studio-secondary-button"
|
|
||||||
type="button"
|
|
||||||
disabled={pending || activity.origin !== "MANUAL"}
|
|
||||||
title={
|
|
||||||
activity.origin === "MANUAL"
|
|
||||||
? undefined
|
|
||||||
: "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다."
|
|
||||||
}
|
|
||||||
onClick={() => void removeActivity(activity)}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,6 +193,8 @@ export function ReleaseEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useSaveShortcut(save, draft !== null && !pending);
|
||||||
|
|
||||||
const toggleChangeType = (value: string) => {
|
const toggleChangeType = (value: string) => {
|
||||||
if (draft === null) return;
|
if (draft === null) return;
|
||||||
update({
|
update({
|
||||||
@@ -309,6 +312,7 @@ export function ReleaseEditor() {
|
|||||||
<button
|
<button
|
||||||
className="studio-primary-button"
|
className="studio-primary-button"
|
||||||
type="button"
|
type="button"
|
||||||
|
title="Ctrl+S"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ctrl+S(맥에서는 Cmd+S)로 저장한다.
|
||||||
|
*
|
||||||
|
* <p>브라우저의 기본 동작은 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장 대화상자가
|
||||||
|
* 뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다. 그래서 기본 동작을 막고 이
|
||||||
|
* 화면의 저장으로 돌린다.
|
||||||
|
*
|
||||||
|
* <p>`enabled` 가 거짓이면 아무것도 하지 않는다. 저장할 것이 없거나 이미 저장 중일 때 단축키가 요청을
|
||||||
|
* 겹쳐 보내지 않게 하는 것은 호출자의 몫이다 — 화면마다 "지금 저장할 수 있는가"의 뜻이 다르다.
|
||||||
|
*/
|
||||||
|
export function useSaveShortcut(
|
||||||
|
save: () => void | Promise<void>,
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "s" && event.key !== "S") return;
|
||||||
|
if (!(event.ctrlKey || event.metaKey)) return;
|
||||||
|
// Ctrl+Shift+S 는 다른 뜻으로 쓰는 곳이 있어 가로채지 않는다.
|
||||||
|
if (event.shiftKey || event.altKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (enabled) void save();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [enabled, save]);
|
||||||
|
}
|
||||||
@@ -1,15 +1,37 @@
|
|||||||
.studio-app .studio-editor-page,
|
.studio-app .studio-editor-page,
|
||||||
.studio-app .studio-editor-layout,
|
.studio-app .studio-editor-split,
|
||||||
.studio-app .studio-editor-workspace,
|
.studio-app .studio-editor-workspace,
|
||||||
.studio-app .studio-editor-workspace > [role="tabpanel"] { min-width: 0; }
|
.studio-app .studio-editor-preview { min-width: 0; }
|
||||||
|
|
||||||
.studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); }
|
.studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); }
|
||||||
.studio-app .studio-editor-tabs { display: flex; gap: 28px; border-bottom: 1px solid var(--line-strong); }
|
/*
|
||||||
.studio-app .studio-editor-tabs button { position: relative; min-width: 112px; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 650; }
|
편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
|
||||||
.studio-app .studio-editor-tabs button[aria-selected="true"] { color: var(--ink); }
|
보려면 편집하던 자리를 화면에서 치워야 했다.
|
||||||
.studio-app .studio-editor-tabs button[aria-selected="true"]::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--signal); content: ""; }
|
*/
|
||||||
|
/*
|
||||||
|
이 화면만 공용 컨테이너보다 넓다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께
|
||||||
|
쓰는데, 여기서는 그 폭에 본문 두 벌이 들어가야 해서 한 칸이 566px 로 좁아졌다. 헤더와
|
||||||
|
다른 화면은 그대로 두고 편집기가 놓인 경우에만 넓힌다.
|
||||||
|
*/
|
||||||
|
.studio-app:has(.studio-editor-split) .studio-header-inner,
|
||||||
|
.studio-app:has(.studio-editor-split) .studio-main { width: min(1600px, calc(100% - 80px)); }
|
||||||
|
|
||||||
.studio-app .studio-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 280px); gap: 64px; align-items: start; padding-top: 40px; }
|
/*
|
||||||
|
편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
|
||||||
|
보려면 편집하던 자리를 화면에서 치워야 했다.
|
||||||
|
|
||||||
|
두 칸 모두 페이지 스크롤을 그대로 탄다. 미리보기를 붙박이로 두고 자체 스크롤을 주었더니
|
||||||
|
편집기를 내려도 미리보기는 제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가
|
||||||
|
둘을 같이 보는 것인데 움직임이 갈라지면 그 이점이 없다.
|
||||||
|
*/
|
||||||
|
.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; padding-top: 40px; }
|
||||||
|
/* 반쪽 폭에는 62px 짜리 제목이 앉을 자리가 없다. */
|
||||||
|
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(28px, 3vw, 40px); }
|
||||||
|
|
||||||
|
.studio-app .studio-editor-preview { display: flex; flex-direction: column; padding-left: 40px; border-left: 1px solid var(--line); }
|
||||||
|
.studio-app .studio-editor-preview__heading { padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
||||||
|
.studio-app .studio-editor-preview__heading .studio-eyebrow { margin-bottom: 8px; }
|
||||||
|
.studio-app .studio-editor-preview__body { padding-top: 20px; }
|
||||||
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
|
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
|
||||||
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
|
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
|
||||||
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
|
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||||
@@ -17,7 +39,7 @@
|
|||||||
.studio-app .studio-editor-section-heading { margin-bottom: 24px; }
|
.studio-app .studio-editor-section-heading { margin-bottom: 24px; }
|
||||||
.studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; }
|
.studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; }
|
||||||
.studio-app .studio-editor-section-heading h2,
|
.studio-app .studio-editor-section-heading h2,
|
||||||
.studio-app .studio-document-status-rail h2,
|
.studio-app .studio-editor-preview__heading h2,
|
||||||
.studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; }
|
.studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; }
|
||||||
|
|
||||||
.studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
.studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
||||||
@@ -71,27 +93,38 @@
|
|||||||
.studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
.studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.studio-app .studio-item-actions button,
|
.studio-app .studio-item-actions button,
|
||||||
.studio-app .studio-add-item,
|
.studio-app .studio-add-item,
|
||||||
.studio-app .studio-document-status-rail button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
.studio-app .studio-document-status-bar button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
||||||
.studio-app .studio-item-actions button:disabled,
|
.studio-app .studio-item-actions button:disabled,
|
||||||
.studio-app .studio-add-item:disabled { opacity: 0.45; }
|
.studio-app .studio-add-item:disabled { opacity: 0.45; }
|
||||||
.studio-app .studio-add-item { margin-top: 12px; }
|
.studio-app .studio-add-item { margin-top: 12px; }
|
||||||
.studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
.studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
||||||
.studio-app .studio-resolution-fields legend { grid-column: 1 / -1; }
|
.studio-app .studio-resolution-fields legend { grid-column: 1 / -1; }
|
||||||
|
|
||||||
.studio-app .studio-document-status-rail { position: sticky; top: 28px; min-width: 0; padding: 24px 0; border-top: 1px solid var(--line-strong); border-bottom: 1px solid var(--line-strong); }
|
/*
|
||||||
.studio-app .studio-document-status-rail .studio-eyebrow { margin-bottom: 8px; }
|
작업 상태와 저장·게시를 화면 아래에 붙여 둔다. 문서가 길어져도 스크롤 위치와 무관하게 손이
|
||||||
.studio-app .studio-editor-status { margin: 20px 0; font-weight: 700; }
|
닿는다. 배경을 불투명하게 두는 것은 장식이 아니라 필요다 — 밑으로 지나가는 본문이 비치면
|
||||||
|
상태 글자를 읽을 수 없다.
|
||||||
|
*/
|
||||||
|
.studio-app .studio-document-status-bar { position: sticky; bottom: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 28px; min-width: 0; margin-top: 48px; padding: 16px 0; border-top: 1px solid var(--line-strong); background: var(--paper); }
|
||||||
|
.studio-app .studio-editor-status { margin: 0; font-weight: 700; }
|
||||||
.studio-app .studio-editor-status--dirty,
|
.studio-app .studio-editor-status--dirty,
|
||||||
.studio-app .studio-editor-status--conflict,
|
.studio-app .studio-editor-status--conflict,
|
||||||
.studio-app .studio-editor-conflict { color: #8f2f27; }
|
.studio-app .studio-editor-conflict { color: #8f2f27; }
|
||||||
.studio-app .studio-editor-status--clean { color: #166748; }
|
.studio-app .studio-editor-status--clean { color: #166748; }
|
||||||
.studio-app .studio-document-status-rail dl { display: grid; gap: 12px; margin: 0 0 20px; }
|
.studio-app .studio-document-status-bar dl { display: flex; gap: 20px; margin: 0; }
|
||||||
.studio-app .studio-document-status-rail dl div { display: flex; justify-content: space-between; gap: 16px; }
|
.studio-app .studio-document-status-bar dl div { display: flex; align-items: baseline; gap: 8px; }
|
||||||
.studio-app .studio-document-status-rail dt { color: var(--muted); font-size: 12px; }
|
.studio-app .studio-document-status-bar dt { color: var(--muted); font-size: 12px; }
|
||||||
.studio-app .studio-document-status-rail dd { margin: 0; font-size: 13px; }
|
.studio-app .studio-document-status-bar dd { margin: 0; font-size: 13px; }
|
||||||
.studio-app .studio-document-status-rail button { width: 100%; border-color: var(--signal); background: var(--signal); color: #fff; }
|
.studio-app .studio-document-status-bar__note { flex: 1 1 240px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
.studio-app .studio-document-status-rail button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
|
/* 색은 위 묶음이 정한다 — 여기서 다시 칠하면 실패가 안내문처럼 보인다. */
|
||||||
.studio-app .studio-document-status-rail > p:last-child { margin: 16px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
.studio-app .studio-document-status-bar .studio-editor-conflict { flex: 1 1 240px; margin: 0; font-size: 12px; line-height: 1.6; }
|
||||||
|
.studio-app .studio-document-status-bar__actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||||
|
/* `.studio-primary-button` 은 세로로 쌓이는 자리에 맞춘 바깥 여백을 갖고 있다. 한 줄에
|
||||||
|
나란히 두면 그 여백이 줄 높이를 늘리고, 옆 버튼만 늘어나 짝이 어긋난다. */
|
||||||
|
.studio-app .studio-document-status-bar__actions .studio-primary-button { margin: 0; }
|
||||||
|
.studio-app .studio-document-status-bar button { min-width: 104px; border-color: var(--signal); background: var(--signal); color: #fff; }
|
||||||
|
.studio-app .studio-document-status-bar button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
|
||||||
|
.studio-app .studio-document-status-bar .studio-editor-unplaced-issues { flex: 1 1 100%; }
|
||||||
|
|
||||||
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
|
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
|
||||||
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
|
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
|
||||||
@@ -107,7 +140,7 @@
|
|||||||
matches this form's submit button, and both selectors carry the same
|
matches this form's submit button, and both selectors carry the same
|
||||||
specificity, so only source order tells them apart. */
|
specificity, so only source order tells them apart. */
|
||||||
/* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the
|
/* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the
|
||||||
reason `.studio-editor-layout` already needs the same pair: a grid track and
|
reason `.studio-editor-split` already needs the same pair: a grid track and
|
||||||
a flex container both default to an automatic minimum of their min-content
|
a flex container both default to an automatic minimum of their min-content
|
||||||
size, so at 360px the search row sized itself to 366px inside a 328px column
|
size, so at 360px the search row sized itself to 366px inside a 328px column
|
||||||
and pushed the submit button off-screen (document scrollWidth 382). */
|
and pushed the submit button off-screen (document scrollWidth 382). */
|
||||||
@@ -128,15 +161,19 @@
|
|||||||
.studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; }
|
.studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.studio-app .studio-editor-layout { grid-template-columns: minmax(0, 1fr); gap: 40px; }
|
.studio-app .studio-editor-split { grid-template-columns: minmax(0, 1fr); gap: 40px; }
|
||||||
.studio-app .studio-document-status-rail { position: static; }
|
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(38px, 5vw, 62px); }
|
||||||
|
.studio-app .studio-editor-preview { padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.studio-app .studio-editor-heading { padding-bottom: 28px; }
|
.studio-app .studio-editor-heading { padding-bottom: 28px; }
|
||||||
.studio-app .studio-editor-tabs { gap: 18px; }
|
.studio-app .studio-editor-split { padding-top: 28px; }
|
||||||
.studio-app .studio-editor-tabs button { flex: 1; min-width: 0; }
|
/* 안내문은 세 줄로 접혀 뷰포트의 20%를 고정으로 가져간다. 실패 문구는 남기고 안내만 접는다 —
|
||||||
.studio-app .studio-editor-layout { padding-top: 28px; }
|
Ctrl+S 는 여기서 누를 수 있는 것도 아니다. */
|
||||||
|
.studio-app .studio-document-status-bar__note { display: none; }
|
||||||
|
.studio-app .studio-document-status-bar__actions { flex: 1 1 100%; margin-left: 0; }
|
||||||
|
.studio-app .studio-document-status-bar__actions button { flex: 1; min-width: 0; }
|
||||||
.studio-app .studio-field-grid,
|
.studio-app .studio-field-grid,
|
||||||
.studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); }
|
.studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); }
|
||||||
.studio-app .studio-field--wide,
|
.studio-app .studio-field--wide,
|
||||||
@@ -181,6 +218,6 @@
|
|||||||
저장과 게시는 되돌릴 수 있는 정도가 다르다 — 하나는 초안을 남기고, 하나는 공개한다. 둘이
|
저장과 게시는 되돌릴 수 있는 정도가 다르다 — 하나는 초안을 남기고, 하나는 공개한다. 둘이
|
||||||
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
|
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
|
||||||
*/
|
*/
|
||||||
.studio-app .studio-document-status-rail button + button { margin-top: 10px; }
|
.studio-app .studio-document-status-bar__actions { gap: 12px; }
|
||||||
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
|
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
|
||||||
.studio-app .studio-document-status-rail button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
|
.studio-app .studio-document-status-bar button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
|
||||||
|
|||||||
@@ -461,8 +461,7 @@ test("inserts the directive at the saved cursor position and the live preview re
|
|||||||
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
|
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||||
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
||||||
|
|
||||||
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
|
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
|
||||||
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
|
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
|
||||||
@@ -1819,12 +1818,12 @@ test("a Picker search never drops an already-inserted asset out of Instant Previ
|
|||||||
// must narrow; the preview must not.
|
// must narrow; the preview must not.
|
||||||
await user.type(screen.getByLabelText("Asset 검색"), "other");
|
await user.type(screen.getByLabelText("Asset 검색"), "other");
|
||||||
await user.click(screen.getByRole("button", { name: "검색" }));
|
await user.click(screen.getByRole("button", { name: "검색" }));
|
||||||
|
const picker = screen.getByRole("group", { name: "본문에 Asset 삽입" });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
|
expect(within(picker).queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||||
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
within(panel).queryByRole("alert")?.textContent ?? null,
|
within(panel).queryByRole("alert")?.textContent ?? null,
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ describe("TechLog explore discovery", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { router } = await renderDiscoveryRoute(
|
const { router } = await renderDiscoveryRoute(
|
||||||
"TECH_LOG_EXPLORE",
|
"TECH_LOG_EXPLORE",
|
||||||
"/explore?type=CASE&topic=JPA&project=backend-skeleton",
|
"/explore?type=CASE&topic=jpa&project=backend-skeleton",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
|
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
|
||||||
@@ -306,22 +306,28 @@ describe("TechLog explore discovery", () => {
|
|||||||
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
|
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
|
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
|
||||||
expect(screen.getByLabelText("주제")).toHaveValue("JPA");
|
expect(screen.getByLabelText("주제")).toHaveValue("jpa");
|
||||||
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
|
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
|
||||||
});
|
});
|
||||||
|
// 주제 선택지는 보이는 이름과 보내는 값이 다르다. 값이 이름이면 slug 로 거르는 API 가
|
||||||
|
// 0건을 돌려주고, 화면은 「조건에 맞는 공개 기록이 없습니다」만 남는다 — 운영에서 실제로
|
||||||
|
// 그랬다. 목록에서 유도한 값이라 이 단언이 없으면 조용히 되돌아간다.
|
||||||
|
expect(
|
||||||
|
within(screen.getByLabelText("주제")).getByRole("option", { name: "Authentication" }),
|
||||||
|
).toHaveValue("authentication");
|
||||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
|
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
|
||||||
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
|
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
|
||||||
|
|
||||||
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
|
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
|
||||||
await user.selectOptions(screen.getByLabelText("주제"), "Authentication");
|
await user.selectOptions(screen.getByLabelText("주제"), "authentication");
|
||||||
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
|
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
|
||||||
await user.click(screen.getByRole("button", { name: "적용" }));
|
await user.click(screen.getByRole("button", { name: "적용" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(router.state.location.search).toBe(
|
expect(router.state.location.search).toBe(
|
||||||
"?project=auth-lab&topic=Authentication&type=QUESTION",
|
"?project=auth-lab&topic=authentication&type=QUESTION",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ const routeComponents = {
|
|||||||
|
|
||||||
type DocumentRouteId = keyof typeof routeComponents;
|
type DocumentRouteId = keyof typeof routeComponents;
|
||||||
|
|
||||||
|
/** 머리말이 거는 주제 링크를 확인하기 위한 픽스처의 이름 → slug 대응. */
|
||||||
|
const topicSlugs: Readonly<Record<string, string>> = {
|
||||||
|
JPA: "jpa",
|
||||||
|
Authentication: "authentication",
|
||||||
|
Redis: "redis",
|
||||||
|
};
|
||||||
|
|
||||||
class NoopIntersectionObserver implements IntersectionObserver {
|
class NoopIntersectionObserver implements IntersectionObserver {
|
||||||
readonly root = null;
|
readonly root = null;
|
||||||
readonly rootMargin = "0px";
|
readonly rootMargin = "0px";
|
||||||
@@ -205,13 +212,30 @@ describe("TechLog canonical Public documents", () => {
|
|||||||
const main = screen.getByRole("main");
|
const main = screen.getByRole("main");
|
||||||
|
|
||||||
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
|
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
|
||||||
expect(within(main).getByText(evidence, { exact: false })).toBeVisible();
|
/*
|
||||||
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
|
목차가 본문의 절 제목을 그대로 다시 적으므로 같은 글이 두 자리에 나온다. 여기서 확인하려는
|
||||||
`${kind}/${topic}/${project}`,
|
것은 "그 글이 문서에 있는가" 이므로 첫 자리로 충분하다.
|
||||||
|
*/
|
||||||
|
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
|
||||||
|
expect(firstEvidence).toBeVisible();
|
||||||
|
const breadcrumb = within(main).getByRole("navigation", { name: "문서 경로" });
|
||||||
|
expect(breadcrumb).toHaveTextContent(`${kind}/${topic}/${project}`);
|
||||||
|
/*
|
||||||
|
글자만 보고 있었더니 주제 링크가 어디로 가는지는 아무도 묻지 않았다. 그 링크는
|
||||||
|
`/topics/:slug` 를 가리켰고, 그 화면은 세 개의 주제를 하드코딩해 두고 있어 실제 주제는
|
||||||
|
무엇이든 404 였다 — 게시한 모든 문서가 죽은 링크를 하나씩 달고 있었다.
|
||||||
|
*/
|
||||||
|
expect(within(breadcrumb).getByRole("link", { name: topic })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
`/explore?topic=${topicSlugs[topic]}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (path === "/cases/collection-fetch-join-pagination") {
|
/*
|
||||||
expect(within(main).getByText(`게시 ${published} · 마지막 검증 2026.08.11`)).toBeVisible();
|
Case 는 한 배치를 쓴다. 예전에는 픽스처 문서 하나만 `.case-meta` 를 받고 나머지 Case 는
|
||||||
|
`.public-document-header dl` 로 떨어졌으므로 여기도 경로로 갈랐다. 이제 유형으로 가른다.
|
||||||
|
*/
|
||||||
|
if (kind === "Case") {
|
||||||
|
expect(container.querySelector(".case-meta")).toHaveTextContent(`게시 ${published}`);
|
||||||
} else {
|
} else {
|
||||||
const metadata = container.querySelector(".public-document-header dl");
|
const metadata = container.querySelector(".public-document-header dl");
|
||||||
expect(metadata).toHaveTextContent(`유형${kind}`);
|
expect(metadata).toHaveTextContent(`유형${kind}`);
|
||||||
@@ -258,12 +282,12 @@ describe("TechLog canonical Public documents", () => {
|
|||||||
expect(image).toHaveAttribute("loading", "lazy");
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the generic Case markup and omits source relations for the canonical empty state", async () => {
|
it("uses the one Case layout and omits source relations for the canonical empty state", async () => {
|
||||||
const generic = await renderDocumentRoute(
|
const generic = await renderDocumentRoute(
|
||||||
"TECH_LOG_CASE",
|
"TECH_LOG_CASE",
|
||||||
"/cases/redis-adapter-ttl-boundary",
|
"/cases/redis-adapter-ttl-boundary",
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("main")).toHaveClass("shell", "public-document-page");
|
expect(screen.getByRole("main")).toHaveClass("case-page");
|
||||||
expect(generic.container.querySelector("#ownership")).toHaveTextContent(
|
expect(generic.container.querySelector("#ownership")).toHaveTextContent(
|
||||||
"정책과 저장 명령의 주인을 구분하기",
|
"정책과 저장 명령의 주인을 구분하기",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -252,35 +252,26 @@ describe("TechLog project screens", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps project activity ordered and preserves self-fragment and record links", async () => {
|
/*
|
||||||
|
활동은 로그다 — 언제 무엇을 올렸는지만 적는다. 각 줄에 그 기록으로 가는 링크가 있으면 같은
|
||||||
|
글에 닿는 길이 둘이 되고, 읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다.
|
||||||
|
글을 읽는 자리는 기록 화면 하나로 둔다.
|
||||||
|
*/
|
||||||
|
it("keeps project activity ordered and leaves reading to the records screen", async () => {
|
||||||
const { container } = await renderPublicRoute(
|
const { container } = await renderPublicRoute(
|
||||||
"TECH_LOG_PROJECT_ACTIVITY",
|
"TECH_LOG_PROJECT_ACTIVITY",
|
||||||
"/projects/backend-skeleton/activity",
|
"/projects/backend-skeleton/activity",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
const items = Array.from(
|
||||||
Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({
|
container.querySelectorAll(".project-activity-list > li > article"),
|
||||||
id: item.id,
|
);
|
||||||
href: item.querySelector("a")?.getAttribute("href"),
|
expect(items.map((item) => item.id)).toEqual([
|
||||||
label: item.querySelector("a")?.textContent,
|
"fetch-join-case-published",
|
||||||
})),
|
"storage-contract",
|
||||||
).toEqual([
|
"redis-case-published",
|
||||||
{
|
|
||||||
id: "fetch-join-case-published",
|
|
||||||
href: "/cases/collection-fetch-join-pagination",
|
|
||||||
label: "연결된 공개 기록 읽기",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "storage-contract",
|
|
||||||
href: "/projects/backend-skeleton/activity#storage-contract",
|
|
||||||
label: "이 활동 위치 열기",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "redis-case-published",
|
|
||||||
href: "/cases/redis-adapter-ttl-boundary",
|
|
||||||
label: "연결된 공개 기록 읽기",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
expect(items.flatMap((item) => Array.from(item.querySelectorAll("a")))).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { test } from "vitest";
|
||||||
|
|
||||||
|
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
|
||||||
|
import { createHttpPublicContentGateway } from "../../../src/features/tech-log/adapters/http/http-public-content-gateway.ts";
|
||||||
|
|
||||||
|
type QuestionDetail = components["schemas"]["QuestionDetailResponse"];
|
||||||
|
|
||||||
|
/*
|
||||||
|
Open Question 을 게시했는데 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다.
|
||||||
|
|
||||||
|
게이트웨이가 `points` 를 `{group, items}` 배열로 읽고 `.filter` 를 불렀는데, 계약의
|
||||||
|
`QuestionPointGroup` 은 그룹 이름을 키로 갖는 **객체**다. 객체에는 `.filter` 가 없으므로
|
||||||
|
매핑이 통째로 터졌다. `as` 캐스트가 그 어긋남을 타입 검사에서 가렸다.
|
||||||
|
|
||||||
|
탐색 목록은 이 칸들을 빈 배열로 두고 만들기 때문에 목록에서는 멀쩡히 보였다 — 그래서
|
||||||
|
"게시했는데 public 에 안 뜬다" 로만 드러났고, 어느 층이 깨졌는지는 보이지 않았다.
|
||||||
|
|
||||||
|
그래서 여기서는 계약 모양 그대로의 응답을 진짜 게이트웨이에 통과시키고, 화면이 읽는 네 칸이
|
||||||
|
실제로 채워져 나오는지 묻는다. 모양만 검사하면 이 사고는 다시 지나간다.
|
||||||
|
*/
|
||||||
|
function gatewayReturning(value: unknown) {
|
||||||
|
return createHttpPublicContentGateway({
|
||||||
|
operations: {
|
||||||
|
execute: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
kind: "SUCCESS" as const,
|
||||||
|
value,
|
||||||
|
metadata: { status: 200 },
|
||||||
|
effect: "NOT_APPLICABLE" as const,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETAIL = {
|
||||||
|
canonicalPath: "/questions/refresh-rotation-replica-contention",
|
||||||
|
indexable: true,
|
||||||
|
question: {
|
||||||
|
question: "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
|
||||||
|
summary: "두 replica가 같은 refresh token으로 동시에 갱신할 수 있다.",
|
||||||
|
context: "",
|
||||||
|
importance: "",
|
||||||
|
status: "OPEN",
|
||||||
|
nextVerification: "저장소를 공유한 뒤에 재현한다.",
|
||||||
|
points: {
|
||||||
|
facts: ["realm은 refresh token rotation과 재사용 허용 0회를 쓴다."],
|
||||||
|
assumptions: ["운영에서는 replica가 둘 이상이고 저장소를 공유한다."],
|
||||||
|
unknowns: ["같은 refresh token으로 동시에 갱신하면 어떻게 되는지."],
|
||||||
|
constraints: ["이미 발급된 access token은 만료 전까지 계속 통한다."],
|
||||||
|
},
|
||||||
|
updates: [],
|
||||||
|
openedAt: "2026-08-24T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-26T13:41:53.974512Z",
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
primaryProject: {
|
||||||
|
type: "PROJECT",
|
||||||
|
title: "KeyCloak Patterns",
|
||||||
|
path: "/projects/keycloak-patterns",
|
||||||
|
summary: "Keycloak을 쓰면서 실제로 부딪힌 인증 경계를 기록합니다",
|
||||||
|
},
|
||||||
|
resultCase: {
|
||||||
|
type: "CASE",
|
||||||
|
title: "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
|
||||||
|
path: "/cases/split-custody-access-token",
|
||||||
|
summary: "토큰 교환과 토큰 관리의 책임이 서버로 이전했다.",
|
||||||
|
},
|
||||||
|
derivedReferences: [],
|
||||||
|
},
|
||||||
|
} satisfies QuestionDetail;
|
||||||
|
|
||||||
|
test("a published Open Question maps its four point groups into the screen's fields", async () => {
|
||||||
|
const gateway = gatewayReturning(DETAIL);
|
||||||
|
|
||||||
|
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
|
||||||
|
|
||||||
|
assert.ok(record, "게시된 질문은 상세로 돌아와야 한다");
|
||||||
|
assert.equal(record.kind, "QUESTION");
|
||||||
|
assert.equal(record.title, DETAIL.question.question);
|
||||||
|
assert.equal(record.questionStatus, "OPEN");
|
||||||
|
assert.deepEqual([...record.facts], DETAIL.question.points.facts);
|
||||||
|
assert.deepEqual([...record.assumptions], DETAIL.question.points.assumptions);
|
||||||
|
assert.deepEqual([...record.unknowns], DETAIL.question.points.unknowns);
|
||||||
|
assert.deepEqual([...record.constraints], DETAIL.question.points.constraints);
|
||||||
|
assert.equal(record.nextValidation, DETAIL.question.nextVerification);
|
||||||
|
// 질문 상세는 프로젝트를 `relations` 에 담는다 — `question` 에서 찾으면 머리말의 프로젝트
|
||||||
|
// 칸이 늘 비어 있다.
|
||||||
|
assert.equal(record.projectTitle, "KeyCloak Patterns");
|
||||||
|
assert.equal(record.projectSlug, "keycloak-patterns");
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
관계의 이유 자리에 영문 키가 그대로 나오고 있었다 — 매퍼가 찾던 그룹 이름이 계약에 없는
|
||||||
|
것들이었기 때문이다. 그리고 `primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다.
|
||||||
|
*/
|
||||||
|
test("a question's relations read their reason from the contract's own group names", async () => {
|
||||||
|
const gateway = gatewayReturning(DETAIL);
|
||||||
|
|
||||||
|
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
[...record!.relations].map((relation) => ({ reason: relation.reason, path: relation.path })),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
reason: "이 질문에서 나온 기록",
|
||||||
|
path: "/cases/split-custody-access-token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
네 그룹은 계약이 필수로 두고 있다. 이름이 하나라도 바뀌면 위 `satisfies` 가 먼저 깨지고,
|
||||||
|
화면이 읽는 이름과의 대응도 여기서 끊긴다.
|
||||||
|
*/
|
||||||
|
test("the contract names the four point groups the screen renders", () => {
|
||||||
|
const points: components["schemas"]["QuestionPointGroup"] = {
|
||||||
|
facts: [],
|
||||||
|
assumptions: [],
|
||||||
|
unknowns: [],
|
||||||
|
constraints: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(Object.keys(points).sort(), [
|
||||||
|
"assumptions",
|
||||||
|
"constraints",
|
||||||
|
"facts",
|
||||||
|
"unknowns",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { test } from "vitest";
|
||||||
|
|
||||||
|
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
|
||||||
|
|
||||||
|
type ReferenceBody =
|
||||||
|
components["schemas"]["ReferenceDetailResponse"]["reference"];
|
||||||
|
|
||||||
|
/*
|
||||||
|
이 파일은 사고 하나에서 나왔다.
|
||||||
|
|
||||||
|
공개 Reference 화면이 통째로 비어 있었다. Studio 에서는 같은 글이 다 보였으므로 "공개 쪽만 안
|
||||||
|
나온다" 로 드러났다. 원인은 게이트웨이가 읽던 이름이 계약에 없는 것들이었다는 것이다 —
|
||||||
|
`purposeSummary`, `applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. 전부 undefined 로
|
||||||
|
떨어졌고 타입 검사는 `as string` 단언 때문에 아무 말도 하지 않았다.
|
||||||
|
|
||||||
|
그래서 여기서 확인하는 것은 값이 아니라 **이름**이다. 계약이 그 칸을 갖고 있는가, 그리고 화면이
|
||||||
|
기대하는 모양인가.
|
||||||
|
*/
|
||||||
|
test("the public Reference contract keeps the fields the screen reads", () => {
|
||||||
|
const reference = {
|
||||||
|
title: "",
|
||||||
|
// 제목 바로 아래에 오는 것은 문서의 요약이다. scopeSummary 는 그 아래 "이 기준을 쓰는 이유"다.
|
||||||
|
summary: "",
|
||||||
|
scopeSummary: "",
|
||||||
|
appliesTo: [],
|
||||||
|
excludedScope: [],
|
||||||
|
rules: [{ title: "", body: "" }],
|
||||||
|
examples: [],
|
||||||
|
freshnessStatus: "CURRENT",
|
||||||
|
content: "",
|
||||||
|
contentFormat: "MARKDOWN",
|
||||||
|
contentFormatVersion: 1,
|
||||||
|
tags: [],
|
||||||
|
publishedAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
} satisfies ReferenceBody;
|
||||||
|
|
||||||
|
// 화면이 읽는 이름 그대로. 하나라도 계약에서 사라지면 위의 `satisfies` 가 먼저 깨진다.
|
||||||
|
assert.deepEqual(Object.keys(reference).sort(), [
|
||||||
|
"appliesTo",
|
||||||
|
"content",
|
||||||
|
"contentFormat",
|
||||||
|
"contentFormatVersion",
|
||||||
|
"examples",
|
||||||
|
"excludedScope",
|
||||||
|
"freshnessStatus",
|
||||||
|
"publishedAt",
|
||||||
|
"rules",
|
||||||
|
"scopeSummary",
|
||||||
|
"summary",
|
||||||
|
"tags",
|
||||||
|
"title",
|
||||||
|
"updatedAt",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Reference 의 본문은 `content` 마크다운이 아니라 규칙과 예시에 있다. Studio 의 편집기가 제목과
|
||||||
|
본문을 따로 받고 `body_markdown` 은 비워 두기 때문이다 — 그래서 `content` 를 잘라 규칙을 만들려
|
||||||
|
하면 언제나 빈 목록이 된다.
|
||||||
|
*/
|
||||||
|
test("a rule carries its own title and body, not a slice of markdown", () => {
|
||||||
|
const rule: NonNullable<ReferenceBody["rules"]>[number] = {
|
||||||
|
title: "Authorization Endpoint에는 client_secret을 보내지 않는다",
|
||||||
|
body: "이 요청은 브라우저의 full-page navigation으로 나간다.",
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(typeof rule.title, "string");
|
||||||
|
assert.equal(typeof rule.body, "string");
|
||||||
|
});
|
||||||
@@ -386,7 +386,12 @@ describe("shared Public record renderer", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses generic Case markup and does not present generatedAt as publication", () => {
|
/*
|
||||||
|
Case 렌더러가 둘이었을 때 이 테스트는 "축약본" 쪽을 지켰다. 어느 쪽을 쓸지는 슬러그 비교가
|
||||||
|
정했고, 설계 픽스처 문서 하나만 breadcrumb 과 목차가 있는 배치를 받았다 — 실제로 작성한 Case 는
|
||||||
|
전부 축약본이었고 오른쪽 목차가 어디에도 나오지 않았다. 배치는 이제 하나다.
|
||||||
|
*/
|
||||||
|
it("uses the one Case layout and does not present generatedAt as publication", () => {
|
||||||
renderInRouter(
|
renderInRouter(
|
||||||
<PublicRecordRenderer
|
<PublicRecordRenderer
|
||||||
{...renderDependencies}
|
{...renderDependencies}
|
||||||
@@ -402,14 +407,17 @@ describe("shared Public record renderer", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const main = screen.getByRole("main");
|
const main = screen.getByRole("main");
|
||||||
expect(main).toHaveClass("shell", "public-document-page");
|
expect(main).toHaveClass("case-page");
|
||||||
expect(main).toHaveAttribute("id", "main-content");
|
expect(main).toHaveAttribute("id", "main-content");
|
||||||
expect(screen.getByText("게시 전")).toBeVisible();
|
// 완성된 배치에서는 "게시 전" 이 기록 줄의 한 문장 안에 들어간다 — 홀로 선 노드가 아니다.
|
||||||
|
expect(main).toHaveTextContent("게시 전");
|
||||||
expect(main).not.toHaveTextContent("2035.05.06");
|
expect(main).not.toHaveTextContent("2035.05.06");
|
||||||
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
|
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
|
||||||
"문제문제결론결론",
|
"문제문제결론결론",
|
||||||
);
|
);
|
||||||
expect(screen.getByText("일반 본문")).toBeVisible();
|
expect(screen.getByText("일반 본문")).toBeVisible();
|
||||||
|
// 제목이 없는 본문에는 목차를 그리지 않는다 — 빈 레일만 남는다.
|
||||||
|
expect(screen.queryByLabelText("문서 목차")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders supplied Reference and Question preview fields and empty copy", () => {
|
it("renders supplied Reference and Question preview fields and empty copy", () => {
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ const expectedRoutes = [
|
|||||||
["TECH_LOG_STUDIO_PUBLICATION_PREVIEW", "/studio/publications/:publicationEventId/preview", "STUDIO", "TechLogPublicationEventIdParams", null],
|
["TECH_LOG_STUDIO_PUBLICATION_PREVIEW", "/studio/publications/:publicationEventId/preview", "STUDIO", "TechLogPublicationEventIdParams", null],
|
||||||
["TECH_LOG_STUDIO_ASSETS", "/studio/assets", "STUDIO", null, null],
|
["TECH_LOG_STUDIO_ASSETS", "/studio/assets", "STUDIO", null, null],
|
||||||
["TECH_LOG_STUDIO_TAXONOMY", "/studio/taxonomy", "STUDIO", null, null],
|
["TECH_LOG_STUDIO_TAXONOMY", "/studio/taxonomy", "STUDIO", null, null],
|
||||||
|
["TECH_LOG_STUDIO_PROJECT_EDIT", "/studio/projects/:id", "STUDIO", "TechLogDocumentIdParams", null],
|
||||||
["TECH_LOG_STUDIO_RELEASES", "/studio/releases", "STUDIO", null, null],
|
["TECH_LOG_STUDIO_RELEASES", "/studio/releases", "STUDIO", null, null],
|
||||||
|
["TECH_LOG_STUDIO_RELEASE_EDIT", "/studio/releases/:id", "STUDIO", "TechLogDocumentIdParams", null],
|
||||||
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
|
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
|
||||||
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
|
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
|
||||||
] as const;
|
] as const;
|
||||||
@@ -72,13 +74,15 @@ const expectedTitles = {
|
|||||||
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
|
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
|
||||||
TECH_LOG_STUDIO_ASSETS: "Asset",
|
TECH_LOG_STUDIO_ASSETS: "Asset",
|
||||||
TECH_LOG_STUDIO_TAXONOMY: "주제와 프로젝트",
|
TECH_LOG_STUDIO_TAXONOMY: "주제와 프로젝트",
|
||||||
|
TECH_LOG_STUDIO_PROJECT_EDIT: "프로젝트 편집",
|
||||||
TECH_LOG_STUDIO_RELEASES: "릴리즈",
|
TECH_LOG_STUDIO_RELEASES: "릴리즈",
|
||||||
|
TECH_LOG_STUDIO_RELEASE_EDIT: "릴리즈 편집",
|
||||||
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
|
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
|
||||||
NOT_FOUND: "페이지를 찾을 수 없습니다.",
|
NOT_FOUND: "페이지를 찾을 수 없습니다.",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
describe("TechLog route boundary contract", () => {
|
describe("TechLog route boundary contract", () => {
|
||||||
it("freezes the standalone 29-route inventory before runtime installation", () => {
|
it("freezes the standalone 31-route inventory before runtime installation", () => {
|
||||||
expect(
|
expect(
|
||||||
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
|
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
|
||||||
definition.routeId,
|
definition.routeId,
|
||||||
@@ -153,7 +157,7 @@ describe("TechLog route boundary contract", () => {
|
|||||||
for (const locale of ["ko-KR", "en-US"] as const) {
|
for (const locale of ["ko-KR", "en-US"] as const) {
|
||||||
const catalog: Readonly<Record<string, string>> =
|
const catalog: Readonly<Record<string, string>> =
|
||||||
TECH_LOG_MESSAGE_CATALOGS[locale];
|
TECH_LOG_MESSAGE_CATALOGS[locale];
|
||||||
expect(Object.keys(catalog)).toHaveLength(60);
|
expect(Object.keys(catalog)).toHaveLength(64);
|
||||||
for (const [routeId, title] of Object.entries(expectedTitles)) {
|
for (const [routeId, title] of Object.entries(expectedTitles)) {
|
||||||
expect(catalog[`route.${routeId}.title`]).toBe(title);
|
expect(catalog[`route.${routeId}.title`]).toBe(title);
|
||||||
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
|
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { afterEach, test } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useSaveShortcut } from "../../../src/features/tech-log/presentation/studio/components/use-save-shortcut.ts";
|
||||||
|
|
||||||
|
function pressSave(overrides: Partial<KeyboardEventInit> = {}) {
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: "s",
|
||||||
|
ctrlKey: true,
|
||||||
|
cancelable: true,
|
||||||
|
bubbles: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
브라우저의 Ctrl+S 는 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장 대화상자가
|
||||||
|
뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다. 그래서 기본 동작을 막는
|
||||||
|
것까지가 이 훅의 일이다.
|
||||||
|
*/
|
||||||
|
test("Ctrl+S saves and keeps the browser from offering the page as a file", () => {
|
||||||
|
let saved = 0;
|
||||||
|
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||||
|
|
||||||
|
const event = pressSave();
|
||||||
|
|
||||||
|
assert.equal(saved, 1);
|
||||||
|
assert.equal(event.defaultPrevented, true);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Cmd+S does the same, because the habit is the same on a Mac", () => {
|
||||||
|
let saved = 0;
|
||||||
|
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||||
|
|
||||||
|
pressSave({ ctrlKey: false, metaKey: true });
|
||||||
|
|
||||||
|
assert.equal(saved, 1);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
저장할 것이 없을 때도 기본 동작은 막는다. 막지 않으면 "저장할 것이 없다"는 화면 상태와
|
||||||
|
브라우저의 저장 대화상자가 동시에 나와, 무엇이 일어났는지 알 수 없다.
|
||||||
|
*/
|
||||||
|
test("a disabled shortcut still swallows the browser default", () => {
|
||||||
|
let saved = 0;
|
||||||
|
const { unmount } = renderHook(() =>
|
||||||
|
useSaveShortcut(() => { saved += 1; }, false),
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = pressSave();
|
||||||
|
|
||||||
|
assert.equal(saved, 0);
|
||||||
|
assert.equal(event.defaultPrevented, true);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modified chords are left to whoever else wants them", () => {
|
||||||
|
let saved = 0;
|
||||||
|
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||||
|
|
||||||
|
const shift = pressSave({ shiftKey: true });
|
||||||
|
const alt = pressSave({ altKey: true });
|
||||||
|
const plain = pressSave({ ctrlKey: false });
|
||||||
|
|
||||||
|
assert.equal(saved, 0);
|
||||||
|
assert.equal(shift.defaultPrevented, false);
|
||||||
|
assert.equal(alt.defaultPrevented, false);
|
||||||
|
assert.equal(plain.defaultPrevented, false);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the listener goes away with the screen", () => {
|
||||||
|
let saved = 0;
|
||||||
|
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
pressSave();
|
||||||
|
|
||||||
|
assert.equal(saved, 0);
|
||||||
|
});
|
||||||
@@ -166,8 +166,7 @@ describe("TechLog Studio project decision authoring", () => {
|
|||||||
expect(screen.getByRole("complementary", { name: "작업 상태" }))
|
expect(screen.getByRole("complementary", { name: "작업 상태" }))
|
||||||
.toHaveTextContent("종류Decision");
|
.toHaveTextContent("종류Decision");
|
||||||
|
|
||||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
const preview = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||||
const preview = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
||||||
expect(
|
expect(
|
||||||
within(preview).getByRole("heading", {
|
within(preview).getByRole("heading", {
|
||||||
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
||||||
|
|||||||
@@ -114,20 +114,17 @@ describe("TechLog Studio document editor", () => {
|
|||||||
]);
|
]);
|
||||||
expect(within(rail).queryAllByRole("link")).toHaveLength(0);
|
expect(within(rail).queryAllByRole("link")).toHaveLength(0);
|
||||||
|
|
||||||
const editTab = screen.getByRole("tab", { name: "편집" });
|
// 편집과 미리보기는 한 화면에 함께 있다. 탭이었을 때는 고친 결과를 보려면 편집하던 자리를
|
||||||
const previewTab = screen.getByRole("tab", { name: "즉시 미리보기" });
|
// 화면에서 치워야 했고, 돌아오면 스크롤 위치도 잃었다 — 그 왕복이 없어야 한다는 것이
|
||||||
editTab.focus();
|
// 이 화면의 요구다. 아무것도 누르지 않은 채로 둘 다 보이는지 묻는다.
|
||||||
await user.keyboard("{ArrowRight}");
|
expect(screen.getByRole("region", { name: "문서 편집" })).toBeVisible();
|
||||||
expect(previewTab).toHaveFocus();
|
|
||||||
expect(previewTab).toHaveAttribute("aria-selected", "true");
|
|
||||||
expect(
|
expect(
|
||||||
within(screen.getByRole("tabpanel", { name: "즉시 미리보기" })).getByRole(
|
within(screen.getByRole("region", { name: "즉시 미리보기" })).getByRole(
|
||||||
"heading",
|
"heading",
|
||||||
{ level: 1, name: "편집한 Redis 경계" },
|
{ level: 1, name: "편집한 Redis 경계" },
|
||||||
),
|
),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await user.keyboard("{Home}");
|
expect(screen.queryByRole("tab", { name: "즉시 미리보기" })).toBeNull();
|
||||||
expect(editTab).toHaveFocus();
|
|
||||||
expect(screen.getByLabelText("제목")).toHaveValue("편집한 Redis 경계");
|
expect(screen.getByLabelText("제목")).toHaveValue("편집한 Redis 경계");
|
||||||
expect(calls).toEqual({ save: 0, validate: 0, preview: 0, publish: 0 });
|
expect(calls).toEqual({ save: 0, validate: 0, preview: 0, publish: 0 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ describe("TechLog consumer-visible style contract", () => {
|
|||||||
expect(rootStyle.getPropertyValue("--ink")).toBe("#17181b");
|
expect(rootStyle.getPropertyValue("--ink")).toBe("#17181b");
|
||||||
expect(rootStyle.getPropertyValue("--signal")).toBe("#3e5cc7");
|
expect(rootStyle.getPropertyValue("--signal")).toBe("#3e5cc7");
|
||||||
expect(rootStyle.getPropertyValue("--shell")).toBe("1180px");
|
expect(rootStyle.getPropertyValue("--shell")).toBe("1180px");
|
||||||
expect(rootStyle.getPropertyValue("--body-copy")).toBe("42rem");
|
// 42rem 은 영문 기준 측정값이었다. 한글 본문에서 좁았고, 바로 위의 유형/프로젝트 줄이
|
||||||
|
// shell 전체를 쓰고 있어 대비가 더 심했다. 읽는 단 전체가 이 값 하나를 따른다.
|
||||||
|
expect(rootStyle.getPropertyValue("--body-copy")).toBe("56rem");
|
||||||
expect(rootStyle.color).toBe("var(--ink)");
|
expect(rootStyle.color).toBe("var(--ink)");
|
||||||
expect(rootStyle.background).toBe("var(--canvas)");
|
expect(rootStyle.background).toBe("var(--canvas)");
|
||||||
expect(bodyStyle.color).toBe("var(--ink)");
|
expect(bodyStyle.color).toBe("var(--ink)");
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ async function interact(page, action) {
|
|||||||
} else if (action === "studio-menu") {
|
} else if (action === "studio-menu") {
|
||||||
await page.getByRole("button", { name: "Studio 메뉴 열기" }).click();
|
await page.getByRole("button", { name: "Studio 메뉴 열기" }).click();
|
||||||
} else if (action === "immediate-preview") {
|
} else if (action === "immediate-preview") {
|
||||||
await page.getByRole("tab", { name: "즉시 미리보기" }).click();
|
// 미리보기는 이제 편집 옆에 늘 떠 있다 — 열 탭이 없으므로 자리 잡기를 기다리기만 한다.
|
||||||
|
await page.getByRole("region", { name: "즉시 미리보기" }).waitFor();
|
||||||
} else if (action === "dirty-dialog") {
|
} else if (action === "dirty-dialog") {
|
||||||
await page.getByLabel("요약").fill("저장하지 않은 시각 검증 변경");
|
await page.getByLabel("요약").fill("저장하지 않은 시각 검증 변경");
|
||||||
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
|
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
|
||||||
|
|||||||
@@ -95,8 +95,7 @@ test("Studio immediate preview matches the source without a mask", async ({ page
|
|||||||
page,
|
page,
|
||||||
"/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
"/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
||||||
);
|
);
|
||||||
await page.getByRole("tab", { name: "즉시 미리보기" }).click();
|
await expect(page.getByRole("region", { name: "즉시 미리보기" })).toBeVisible();
|
||||||
await expect(page.getByRole("tabpanel", { name: "즉시 미리보기" })).toBeVisible();
|
|
||||||
await expect(page).toHaveScreenshot("tech-log-studio-immediate-preview-1440.png", {
|
await expect(page).toHaveScreenshot("tech-log-studio-immediate-preview-1440.png", {
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user