feat: read the profile's topics from Studio, and add working-copy deletion
Two things an author could not control from Studio. The profile's "주요 관심 주제" was four strings in the JSX. Creating or removing a topic in Studio changed nothing, and correcting the list meant a rebuild and a redeploy. It now renders the published topic list. The old literal opened with "Backend Architecture", which no record in the catalogue actually carries — the profile was advertising a topic that did not exist, and nothing could have caught that while the list lived in the markup. The working-copy list gained a delete control. It routes by kind because the contract and the storage both do: Case and Reference share one table split by type, Question is its own. Decision has no delete — its lifecycle is accept, reject, supersede, which records what happened rather than erasing it — so the control does not appear for it. The list summary carries no version, so deletion reads the working copy first and uses the version it finds. A stale version from a list left open should fail as a conflict, not delete whatever is there now.
This commit is contained in:
@@ -38,28 +38,28 @@
|
|||||||
},
|
},
|
||||||
"contractSet": {
|
"contractSet": {
|
||||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||||
"setDigest": "sha256:0872fd944d931d70550549496a8086322ea6c86adc1d5802030300eb0076d2f2",
|
"setDigest": "sha256:f3124d4f29c487d1d6b4f42326393c91ad0542ecb4b60c0ce09a62fb452bb173",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/management-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"digest": "sha256:e16a9695e9034a273c769792ea8901fd706c61514137228b2c20e1b117ea0c01",
|
"digest": "sha256:9efc7709b29404db96a7cc7acecdd9ec7b27cb95f3389e5da2c4953e2e3e23aa",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "0c10a4a"
|
"sourceRevision": "332b11f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/public-contract",
|
"packageId": "@tech-log/public-contract",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e",
|
"digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "0c10a4a"
|
"sourceRevision": "332b11f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4",
|
"digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "0c10a4a"
|
"sourceRevision": "332b11f"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,19 @@ export function createHttpManagementGateway(
|
|||||||
run<PublishResponse>("publishRelease", { id, expectedVersion }),
|
run<PublishResponse>("publishRelease", { id, expectedVersion }),
|
||||||
archiveRelease: (id: string, expectedVersion: number) =>
|
archiveRelease: (id: string, expectedVersion: number) =>
|
||||||
run<ReleaseEditResponse>("archiveRelease", { id, expectedVersion }),
|
run<ReleaseEditResponse>("archiveRelease", { id, expectedVersion }),
|
||||||
|
deleteDocument: async (
|
||||||
|
kind: "CASE" | "REFERENCE" | "QUESTION",
|
||||||
|
id: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
) => {
|
||||||
|
const operationId =
|
||||||
|
kind === "CASE"
|
||||||
|
? "deleteCaseDraft"
|
||||||
|
: kind === "REFERENCE"
|
||||||
|
? "deleteReferenceDraft"
|
||||||
|
: "deleteQuestion";
|
||||||
|
await run<void>(operationId, { id, expectedVersion });
|
||||||
|
},
|
||||||
deleteProject: async (id: string, expectedVersion: number) => {
|
deleteProject: async (id: string, expectedVersion: number) => {
|
||||||
await run<void>("deleteProject", { id, expectedVersion });
|
await run<void>("deleteProject", { id, expectedVersion });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
PublicContentQueries,
|
PublicContentQueries,
|
||||||
PublicRecord,
|
PublicRecord,
|
||||||
|
PublicTopic,
|
||||||
QuestionRecord,
|
QuestionRecord,
|
||||||
RecordFilters,
|
RecordFilters,
|
||||||
RecordKind,
|
RecordKind,
|
||||||
@@ -292,6 +293,18 @@ export function createHttpPublicContentGateway(
|
|||||||
* one object with a named slot per kind rather than a list, because each slot
|
* one object with a named slot per kind rather than a list, because each slot
|
||||||
* has its own shape; the order below is the order the screen renders them in.
|
* has its own shape; the order below is the order the screen renders them in.
|
||||||
*/
|
*/
|
||||||
|
async function listTopics(): Promise<PublicTopic[]> {
|
||||||
|
const page = await read<Page>("listPublicTopics", {});
|
||||||
|
if (page === NOT_FOUND) return [];
|
||||||
|
return (page.items ?? []).map((item) =>
|
||||||
|
Object.freeze({
|
||||||
|
name: String(item.name ?? ""),
|
||||||
|
slug: String(item.slug ?? ""),
|
||||||
|
recordCount: Number(item.recordCount ?? 0),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
|
async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
|
||||||
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
|
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
|
||||||
"getPublicHome",
|
"getPublicHome",
|
||||||
@@ -430,6 +443,7 @@ export function createHttpPublicContentGateway(
|
|||||||
getRecord,
|
getRecord,
|
||||||
getProject,
|
getProject,
|
||||||
getRelease,
|
getRelease,
|
||||||
|
listTopics,
|
||||||
getProjectRecords,
|
getProjectRecords,
|
||||||
getProjectDecisions,
|
getProjectDecisions,
|
||||||
getProjectActivity,
|
getProjectActivity,
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
type Release,
|
type Release,
|
||||||
type HomeFocusItem,
|
type HomeFocusItem,
|
||||||
} from "./public-content.ts";
|
} from "./public-content.ts";
|
||||||
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
|
import type {
|
||||||
|
PublicContentQueries,
|
||||||
|
PublicTopic,
|
||||||
|
} from "../../application/ports/public-content-queries.ts";
|
||||||
|
|
||||||
export type RecordFilters = {
|
export type RecordFilters = {
|
||||||
kind?: RecordKind;
|
kind?: RecordKind;
|
||||||
@@ -98,6 +101,24 @@ export function getProjectActivity(projectSlug: string): ProjectActivity[] {
|
|||||||
return [...(getProject(projectSlug)?.activity ?? [])];
|
return [...(getProject(projectSlug)?.activity ?? [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정적 카탈로그에는 주제 테이블이 없다 — 기록마다 붙은 주제 이름이 있을 뿐이다. 그것을 모아
|
||||||
|
* 세면 백엔드의 `listPublicTopics` 와 같은 모양이 되고, 이 어댑터의 목적(백엔드 없이 화면을
|
||||||
|
* 그린다)에도 맞는다.
|
||||||
|
*/
|
||||||
|
export function listTopics(): PublicTopic[] {
|
||||||
|
const counts = new Map<string, { name: string; slug: string; recordCount: number }>();
|
||||||
|
for (const record of listRecords()) {
|
||||||
|
if (!record.topic) continue;
|
||||||
|
const existing = counts.get(record.topicSlug);
|
||||||
|
if (existing) existing.recordCount += 1;
|
||||||
|
else counts.set(record.topicSlug, { name: record.topic, slug: record.topicSlug, recordCount: 1 });
|
||||||
|
}
|
||||||
|
return [...counts.values()]
|
||||||
|
.sort((left, right) => right.recordCount - left.recordCount || (left.name < right.name ? -1 : 1))
|
||||||
|
.map((entry) => Object.freeze(entry));
|
||||||
|
}
|
||||||
|
|
||||||
export function getHomeFocusItems(): HomeFocusItem[] {
|
export function getHomeFocusItems(): HomeFocusItem[] {
|
||||||
const project = getProject("backend-skeleton");
|
const project = getProject("backend-skeleton");
|
||||||
const question = getRecord("QUESTION", "validate-edge-token-again");
|
const question = getRecord("QUESTION", "validate-edge-token-again");
|
||||||
@@ -240,6 +261,9 @@ export const publicContentQueries = Object.freeze({
|
|||||||
async getProjectActivity(projectSlug: string) {
|
async getProjectActivity(projectSlug: string) {
|
||||||
return getProjectActivity(projectSlug);
|
return getProjectActivity(projectSlug);
|
||||||
},
|
},
|
||||||
|
async listTopics() {
|
||||||
|
return listTopics();
|
||||||
|
},
|
||||||
async getHomeFocusItems() {
|
async getHomeFocusItems() {
|
||||||
return getHomeFocusItems();
|
return getHomeFocusItems();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
} from "../../contracts/management/contract.ts";
|
} from "../../contracts/management/contract.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 주제·프로젝트·릴리즈 관리 표면.
|
* 주제·프로젝트·릴리즈·작업본 삭제 관리 표면.
|
||||||
*
|
*
|
||||||
* <p>Studio 게이트웨이와 같은 실패 규약이다 — 실패는 던지고, 화면이 잡는다. MOCK 대응물을 두지
|
* <p>Studio 게이트웨이와 같은 실패 규약이다 — 실패는 던지고, 화면이 잡는다. MOCK 대응물을 두지
|
||||||
* 않는 것도 의도다: 이 표면은 백엔드가 없으면 존재할 이유가 없고, 픽스처를 만들면 실제로는 만들
|
* 않는 것도 의도다: 이 표면은 백엔드가 없으면 존재할 이유가 없고, 픽스처를 만들면 실제로는 만들
|
||||||
@@ -34,4 +34,10 @@ export type ManagementGateway = Readonly<{
|
|||||||
deleteRelease(id: string, expectedVersion: number): Promise<void>;
|
deleteRelease(id: string, expectedVersion: number): Promise<void>;
|
||||||
publishRelease(id: string, expectedVersion: number): Promise<PublishResponse>;
|
publishRelease(id: string, expectedVersion: number): Promise<PublishResponse>;
|
||||||
archiveRelease(id: string, expectedVersion: number): Promise<ReleaseEditResponse>;
|
archiveRelease(id: string, expectedVersion: number): Promise<ReleaseEditResponse>;
|
||||||
|
/**
|
||||||
|
* 작업본 삭제. 종류마다 다른 endpoint 인 것은 계약의 모양이자 저장 구조다 — Case 와 Reference
|
||||||
|
* 는 한 테이블을 나눠 쓰고 Question 은 다른 테이블이다. Decision 은 계약에 삭제가 없다:
|
||||||
|
* 그쪽 수명주기는 수락·기각·대체이고, 그건 지우는 것이 아니라 무슨 일이 있었는지 남기는 것이다.
|
||||||
|
*/
|
||||||
|
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION", id: string, expectedVersion: number): Promise<void>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ export type Release = {
|
|||||||
related: ReadonlyArray<{ title: string; path: string }>;
|
related: ReadonlyArray<{ title: string; path: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 계약 `TopicSummary`. 목록에 필요한 만큼만 옮긴다. */
|
||||||
|
export type PublicTopic = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
recordCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type FocusKey = "current" | "question" | "decision";
|
export type FocusKey = "current" | "question" | "decision";
|
||||||
|
|
||||||
export type HomeFocusItem = {
|
export type HomeFocusItem = {
|
||||||
@@ -190,6 +197,11 @@ export type PublicContentQueries = Readonly<{
|
|||||||
getProjectRecords(projectSlug: string): Promise<PublicRecord[]>;
|
getProjectRecords(projectSlug: string): Promise<PublicRecord[]>;
|
||||||
getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]>;
|
getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]>;
|
||||||
getProjectActivity(projectSlug: string): Promise<ProjectActivity[]>;
|
getProjectActivity(projectSlug: string): Promise<ProjectActivity[]>;
|
||||||
|
/**
|
||||||
|
* 공개된 주제 목록. 프로필의 "주요 관심 주제"가 이 값을 그린다 — 그 목록은 코드에 박혀
|
||||||
|
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
|
||||||
|
*/
|
||||||
|
listTopics(): Promise<PublicTopic[]>;
|
||||||
getHomeFocusItems(): Promise<HomeFocusItem[]>;
|
getHomeFocusItems(): Promise<HomeFocusItem[]>;
|
||||||
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
|
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"packageId": "@tech-log/management-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"digest": "sha256:e16a9695e9034a273c769792ea8901fd706c61514137228b2c20e1b117ea0c01",
|
"digest": "sha256:9efc7709b29404db96a7cc7acecdd9ec7b27cb95f3389e5da2c4953e2e3e23aa",
|
||||||
"sourceRevision": "0c10a4a",
|
"sourceRevision": "332b11f",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"createCaseDraft",
|
"createCaseDraft",
|
||||||
"getCaseForEdit",
|
"getCaseForEdit",
|
||||||
|
|||||||
@@ -902,7 +902,7 @@ export interface components {
|
|||||||
* @description `INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.
|
* @description `INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "REQUEST_VALIDATION_FAILED" | "VERSION_CONFLICT" | "TOPIC_NOT_FOUND" | "TOPIC_NAME_TAKEN" | "TOPIC_SLUG_TAKEN" | "TOPIC_IN_USE" | "PROJECT_NOT_FOUND" | "PROJECT_SLUG_TAKEN" | "PROJECT_IN_USE" | "RELEASE_NOT_FOUND" | "RELEASE_VERSION_TAKEN" | "RELEASE_NOT_PUBLISHABLE" | "INTERNAL_ERROR";
|
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "REQUEST_VALIDATION_FAILED" | "VERSION_CONFLICT" | "TOPIC_NOT_FOUND" | "TOPIC_NAME_TAKEN" | "TOPIC_SLUG_TAKEN" | "TOPIC_IN_USE" | "PROJECT_NOT_FOUND" | "PROJECT_SLUG_TAKEN" | "PROJECT_IN_USE" | "RELEASE_NOT_FOUND" | "RELEASE_VERSION_TAKEN" | "RELEASE_NOT_PUBLISHABLE" | "DOCUMENT_NOT_FOUND" | "DOCUMENT_PUBLISHED" | "DOCUMENT_IN_USE" | "QUESTION_NOT_FOUND" | "QUESTION_IN_USE" | "INTERNAL_ERROR";
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL";
|
category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL";
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1922,7 +1922,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -1931,7 +1931,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Forbidden */
|
/** @description Forbidden */
|
||||||
@@ -1940,7 +1940,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
@@ -1949,7 +1949,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Conflict */
|
/** @description Conflict */
|
||||||
@@ -1958,7 +1958,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unprocessable Content */
|
/** @description Unprocessable Content */
|
||||||
@@ -1967,7 +1967,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Internal Server Error */
|
/** @description Internal Server Error */
|
||||||
@@ -1976,7 +1976,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2258,7 +2258,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -2267,7 +2267,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Forbidden */
|
/** @description Forbidden */
|
||||||
@@ -2276,7 +2276,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
@@ -2285,7 +2285,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Conflict */
|
/** @description Conflict */
|
||||||
@@ -2294,7 +2294,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unprocessable Content */
|
/** @description Unprocessable Content */
|
||||||
@@ -2303,7 +2303,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Internal Server Error */
|
/** @description Internal Server Error */
|
||||||
@@ -2312,7 +2312,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -3936,7 +3936,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -3945,7 +3945,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Forbidden */
|
/** @description Forbidden */
|
||||||
@@ -3954,7 +3954,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
@@ -3963,7 +3963,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Conflict */
|
/** @description Conflict */
|
||||||
@@ -3972,7 +3972,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unprocessable Content */
|
/** @description Unprocessable Content */
|
||||||
@@ -3981,7 +3981,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Internal Server Error */
|
/** @description Internal Server Error */
|
||||||
@@ -3990,7 +3990,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ info:
|
|||||||
description: |-
|
description: |-
|
||||||
⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다.
|
⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다.
|
||||||
|
|
||||||
topics 4개, projects 5개, releases 7개를 studio-v1.yaml과 같은 방식으로
|
topics 4개, projects 5개, releases 7개, 그리고 작업본 삭제 3개
|
||||||
변환했다 (`application/json` + ErrorEnvelope / <Payload>Envelope). 그 16개가
|
(deleteCaseDraft / deleteReferenceDraft / deleteQuestion)를 studio-v1.yaml과
|
||||||
구현된 것이자 소비자가 있는 것이기 때문이다.
|
같은 방식으로 변환했다 (`application/json` + ErrorEnvelope / <Payload>Envelope).
|
||||||
|
그 19개가 구현된 것이자 소비자가 있는 것이기 때문이다.
|
||||||
|
|
||||||
나머지 63개는 아직 bare payload + `application/problem+json` + ProblemDetails
|
나머지 60개는 아직 bare payload + `application/problem+json` + ProblemDetails
|
||||||
다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리
|
다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리
|
||||||
변환해 두면 검증되지 않은 모양이 계약에 고정된다.
|
변환해 두면 검증되지 않은 모양이 계약에 고정된다.
|
||||||
|
|
||||||
@@ -260,45 +261,45 @@ paths:
|
|||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -509,45 +510,45 @@ paths:
|
|||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -1795,45 +1796,45 @@ paths:
|
|||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -5270,6 +5271,11 @@ components:
|
|||||||
- RELEASE_NOT_FOUND
|
- RELEASE_NOT_FOUND
|
||||||
- RELEASE_VERSION_TAKEN
|
- RELEASE_VERSION_TAKEN
|
||||||
- RELEASE_NOT_PUBLISHABLE
|
- RELEASE_NOT_PUBLISHABLE
|
||||||
|
- DOCUMENT_NOT_FOUND
|
||||||
|
- DOCUMENT_PUBLISHED
|
||||||
|
- DOCUMENT_IN_USE
|
||||||
|
- QUESTION_NOT_FOUND
|
||||||
|
- QUESTION_IN_USE
|
||||||
- INTERNAL_ERROR
|
- INTERNAL_ERROR
|
||||||
description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.'
|
description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.'
|
||||||
category:
|
category:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"packageId": "@tech-log/public-contract",
|
"packageId": "@tech-log/public-contract",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e",
|
"digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e",
|
||||||
"sourceRevision": "0c10a4a",
|
"sourceRevision": "332b11f",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getPublicSite",
|
"getPublicSite",
|
||||||
"getPublicHome",
|
"getPublicHome",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4",
|
"digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4",
|
||||||
"sourceRevision": "0c10a4a",
|
"sourceRevision": "332b11f",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getStudioSession",
|
"getStudioSession",
|
||||||
"getStudioDashboard",
|
"getStudioDashboard",
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ const MANAGEMENT_ERROR_CODES = Object.freeze([
|
|||||||
"RELEASE_NOT_FOUND",
|
"RELEASE_NOT_FOUND",
|
||||||
"RELEASE_VERSION_TAKEN",
|
"RELEASE_VERSION_TAKEN",
|
||||||
"RELEASE_NOT_PUBLISHABLE",
|
"RELEASE_NOT_PUBLISHABLE",
|
||||||
|
"DOCUMENT_NOT_FOUND",
|
||||||
|
"DOCUMENT_PUBLISHED",
|
||||||
|
"DOCUMENT_IN_USE",
|
||||||
|
"QUESTION_NOT_FOUND",
|
||||||
|
"QUESTION_IN_USE",
|
||||||
"INTERNAL_ERROR",
|
"INTERNAL_ERROR",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -159,6 +164,7 @@ const byId = (input: never) => {
|
|||||||
const T = "/api/v1/studio/topics";
|
const T = "/api/v1/studio/topics";
|
||||||
const P = "/api/v1/studio/projects";
|
const P = "/api/v1/studio/projects";
|
||||||
const R = "/api/v1/studio/releases";
|
const R = "/api/v1/studio/releases";
|
||||||
|
const D = "/api/v1/studio";
|
||||||
|
|
||||||
const HTTP_CONTRACTS = Object.freeze([
|
const HTTP_CONTRACTS = Object.freeze([
|
||||||
readOperation("listStudioTopics", T, 131_072),
|
readOperation("listStudioTopics", T, 131_072),
|
||||||
@@ -330,6 +336,63 @@ const HTTP_CONTRACTS = Object.freeze([
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteCaseDraft",
|
||||||
|
"DELETE",
|
||||||
|
`${D}/cases/{id}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteReferenceDraft",
|
||||||
|
"DELETE",
|
||||||
|
`${D}/references/{id}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteQuestion",
|
||||||
|
"DELETE",
|
||||||
|
`${D}/questions/{id}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze(
|
export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze(
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ const principles = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const;
|
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
// The two project slugs this named were the static fixture's, and they exist
|
// The two project slugs this named were the static fixture's, and they exist
|
||||||
// in no real deployment — the page asked the backend for them, took two 404s,
|
// in no real deployment — the page asked the backend for them, took two 404s,
|
||||||
@@ -34,10 +32,14 @@ export function ProfilePage() {
|
|||||||
const entries = (await queries.searchPublicContent("")).filter(
|
const entries = (await queries.searchPublicContent("")).filter(
|
||||||
(item) => item.contentType === "PROJECT",
|
(item) => item.contentType === "PROJECT",
|
||||||
);
|
);
|
||||||
const resolved = await Promise.all(
|
const [resolved, topics] = await Promise.all([
|
||||||
entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))),
|
Promise.all(entries.map((item) => queries.getProject(item.path.replace("/projects/", "")))),
|
||||||
);
|
queries.listTopics(),
|
||||||
return { currentProjects: resolved.filter((project) => project !== undefined) };
|
]);
|
||||||
|
return {
|
||||||
|
currentProjects: resolved.filter((project) => project !== undefined),
|
||||||
|
topics,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only the project list comes from the network. Returning the page-wide
|
// Only the project list comes from the network. Returning the page-wide
|
||||||
@@ -96,14 +98,24 @@ export function ProfilePage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{/*
|
||||||
|
이 목록은 코드에 네 개가 박혀 있었다 — Studio 에서 주제를 만들거나 지워도 프로필은
|
||||||
|
그대로였고, 고치려면 배포를 다시 해야 했다. 이제 공개 주제 목록을 그대로 그린다.
|
||||||
|
*/}
|
||||||
<section className="profile-topics" aria-labelledby="profile-topics-title">
|
<section className="profile-topics" aria-labelledby="profile-topics-title">
|
||||||
<p className="section-kicker">Topics</p>
|
<p className="section-kicker">Topics</p>
|
||||||
<h2 id="profile-topics-title">주요 관심 주제</h2>
|
<h2 id="profile-topics-title">주요 관심 주제</h2>
|
||||||
<ul>
|
{!view.ready ? (
|
||||||
{topics.map((topic) => (
|
view.fallback
|
||||||
<li key={topic}>{topic}</li>
|
) : view.data.topics.length === 0 ? (
|
||||||
))}
|
<p className="public-empty-note">아직 등록한 주제가 없습니다.</p>
|
||||||
</ul>
|
) : (
|
||||||
|
<ul>
|
||||||
|
{view.data.topics.map((topic) => (
|
||||||
|
<li key={topic.slug}>{topic.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,8 +29,16 @@ function isAbortError(error: unknown): boolean {
|
|||||||
return error instanceof DOMException && error.name === "AbortError";
|
return error instanceof DOMException && error.name === "AbortError";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decision 은 지울 수 없다. 계약에 삭제 operation 이 없고, 그건 누락이 아니라 판단이다 — 결정의
|
||||||
|
* 수명주기는 수락·기각·대체이고 그 셋은 무슨 일이 있었는지 남기는 반면 삭제는 없앤다.
|
||||||
|
*/
|
||||||
|
const DELETABLE_KINDS = new Set(["CASE", "REFERENCE", "QUESTION"]);
|
||||||
|
|
||||||
export function DocumentList() {
|
export function DocumentList() {
|
||||||
const { gateway } = useStudio();
|
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState("");
|
||||||
const [searchDraft, setSearchDraft] = useState("");
|
const [searchDraft, setSearchDraft] = useState("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [kind, setKind] = useState<ListDocumentsQuery["kind"]>();
|
const [kind, setKind] = useState<ListDocumentsQuery["kind"]>();
|
||||||
@@ -42,6 +50,33 @@ export function DocumentList() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [retryGeneration, setRetryGeneration] = useState(0);
|
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목록 행에는 version 이 없다 (계약의 `DocumentSummary`). 삭제는 expectedVersion 을 요구하므로
|
||||||
|
* 지우기 직전에 작업본을 한 번 읽어 그 시점의 version 을 쓴다 — 목록을 띄워 둔 채 다른 곳에서
|
||||||
|
* 수정된 경우 여기서 409 로 걸리는 편이, 목록이 기억하던 낡은 version 으로 지우는 것보다 낫다.
|
||||||
|
*/
|
||||||
|
const removeDocument = async (item: DocumentPage["items"][number]) => {
|
||||||
|
if (deletingId !== null) return;
|
||||||
|
setDeletingId(item.id);
|
||||||
|
setDeleteError("");
|
||||||
|
try {
|
||||||
|
const detail = await gateway.getDocument(item.id);
|
||||||
|
await managementGateway.deleteDocument(
|
||||||
|
item.kind as "CASE" | "REFERENCE" | "QUESTION",
|
||||||
|
item.id,
|
||||||
|
detail.document.version,
|
||||||
|
);
|
||||||
|
setRequestAnnouncement(`작업본 ${item.title || "제목 없음"} 을(를) 삭제했습니다.`);
|
||||||
|
setRetryGeneration((value) => value + 1);
|
||||||
|
} catch {
|
||||||
|
setDeleteError(
|
||||||
|
"삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -169,6 +204,11 @@ export function DocumentList() {
|
|||||||
{page && !loading && !error ? (
|
{page && !loading && !error ? (
|
||||||
<>
|
<>
|
||||||
<p className="studio-result-count">{page.items.length}개의 작업본</p>
|
<p className="studio-result-count">{page.items.length}개의 작업본</p>
|
||||||
|
{deleteError ? (
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
{deleteError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{page.items.length ? (
|
{page.items.length ? (
|
||||||
<div className="studio-document-list">
|
<div className="studio-document-list">
|
||||||
{page.items.map((item) => (
|
{page.items.map((item) => (
|
||||||
@@ -202,6 +242,16 @@ export function DocumentList() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{DELETABLE_KINDS.has(item.kind) ? (
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={deletingId !== null}
|
||||||
|
onClick={() => void removeDocument(item)}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -361,7 +361,11 @@ describe("TechLog release and profile screens", () => {
|
|||||||
).toEqual(["/projects/backend-skeleton", "/projects/auth-lab"]);
|
).toEqual(["/projects/backend-skeleton", "/projects/auth-lab"]);
|
||||||
expect(
|
expect(
|
||||||
Array.from(container.querySelectorAll(".profile-topics li"), (item) => item.textContent),
|
Array.from(container.querySelectorAll(".profile-topics li"), (item) => item.textContent),
|
||||||
).toEqual(["Backend Architecture", "JPA", "Authentication", "Redis"]);
|
// Derived from the catalogue, not a literal in the page. The old hard-coded
|
||||||
|
// list opened with "Backend Architecture", which no record in the fixture
|
||||||
|
// actually carries — the profile was advertising a topic that did not
|
||||||
|
// exist, and nothing could have caught it while the list lived in the JSX.
|
||||||
|
).toEqual(["JPA", "Authentication", "Redis"]);
|
||||||
expect(within(main).queryByText(/이메일|연락처|경력|소속|회사/)).not.toBeInTheDocument();
|
expect(within(main).queryByText(/이메일|연락처|경력|소속|회사/)).not.toBeInTheDocument();
|
||||||
expect(container.querySelector('a[href^="mailto:"]')).toBeNull();
|
expect(container.querySelector('a[href^="mailto:"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user