Compare commits
3
Commits
ab67eb912c
...
fd73bc88a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd73bc88a1 | ||
|
|
e5770dfbc8 | ||
|
|
a7fe069a6d |
@@ -38,28 +38,28 @@
|
||||
},
|
||||
"contractSet": {
|
||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||
"setDigest": "sha256:b729150c56b64ba01d7a71a7442c14bb04d9e573b852d1d1879e748854c20174",
|
||||
"setDigest": "sha256:7832d43886cf12e6569ffd28e3f033d8a64a88e673e31449726d6dc450fa62ae",
|
||||
"packages": [
|
||||
{
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "06ae075"
|
||||
"sourceRevision": "83148b2"
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.1.0",
|
||||
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "06ae075"
|
||||
"sourceRevision": "83148b2"
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.1.0",
|
||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
||||
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "06ae075"
|
||||
"sourceRevision": "83148b2"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -142,17 +142,12 @@ export function getLatestEntries(): LatestRecordEntry[] {
|
||||
};
|
||||
}),
|
||||
);
|
||||
const releaseEntries = releases.map((release) => ({
|
||||
id: `release-${release.version}`,
|
||||
entryType: "RELEASE" as const,
|
||||
title: release.title,
|
||||
summary: release.summary,
|
||||
path: release.path,
|
||||
publishedAt: release.publishedAt,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
}));
|
||||
return [...activities, ...releaseEntries].sort((left, right) =>
|
||||
/*
|
||||
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
|
||||
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
|
||||
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
|
||||
*/
|
||||
return [...activities].sort((left, right) =>
|
||||
right.publishedAt.localeCompare(left.publishedAt),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||
"sourceRevision": "06ae075",
|
||||
"sourceRevision": "83148b2",
|
||||
"operationIds": [
|
||||
"createCaseDraft",
|
||||
"getCaseForEdit",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.1.0",
|
||||
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
|
||||
"sourceRevision": "06ae075",
|
||||
"sourceRevision": "83148b2",
|
||||
"operationIds": [
|
||||
"getPublicSite",
|
||||
"getPublicHome",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.1.0",
|
||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
||||
"sourceRevision": "06ae075",
|
||||
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
|
||||
"sourceRevision": "83148b2",
|
||||
"operationIds": [
|
||||
"getStudioSession",
|
||||
"getStudioDashboard",
|
||||
|
||||
@@ -1167,7 +1167,7 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
status: "PROPOSED" | "ADOPTED";
|
||||
/** Format: date */
|
||||
decidedOn: string;
|
||||
decidedOn: string | null;
|
||||
statement: string;
|
||||
rationale: string;
|
||||
consequences: components["schemas"]["OrderedText"][];
|
||||
|
||||
@@ -1572,7 +1572,10 @@ components:
|
||||
properties:
|
||||
kind: { type: string, enum: [PROJECT_DECISION] }
|
||||
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 }
|
||||
rationale: { type: string, maxLength: 100000 }
|
||||
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||
|
||||
@@ -131,7 +131,18 @@ function renderContext(
|
||||
|
||||
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
|
||||
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}`;
|
||||
}
|
||||
const prefix =
|
||||
@@ -186,7 +197,7 @@ export function projectWorkingCopy(
|
||||
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
||||
if (!topic) fail("TOPIC catalog entry is required");
|
||||
if (input.kind === "PROJECT_DECISION" && !project) {
|
||||
fail("PROJECT catalog entry is required");
|
||||
fail("Decision 은 프로젝트에 속합니다. 기본 정보에서 프로젝트를 골라 주세요.");
|
||||
}
|
||||
|
||||
const base = {
|
||||
@@ -273,12 +284,16 @@ export function projectWorkingCopy(
|
||||
|
||||
case "PROJECT_DECISION":
|
||||
if (!input.decisionStatus) fail("Decision status is required");
|
||||
if (!input.decidedOn) fail("Decision date is required");
|
||||
/*
|
||||
결정일은 비어 있어도 모델을 만든다. 검증이 그것을 경고로만 다루므로 날짜 없이 게시할 수
|
||||
있는데, 여기서 막으면 그 문서는 미리보기조차 열리지 않는다 — 작성자는 "경고라면서 왜
|
||||
안 되냐"를 만난다. 화면이 "미정"이라고 말하면 된다.
|
||||
*/
|
||||
return {
|
||||
...base,
|
||||
kind: "PROJECT_DECISION",
|
||||
status: input.decisionStatus,
|
||||
decidedOn: input.decidedOn,
|
||||
decidedOn: input.decidedOn ?? null,
|
||||
statement: input.statement,
|
||||
rationale: input.rationale,
|
||||
consequences: ordered(input.consequences),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
@@ -21,28 +19,33 @@ export function ProjectActivityPage() {
|
||||
const { project, activity } = view.data;
|
||||
if (!project) return <RegisteredNotFoundRoute />;
|
||||
|
||||
/*
|
||||
활동은 로그다 — 언제 무엇을 올렸는지만 적는다.
|
||||
|
||||
예전에는 각 줄에 그 기록으로 가는 링크가 있었다. 그러면 같은 글에 닿는 길이 둘이 되고,
|
||||
읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다. 글을 읽는 자리는
|
||||
기록 화면 하나로 둔다.
|
||||
*/
|
||||
return (
|
||||
<main id="main-content" className="shell project-page">
|
||||
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
||||
<ol className="project-activity-list">
|
||||
{activity.map((item) => (
|
||||
<li key={item.id}>
|
||||
<article id={item.id}>
|
||||
<div>
|
||||
<span>{item.type}</span>
|
||||
<time dateTime={item.dateTime}>{item.date}</time>
|
||||
</div>
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.summary}</p>
|
||||
<Link to={item.recordPath ?? item.path}>
|
||||
{item.recordPath
|
||||
? "연결된 공개 기록 읽기"
|
||||
: "이 활동 위치 열기"}
|
||||
</Link>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{activity.length === 0 ? (
|
||||
<p className="public-empty-note">아직 이 프로젝트에서 공개한 기록이 없습니다.</p>
|
||||
) : (
|
||||
<ol className="project-activity-list">
|
||||
{activity.map((item) => (
|
||||
<li key={item.id}>
|
||||
<article id={item.id}>
|
||||
<div>
|
||||
<span>{item.type}</span>
|
||||
<time dateTime={item.dateTime}>{item.date}</time>
|
||||
</div>
|
||||
<h2>{item.title}</h2>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,16 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
`IntersectionObserver` 가 없는 환경이 있다 — jsdom 이 그렇고, 오래된 브라우저도 그렇다.
|
||||
없으면 "지금 읽는 절" 표시만 못 할 뿐 목차 자체는 쓸 수 있으므로, 없다고 문서를 통째로
|
||||
못 그리게 두지 않는다.
|
||||
|
||||
한동안 이 컴포넌트는 픽스처 Case 하나에서만 쓰여 그 환경을 만난 적이 없었다. 모든 Case 가
|
||||
목차를 받게 되면서 드러났다.
|
||||
*/
|
||||
if (typeof IntersectionObserver === "undefined") return undefined;
|
||||
|
||||
const elements = headings
|
||||
.map((heading) => document.getElementById(heading.id))
|
||||
.filter((element): element is HTMLElement => Boolean(element));
|
||||
|
||||
@@ -435,7 +435,11 @@ function ProjectDecisionDocument({
|
||||
<header>
|
||||
<div>
|
||||
<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>
|
||||
<h2>{model.title}</h2>
|
||||
<p><PlainText text={model.statement} /></p>
|
||||
|
||||
@@ -41,12 +41,19 @@ export function HomeFocusEditor() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
managementGateway.getHomeFocus(),
|
||||
managementGateway.listProjects(0, 50),
|
||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||
]).then(
|
||||
([current, projectPage, catalog]) => {
|
||||
/*
|
||||
try/catch 로 감싼다. `Promise.all([gateway.foo()])` 은 `foo` 가 거절하는 것만 잡는다 —
|
||||
호출 자체가 동기적으로 던지면(예: 그 표면을 갖추지 않은 게이트웨이) 배열을 만드는 중에
|
||||
터지므로 rejection handler 를 지나지 못한다. 그러면 이 섹션이 아니라 대시보드 전체가
|
||||
빈 화면이 된다. 한 칸의 실패가 화면을 통째로 날리지 않게 한다.
|
||||
*/
|
||||
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;
|
||||
setFocus(current);
|
||||
setProjects(projectPage.items ?? []);
|
||||
@@ -55,11 +62,10 @@ export function HomeFocusEditor() {
|
||||
setQuestionId(current.openQuestionId ?? "");
|
||||
setDecisionId(current.recentDecisionId ?? "");
|
||||
setError("");
|
||||
},
|
||||
() => {
|
||||
} catch {
|
||||
if (!cancelled) setError("홈 설정을 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -114,14 +114,6 @@ export function ProjectEditor() {
|
||||
const [records, setRecords] = useState<CatalogEntry[]>([]);
|
||||
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
|
||||
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 [error, setError] = useState("");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
@@ -130,13 +122,15 @@ export function ProjectEditor() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
managementGateway.getProject(projectId),
|
||||
managementGateway.listProjectActivities(projectId),
|
||||
managementGateway.listTopics(),
|
||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||
]).then(
|
||||
([loaded, activityList, topicList, catalog]) => {
|
||||
// 동기적으로 던지는 호출도 잡는다 — `home-focus-editor` 와 같은 이유다.
|
||||
void (async () => {
|
||||
try {
|
||||
const [loaded, activityList, topicList, catalog] = await Promise.all([
|
||||
managementGateway.getProject(projectId),
|
||||
managementGateway.listProjectActivities(projectId),
|
||||
managementGateway.listTopics(),
|
||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setProject(loaded);
|
||||
setDraft(toDraft(loaded));
|
||||
@@ -144,11 +138,10 @@ export function ProjectEditor() {
|
||||
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
|
||||
setRecords(catalog.items ?? []);
|
||||
setError("");
|
||||
},
|
||||
() => {
|
||||
} catch {
|
||||
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -218,88 +211,6 @@ export function ProjectEditor() {
|
||||
}
|
||||
};
|
||||
|
||||
const addActivity = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending || !project) return;
|
||||
const title = newActivity.title.trim();
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -445,9 +356,19 @@ export function ProjectEditor() {
|
||||
>
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">NARRATIVE</p>
|
||||
<h2 id="studio-project-narrative-title">맥락</h2>
|
||||
<p>프로젝트 상세 화면의 본문입니다. 마크다운을 씁니다.</p>
|
||||
<h2 id="studio-project-narrative-title">목적</h2>
|
||||
<p>
|
||||
프로젝트 화면의 “프로젝트가 지키는 기준”이 이 글을 그립니다.
|
||||
</p>
|
||||
</div>
|
||||
{/*
|
||||
경계와 시스템 개요 칸이 여기 함께 있었다. 저장은 되지만 공개 화면 어디에도
|
||||
나오지 않는다 — 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 쓴 글이
|
||||
어딘가 실릴 것처럼 보이는 칸이었고, 실제로는 아무도 읽지 않았다.
|
||||
|
||||
열의 데이터는 남겨 둔다(계약이 요구하고, 이미 적어 둔 글이 있을 수 있다). 저장할
|
||||
때 서버가 가진 값을 그대로 돌려보내므로 지워지지 않는다.
|
||||
*/}
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>목적</span>
|
||||
@@ -456,24 +377,7 @@ export function ProjectEditor() {
|
||||
value={draft.purposeMarkdown}
|
||||
onChange={(event) => update({ purposeMarkdown: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<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 })
|
||||
}
|
||||
/>
|
||||
<small>마크다운을 씁니다.</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
@@ -490,117 +394,22 @@ export function ProjectEditor() {
|
||||
</div>
|
||||
</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">
|
||||
<p className="studio-eyebrow">ACTIVITY</p>
|
||||
<h2 id="studio-project-activity-title">활동</h2>
|
||||
<p>공개로 둔 줄만 프로젝트 화면의 타임라인에 나옵니다.</p>
|
||||
<p>
|
||||
기록을 게시하면 이 목록에 한 줄이 자동으로 남습니다. 손으로 적는 자리가 아닙니다.
|
||||
</p>
|
||||
</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 ? (
|
||||
<p className="studio-empty-inline">아직 활동이 없습니다.</p>
|
||||
<p className="studio-empty-inline">
|
||||
아직 이 프로젝트에서 게시한 기록이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="studio-document-list">
|
||||
{activities.map((activity) => (
|
||||
@@ -609,61 +418,27 @@ export function ProjectEditor() {
|
||||
<div className="studio-document-title">
|
||||
<h2>{activity.title}</h2>
|
||||
<p>
|
||||
{activity.summary || "요약 없음"}
|
||||
{activity.relatedResourceId ? " · 기록 연결됨" : " · 연결된 기록 없음"}
|
||||
{activity.visibility === "PUBLIC"
|
||||
? "공개 타임라인에 나옵니다"
|
||||
: "비공개 — 타임라인에 나오지 않습니다"}
|
||||
</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>일시</dt>
|
||||
<dt>게시</dt>
|
||||
<dd>
|
||||
<input
|
||||
type="datetime-local"
|
||||
aria-label={`${activity.title} 일시`}
|
||||
defaultValue={toLocalInput(activity.occurredAt)}
|
||||
onBlur={(event) =>
|
||||
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>
|
||||
<time dateTime={activity.occurredAt}>
|
||||
{new Date(activity.occurredAt).toLocaleDateString("ko-KR", {
|
||||
timeZone: "Asia/Seoul",
|
||||
})}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기록</dt>
|
||||
<dd>{activity.origin === "MANUAL" ? "손으로 적음" : "자동 기록"}</dd>
|
||||
<dd>{activity.origin === "AUTO" ? "게시가 남김" : "손으로 적음"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending || activity.origin !== "MANUAL"}
|
||||
title={
|
||||
activity.origin === "MANUAL"
|
||||
? undefined
|
||||
: "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다."
|
||||
}
|
||||
onClick={() => void removeActivity(activity)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -205,13 +205,22 @@ describe("TechLog canonical Public documents", () => {
|
||||
const main = screen.getByRole("main");
|
||||
|
||||
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
|
||||
expect(within(main).getByText(evidence, { exact: false })).toBeVisible();
|
||||
/*
|
||||
목차가 본문의 절 제목을 그대로 다시 적으므로 같은 글이 두 자리에 나온다. 여기서 확인하려는
|
||||
것은 "그 글이 문서에 있는가" 이므로 첫 자리로 충분하다.
|
||||
*/
|
||||
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
|
||||
expect(firstEvidence).toBeVisible();
|
||||
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
|
||||
`${kind}/${topic}/${project}`,
|
||||
);
|
||||
|
||||
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 {
|
||||
const metadata = container.querySelector(".public-document-header dl");
|
||||
expect(metadata).toHaveTextContent(`유형${kind}`);
|
||||
@@ -258,12 +267,12 @@ describe("TechLog canonical Public documents", () => {
|
||||
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(
|
||||
"TECH_LOG_CASE",
|
||||
"/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(
|
||||
"정책과 저장 명령의 주인을 구분하기",
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
"TECH_LOG_PROJECT_ACTIVITY",
|
||||
"/projects/backend-skeleton/activity",
|
||||
);
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({
|
||||
id: item.id,
|
||||
href: item.querySelector("a")?.getAttribute("href"),
|
||||
label: item.querySelector("a")?.textContent,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
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: "연결된 공개 기록 읽기",
|
||||
},
|
||||
const items = Array.from(
|
||||
container.querySelectorAll(".project-activity-list > li > article"),
|
||||
);
|
||||
expect(items.map((item) => item.id)).toEqual([
|
||||
"fetch-join-case-published",
|
||||
"storage-contract",
|
||||
"redis-case-published",
|
||||
]);
|
||||
expect(items.flatMap((item) => Array.from(item.querySelectorAll("a")))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
<PublicRecordRenderer
|
||||
{...renderDependencies}
|
||||
@@ -402,14 +407,17 @@ describe("shared Public record renderer", () => {
|
||||
);
|
||||
|
||||
const main = screen.getByRole("main");
|
||||
expect(main).toHaveClass("shell", "public-document-page");
|
||||
expect(main).toHaveClass("case-page");
|
||||
expect(main).toHaveAttribute("id", "main-content");
|
||||
expect(screen.getByText("게시 전")).toBeVisible();
|
||||
// 완성된 배치에서는 "게시 전" 이 기록 줄의 한 문장 안에 들어간다 — 홀로 선 노드가 아니다.
|
||||
expect(main).toHaveTextContent("게시 전");
|
||||
expect(main).not.toHaveTextContent("2035.05.06");
|
||||
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
|
||||
"문제문제결론결론",
|
||||
);
|
||||
expect(screen.getByText("일반 본문")).toBeVisible();
|
||||
// 제목이 없는 본문에는 목차를 그리지 않는다 — 빈 레일만 남는다.
|
||||
expect(screen.queryByLabelText("문서 목차")).toBeNull();
|
||||
});
|
||||
|
||||
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_ASSETS", "/studio/assets", "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_RELEASE_EDIT", "/studio/releases/:id", "STUDIO", "TechLogDocumentIdParams", null],
|
||||
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
|
||||
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
|
||||
] as const;
|
||||
@@ -72,13 +74,15 @@ const expectedTitles = {
|
||||
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
|
||||
TECH_LOG_STUDIO_ASSETS: "Asset",
|
||||
TECH_LOG_STUDIO_TAXONOMY: "주제와 프로젝트",
|
||||
TECH_LOG_STUDIO_PROJECT_EDIT: "프로젝트 편집",
|
||||
TECH_LOG_STUDIO_RELEASES: "릴리즈",
|
||||
TECH_LOG_STUDIO_RELEASE_EDIT: "릴리즈 편집",
|
||||
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
|
||||
NOT_FOUND: "페이지를 찾을 수 없습니다.",
|
||||
} as const;
|
||||
|
||||
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(
|
||||
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
|
||||
definition.routeId,
|
||||
@@ -153,7 +157,7 @@ describe("TechLog route boundary contract", () => {
|
||||
for (const locale of ["ko-KR", "en-US"] as const) {
|
||||
const catalog: Readonly<Record<string, string>> =
|
||||
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)) {
|
||||
expect(catalog[`route.${routeId}.title`]).toBe(title);
|
||||
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
|
||||
|
||||
@@ -79,7 +79,9 @@ describe("TechLog consumer-visible style contract", () => {
|
||||
expect(rootStyle.getPropertyValue("--ink")).toBe("#17181b");
|
||||
expect(rootStyle.getPropertyValue("--signal")).toBe("#3e5cc7");
|
||||
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.background).toBe("var(--canvas)");
|
||||
expect(bodyStyle.color).toBe("var(--ink)");
|
||||
|
||||
Reference in New Issue
Block a user