feat: 프로젝트를 편집하고 활동을 남길 수 있게 한다
홈의 집중 카드가 제목만 보여 주고 "현재 목표"·"다음 작업" 칸이 비어 있었다. 프로젝트 페이지의 "활동" 도 늘 비어 있었다. 프로젝트를 만들 수는 있었지만 고칠 화면이 없었다. 그래서 이름과 slug 말고는 아무 값도 가질 수 없었고, 그 값을 읽는 공개 화면들은 빈칸을 그렸다 — 백엔드의 `updateProject` 는 처음부터 구현돼 있었고 채울 화면만 없었다. `/studio/projects/:id` 를 연다. 프로젝트 필드 전부와 활동 목록을 한 화면에 둔다 — 프로젝트 밖의 활동은 존재하지 않고, 무엇이 공개 타임라인에 실리는지를 프로젝트 필드와 같은 자리에서 보는 편이 낫다. 이미 공개된 프로젝트는 저장한 뒤 투영도 함께 갱신한다. 투영은 게시할 때 세워지므로, 저장만 하면 공개 화면에는 예전 값이 남는다. 단계와 활동 유형의 선택지는 DB CHECK 제약과 같은 목록이다. 화면이 더 많은 값을 보여 주면 저장이 제약에서 터지고, 작성자는 왜 안 되는지 알 수 없다. 라우트가 하나 늘어 CI 게이트가 함께 움직였다 — FE-GATE-009 는 설치된 라우트마다 수동 접근성 증거를 하나씩 요구하고 그 집합이 정확히 일치하지 않으면 거절한다. 아티팩트 기준선 132→133, 증거 개수 111→112, 게이트 형태 다이제스트 재계산(이전 상수 187dbd96… 을 이전 gates.json 에서 먼저 재현해 계산 방법을 확인했다), 서빙 패턴 하나 추가. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
co-authored by
Claude Opus 5
parent
0eb3c86839
commit
16e5b9f4ec
@@ -0,0 +1,18 @@
|
||||
# TECH_LOG_STUDIO_PROJECT_EDIT accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: TECH_LOG_STUDIO_PROJECT_EDIT
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: pending
|
||||
M5 Error association: pending
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
|
||||
@@ -1202,6 +1202,12 @@
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md",
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
|
||||
@@ -1962,6 +1968,7 @@
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
|
||||
|
||||
@@ -38,28 +38,28 @@
|
||||
},
|
||||
"contractSet": {
|
||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||
"setDigest": "sha256:5fcc7f278c09fdca2a50a65ab669039c27a0a9a1b4e56062f1309f46a7a90ab7",
|
||||
"setDigest": "sha256:9e51906d7d3b8c1b6a475ea0bd2f6bb28aadba832a29e13f635b5b5acefa2d8e",
|
||||
"packages": [
|
||||
{
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:c143ad3303eb05a02320940e2a0f5300a38b0c05f40b5bd5cf30960ef8004573",
|
||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "ed04872"
|
||||
"sourceRevision": "436937f"
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.1.0",
|
||||
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "ed04872"
|
||||
"sourceRevision": "436937f"
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.1.0",
|
||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "ed04872"
|
||||
"sourceRevision": "436937f"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -468,7 +468,10 @@ const CANONICAL_GATE_SHAPE_SHA256 =
|
||||
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
|
||||
// was first reproduced from the previous gates.json, so the computation that
|
||||
// produced this value is known to be the one the constant was pinned under.
|
||||
"187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865";
|
||||
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
|
||||
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
|
||||
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
|
||||
"f9e7e521c1ea587c992f475e38f0c5f2ca39721970724529fa98678cc078a6c7";
|
||||
|
||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||
const normalized = gates.map(
|
||||
@@ -523,8 +526,11 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
||||
// Alignment follow-up, item 2 added the TechLog junit report.
|
||||
// The taxonomy route added its own manual a11y evidence file — every installed
|
||||
// route carries one, and the gate checks that the two sets match exactly.
|
||||
if (contract.artifacts.length !== 132) {
|
||||
failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`);
|
||||
// The project edit route did the same: it is what finally lets a project carry
|
||||
// a purpose, a current objective, and a next step, so the public screens that
|
||||
// read those fields stop rendering blanks.
|
||||
if (contract.artifacts.length !== 133) {
|
||||
failures.push(`artifact authority baseline must contain exactly 133 artifacts; received ${contract.artifacts.length}`);
|
||||
}
|
||||
if (contract.stages.length !== 5) {
|
||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||
|
||||
@@ -2,6 +2,9 @@ import type {
|
||||
CreateDraftResponse,
|
||||
HomeFocusRequest,
|
||||
HomeFocusResponse,
|
||||
ProjectActivityRequest,
|
||||
ProjectActivityResponse,
|
||||
UpdateProjectActivityRequest,
|
||||
ProjectEditResponse,
|
||||
ProjectIndexPage,
|
||||
ProjectUpdateRequest,
|
||||
@@ -87,6 +90,18 @@ export function createHttpManagementGateway(
|
||||
deleteRelease: async (id: string, expectedVersion: number) => {
|
||||
await run<void>("deleteRelease", { id, expectedVersion });
|
||||
},
|
||||
listProjectActivities: (id: string) =>
|
||||
run<ProjectActivityResponse[]>("listStudioProjectActivities", { id }),
|
||||
createProjectActivity: (id: string, body: ProjectActivityRequest) =>
|
||||
run<ProjectActivityResponse>("createProjectActivity", { id, body }),
|
||||
updateProjectActivity: (
|
||||
id: string,
|
||||
activityId: string,
|
||||
body: UpdateProjectActivityRequest,
|
||||
) => run<ProjectActivityResponse>("updateProjectActivity", { id, activityId, body }),
|
||||
deleteProjectActivity: async (id: string, activityId: string, expectedVersion: number) => {
|
||||
await run<void>("deleteProjectActivity", { id, activityId, expectedVersion });
|
||||
},
|
||||
publishProject: (
|
||||
id: string,
|
||||
expectedVersion: number,
|
||||
|
||||
@@ -2,6 +2,9 @@ import type {
|
||||
CreateDraftResponse,
|
||||
HomeFocusRequest,
|
||||
HomeFocusResponse,
|
||||
ProjectActivityRequest,
|
||||
ProjectActivityResponse,
|
||||
UpdateProjectActivityRequest,
|
||||
ProjectEditResponse,
|
||||
ProjectIndexPage,
|
||||
ProjectUpdateRequest,
|
||||
@@ -41,6 +44,18 @@ export type ManagementGateway = Readonly<{
|
||||
visibility?: "PUBLIC" | "UNLISTED",
|
||||
): Promise<PublishResponse>;
|
||||
unpublishProject(id: string, expectedVersion: number): Promise<ProjectEditResponse>;
|
||||
/**
|
||||
* 프로젝트 활동. 공개 프로젝트 화면의 "활동" 은 이 목록을 투영 없이 직접 읽으므로, 여기서
|
||||
* 만든 줄이 곧 그 화면이다.
|
||||
*/
|
||||
listProjectActivities(id: string): Promise<ProjectActivityResponse[]>;
|
||||
createProjectActivity(id: string, body: ProjectActivityRequest): Promise<ProjectActivityResponse>;
|
||||
updateProjectActivity(
|
||||
id: string,
|
||||
activityId: string,
|
||||
body: UpdateProjectActivityRequest,
|
||||
): Promise<ProjectActivityResponse>;
|
||||
deleteProjectActivity(id: string, activityId: string, expectedVersion: number): Promise<void>;
|
||||
/** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */
|
||||
getHomeFocus(): Promise<HomeFocusResponse>;
|
||||
updateHomeFocus(body: HomeFocusRequest): Promise<HomeFocusResponse>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:c143ad3303eb05a02320940e2a0f5300a38b0c05f40b5bd5cf30960ef8004573",
|
||||
"sourceRevision": "ed04872",
|
||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||
"sourceRevision": "436937f",
|
||||
"operationIds": [
|
||||
"createCaseDraft",
|
||||
"getCaseForEdit",
|
||||
@@ -83,6 +83,7 @@
|
||||
"updateHomeFocus",
|
||||
"listStudioProjectActivities",
|
||||
"createProjectActivity",
|
||||
"updateProjectActivity"
|
||||
"updateProjectActivity",
|
||||
"deleteProjectActivity"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,3 +17,6 @@ export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
|
||||
export type PublishResponse = Schemas["PublishResponse"];
|
||||
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
|
||||
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
|
||||
export type ProjectActivityRequest = Schemas["ProjectActivityRequest"];
|
||||
export type UpdateProjectActivityRequest = Schemas["UpdateProjectActivityRequest"];
|
||||
export type ProjectActivityResponse = Schemas["ProjectActivityResponse"];
|
||||
|
||||
@@ -883,7 +883,12 @@ export interface paths {
|
||||
get?: never;
|
||||
put: operations["updateProjectActivity"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
/** @description 프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
|
||||
* 적은 줄이 공개 타임라인에 영구히 남았다.
|
||||
*
|
||||
* `AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
|
||||
* 있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다. */
|
||||
delete: operations["deleteProjectActivity"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@@ -969,6 +974,18 @@ export interface components {
|
||||
data: components["schemas"]["HomeFocusResponse"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
ProjectActivityResponseEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["ProjectActivityResponse"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
ProjectActivityListEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["ProjectActivityResponse"][];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
PublishResponseEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
@@ -8279,7 +8296,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectActivityResponse"][];
|
||||
"application/json": components["schemas"]["ProjectActivityListEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 401 */
|
||||
@@ -8288,7 +8305,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 403 */
|
||||
@@ -8297,7 +8314,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 404 */
|
||||
@@ -8306,7 +8323,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 500 */
|
||||
@@ -8315,7 +8332,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8343,7 +8360,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectActivityResponse"];
|
||||
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 400 */
|
||||
@@ -8352,7 +8369,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 401 */
|
||||
@@ -8361,7 +8378,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 403 */
|
||||
@@ -8370,7 +8387,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 404 */
|
||||
@@ -8379,7 +8396,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 409 */
|
||||
@@ -8388,7 +8405,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 422 */
|
||||
@@ -8397,7 +8414,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 500 */
|
||||
@@ -8406,7 +8423,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8435,7 +8452,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectActivityResponse"];
|
||||
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 400 */
|
||||
@@ -8444,7 +8461,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 401 */
|
||||
@@ -8453,7 +8470,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 403 */
|
||||
@@ -8462,7 +8479,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 404 */
|
||||
@@ -8471,7 +8488,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 409 */
|
||||
@@ -8480,7 +8497,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 422 */
|
||||
@@ -8489,7 +8506,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 500 */
|
||||
@@ -8498,7 +8515,97 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteProjectActivity: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-CSRF-TOKEN": components["parameters"]["CsrfToken"];
|
||||
};
|
||||
path: {
|
||||
id: string;
|
||||
activityId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExpectedVersionRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5101,33 +5101,31 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
$ref: '#/components/schemas/ProjectActivityListEnvelope'
|
||||
'401':
|
||||
description: '401'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: '403'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: '404'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: '500'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
post:
|
||||
operationId: createProjectActivity
|
||||
tags:
|
||||
@@ -5154,49 +5152,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
|
||||
'400':
|
||||
description: '400'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: '401'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: '403'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: '404'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: '409'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: '422'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: '500'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
/api/v1/studio/projects/{id}/activities/{activityId}:
|
||||
put:
|
||||
operationId: updateProjectActivity
|
||||
@@ -5230,49 +5228,126 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
|
||||
'400':
|
||||
description: '400'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: '401'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: '403'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: '404'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: '409'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: '422'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: '500'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
delete:
|
||||
operationId: deleteProjectActivity
|
||||
tags:
|
||||
- Projects
|
||||
description: |-
|
||||
프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
|
||||
적은 줄이 공개 타임라인에 영구히 남았다.
|
||||
|
||||
`AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
|
||||
있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: activityId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- $ref: '#/components/parameters/CsrfToken'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExpectedVersionRequest'
|
||||
responses:
|
||||
'204':
|
||||
description: No Content
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
security:
|
||||
- sessionCookie: []
|
||||
components:
|
||||
securitySchemes:
|
||||
sessionCookie:
|
||||
@@ -5525,6 +5600,38 @@ components:
|
||||
$ref: '#/components/schemas/HomeFocusResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
ProjectActivityResponseEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- meta
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
const: true
|
||||
data:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
ProjectActivityListEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- meta
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
const: true
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
PublishResponseEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.1.0",
|
||||
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
||||
"sourceRevision": "ed04872",
|
||||
"sourceRevision": "436937f",
|
||||
"operationIds": [
|
||||
"getPublicSite",
|
||||
"getPublicHome",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.1.0",
|
||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
||||
"sourceRevision": "ed04872",
|
||||
"sourceRevision": "436937f",
|
||||
"operationIds": [
|
||||
"getStudioSession",
|
||||
"getStudioDashboard",
|
||||
|
||||
@@ -310,6 +310,62 @@ const HTTP_CONTRACTS = Object.freeze([
|
||||
});
|
||||
},
|
||||
),
|
||||
readOperation("listStudioProjectActivities", `${P}/{id}/activities`, 262_144, byId),
|
||||
writeOperation(
|
||||
"createProjectActivity",
|
||||
"POST",
|
||||
`${P}/{id}/activities`,
|
||||
{ acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: value.body,
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"updateProjectActivity",
|
||||
"PUT",
|
||||
`${P}/{id}/activities/{activityId}`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{
|
||||
id: string;
|
||||
activityId: string;
|
||||
body: unknown;
|
||||
}>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: value.body,
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"deleteProjectActivity",
|
||||
"DELETE",
|
||||
`${P}/{id}/activities/{activityId}`,
|
||||
{
|
||||
acceptedStatuses: [204],
|
||||
emptyBodyStatuses: [204],
|
||||
requestByteLimit: 1_024,
|
||||
responseByteLimit: 1_024,
|
||||
},
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{
|
||||
id: string;
|
||||
activityId: string;
|
||||
expectedVersion: number;
|
||||
}>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"publishProject",
|
||||
"POST",
|
||||
|
||||
@@ -49,6 +49,7 @@ const TECH_LOG_ROUTE_SPECS = [
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type {
|
||||
ProjectActivityResponse,
|
||||
ProjectEditResponse,
|
||||
} from "../../../contracts/management/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { slugFromName } from "./slug-from-name.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/** `project_phase_check` 와 같은 목록이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */
|
||||
const PHASES = [
|
||||
["RESEARCH", "조사"],
|
||||
["DESIGN", "설계"],
|
||||
["IMPLEMENTATION", "구현"],
|
||||
["VERIFICATION", "검증"],
|
||||
["MAINTENANCE", "유지"],
|
||||
["PAUSED", "중단"],
|
||||
["COMPLETED", "완료"],
|
||||
] as const;
|
||||
|
||||
/** `project_activity_activity_type_check` 와 같은 목록이다. */
|
||||
const ACTIVITY_TYPES = [
|
||||
["MILESTONE_REACHED", "이정표 도달"],
|
||||
["PHASE_CHANGED", "단계 전환"],
|
||||
["QUESTION_OPENED", "질문 열림"],
|
||||
["QUESTION_RESOLVED", "질문 해결"],
|
||||
["DECISION_ACCEPTED", "결정 수락"],
|
||||
["CASE_PUBLISHED", "Case 게시"],
|
||||
["REFERENCE_PUBLISHED", "Reference 게시"],
|
||||
["PROJECT_PAUSED", "프로젝트 중단"],
|
||||
["PROJECT_RESUMED", "프로젝트 재개"],
|
||||
] as const;
|
||||
|
||||
/** `<input type="datetime-local">` 은 초와 시간대가 없는 지역 시각을 쓴다. */
|
||||
function toLocalInput(isoTimestamp: string): string {
|
||||
const value = new Date(isoTimestamp);
|
||||
if (Number.isNaN(value.getTime())) return "";
|
||||
const pad = (part: number) => String(part).padStart(2, "0");
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(
|
||||
value.getHours(),
|
||||
)}:${pad(value.getMinutes())}`;
|
||||
}
|
||||
|
||||
function fromLocalInput(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 한 편의 편집 화면.
|
||||
*
|
||||
* <p>이 화면이 없는 동안 프로젝트는 이름과 slug 만 가질 수 있었다. 그래서 공개 화면의 "현재 목표"·"다음
|
||||
* 작업"·"목적" 이 늘 비어 있었고, 홈의 focus 카드도 제목만 남았다 — 백엔드의 `updateProject` 는 처음부터
|
||||
* 구현돼 있었고 채울 화면만 없었다.
|
||||
*
|
||||
* <p>활동도 여기서 다룬다. 프로젝트 밖의 활동은 존재하지 않으므로 따로 둘 이유가 없고, 무엇이 공개
|
||||
* 타임라인에 실리는지를 프로젝트 필드와 같은 화면에서 보는 편이 낫다.
|
||||
*/
|
||||
export function ProjectEditor() {
|
||||
const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">();
|
||||
const projectId = params.id;
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
|
||||
const [project, setProject] = useState<ProjectEditResponse | null>(null);
|
||||
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [labels, setLabels] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
managementGateway.getProject(projectId),
|
||||
managementGateway.listProjectActivities(projectId),
|
||||
]).then(
|
||||
([loaded, activityList]) => {
|
||||
if (cancelled) return;
|
||||
setProject(loaded);
|
||||
setActivities(activityList);
|
||||
setDraft({
|
||||
name: loaded.name ?? "",
|
||||
slug: loaded.slug ?? "",
|
||||
oneLinePurpose: loaded.oneLinePurpose ?? "",
|
||||
purposeMarkdown: loaded.purposeMarkdown ?? "",
|
||||
boundaryMarkdown: loaded.boundaryMarkdown ?? "",
|
||||
systemOverviewMarkdown: loaded.systemOverviewMarkdown ?? "",
|
||||
phase: loaded.phase,
|
||||
currentObjective: loaded.currentObjective ?? "",
|
||||
nextStep: loaded.nextStep ?? "",
|
||||
});
|
||||
setLabels((loaded.technologyLabels ?? []).join(", "));
|
||||
setError("");
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [managementGateway, projectId, generation]);
|
||||
|
||||
const set = (key: string, value: string) =>
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending || !project) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
/*
|
||||
slug 는 공개 주소다. 비워 두면 게시할 수 없으므로 이름에서 만들어 채운다 — 예전에는 조용히
|
||||
빈 값으로 저장되었고, 게시 화면에 가서야 "slug 가 없다" 는 말을 들었다.
|
||||
*/
|
||||
const slug = draft.slug?.trim() || slugFromName(draft.name ?? "");
|
||||
const saved = await managementGateway.updateProject(project.id, {
|
||||
expectedVersion: project.version,
|
||||
name: (draft.name ?? "").trim(),
|
||||
slug,
|
||||
oneLinePurpose: draft.oneLinePurpose ?? "",
|
||||
purposeMarkdown: draft.purposeMarkdown ?? "",
|
||||
boundaryMarkdown: draft.boundaryMarkdown ?? "",
|
||||
systemOverviewMarkdown: draft.systemOverviewMarkdown ?? "",
|
||||
phase: draft.phase ?? "RESEARCH",
|
||||
currentObjective: draft.currentObjective ?? "",
|
||||
nextStep: draft.nextStep ?? "",
|
||||
technologyLabels: labels
|
||||
.split(",")
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean),
|
||||
targetVisibility:
|
||||
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
|
||||
? project.targetVisibility
|
||||
: "PRIVATE",
|
||||
featuredOrder: project.featuredOrder,
|
||||
});
|
||||
setProject(saved);
|
||||
setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`);
|
||||
/*
|
||||
이미 공개된 프로젝트라면 공개 투영도 함께 갱신해야 한다. 투영은 게시할 때 세워지므로,
|
||||
저장만 하면 화면에는 예전 값이 남는다.
|
||||
*/
|
||||
if (saved.targetVisibility !== "PRIVATE") {
|
||||
await managementGateway.publishProject(
|
||||
saved.id,
|
||||
saved.version,
|
||||
saved.targetVisibility === "UNLISTED" ? "UNLISTED" : "PUBLIC",
|
||||
);
|
||||
}
|
||||
reload();
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "프로젝트를 저장하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addActivity = async () => {
|
||||
if (pending || !project) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.createProjectActivity(project.id, {
|
||||
expectedProjectVersion: project.version,
|
||||
activityType: "MILESTONE_REACHED",
|
||||
title: "새 활동",
|
||||
summary: "",
|
||||
visibility: "PUBLIC",
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
setRequestAnnouncement("활동을 추가했습니다.");
|
||||
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("활동을 삭제했습니다.");
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "활동을 삭제하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (
|
||||
key: string,
|
||||
label: string,
|
||||
hint?: string,
|
||||
multiline = false,
|
||||
) => (
|
||||
<div className="studio-field" key={key}>
|
||||
<label htmlFor={`project-${key}`}>{label}</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className="studio-control studio-markdown"
|
||||
id={`project-${key}`}
|
||||
rows={5}
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(event) => set(key, event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="studio-control"
|
||||
id={`project-${key}`}
|
||||
type="text"
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(event) => set(key, event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{hint ? <p className="studio-field-note">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
<div className="studio-page-heading">
|
||||
<p className="studio-eyebrow">PROJECT</p>
|
||||
<h1>{project?.name ?? "프로젝트"}</h1>
|
||||
<p>
|
||||
공개 프로젝트 화면과 프로필의 “현재 프로젝트”, 홈의 집중 카드가 여기 값을
|
||||
그대로 읽습니다.
|
||||
</p>
|
||||
</div>
|
||||
<GuardedStudioLink className="studio-primary-action" href="/studio/taxonomy">
|
||||
목록으로
|
||||
</GuardedStudioLink>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{!project && !error ? (
|
||||
<p className="studio-loading" role="status">
|
||||
프로젝트를 불러오는 중입니다.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{project ? (
|
||||
<>
|
||||
<form className="studio-home-focus-form" onSubmit={save}>
|
||||
{field("name", "이름")}
|
||||
{field("slug", "slug", "공개 주소가 됩니다. 비우면 이름에서 만듭니다.")}
|
||||
|
||||
<div className="studio-field">
|
||||
<label htmlFor="project-phase">단계</label>
|
||||
<select
|
||||
className="studio-control"
|
||||
id="project-phase"
|
||||
value={draft.phase ?? "RESEARCH"}
|
||||
onChange={(event) => set("phase", event.target.value)}
|
||||
>
|
||||
{PHASES.map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label} ({value})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{field("oneLinePurpose", "한 줄 목적", "프로젝트 목록과 프로필 카드에 나옵니다.")}
|
||||
{field("currentObjective", "현재 목표", "홈의 집중 카드에 나옵니다.")}
|
||||
{field("nextStep", "다음 작업", "홈의 집중 카드에 나옵니다.")}
|
||||
|
||||
<div className="studio-field">
|
||||
<label htmlFor="project-labels">기술 라벨</label>
|
||||
<input
|
||||
className="studio-control"
|
||||
id="project-labels"
|
||||
type="text"
|
||||
value={labels}
|
||||
onChange={(event) => setLabels(event.target.value)}
|
||||
/>
|
||||
<p className="studio-field-note">쉼표로 구분합니다.</p>
|
||||
</div>
|
||||
|
||||
{field("purposeMarkdown", "목적", undefined, true)}
|
||||
{field("boundaryMarkdown", "경계", undefined, true)}
|
||||
{field("systemOverviewMarkdown", "시스템 개요", undefined, true)}
|
||||
|
||||
<div className="studio-row-actions">
|
||||
<button className="studio-primary-button" type="submit" disabled={pending}>
|
||||
{pending ? "저장하는 중" : "저장"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="studio-work-section" aria-labelledby="project-activity-title">
|
||||
<div className="studio-section-title">
|
||||
<h2 id="project-activity-title">활동</h2>
|
||||
<button
|
||||
className="studio-text-action"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void addActivity()}
|
||||
>
|
||||
활동 추가
|
||||
</button>
|
||||
</div>
|
||||
<p className="studio-field-note">
|
||||
공개로 둔 줄만 프로젝트 화면의 타임라인에 나옵니다.
|
||||
</p>
|
||||
{activities.length === 0 ? (
|
||||
<p className="studio-empty-inline">아직 활동이 없습니다.</p>
|
||||
) : (
|
||||
<div className="studio-document-list">
|
||||
{activities.map((activity) => (
|
||||
<article className="studio-document-row" key={activity.id}>
|
||||
<p className="studio-row-label">
|
||||
{ACTIVITY_TYPES.find(([value]) => value === activity.activityType)?.[1] ??
|
||||
activity.activityType}
|
||||
</p>
|
||||
<div className="studio-document-title">
|
||||
<div className="studio-field">
|
||||
<label htmlFor={`activity-title-${activity.id}`}>제목</label>
|
||||
<input
|
||||
className="studio-control"
|
||||
id={`activity-title-${activity.id}`}
|
||||
type="text"
|
||||
defaultValue={activity.title}
|
||||
onBlur={(event) =>
|
||||
event.target.value !== activity.title &&
|
||||
void saveActivity(activity, { title: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="studio-field">
|
||||
<label htmlFor={`activity-summary-${activity.id}`}>요약</label>
|
||||
<input
|
||||
className="studio-control"
|
||||
id={`activity-summary-${activity.id}`}
|
||||
type="text"
|
||||
defaultValue={activity.summary ?? ""}
|
||||
onBlur={(event) =>
|
||||
event.target.value !== (activity.summary ?? "") &&
|
||||
void saveActivity(activity, { summary: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>일시</dt>
|
||||
<dd>
|
||||
<input
|
||||
className="studio-control"
|
||||
type="datetime-local"
|
||||
aria-label="활동 일시"
|
||||
defaultValue={toLocalInput(activity.occurredAt)}
|
||||
onBlur={(event) =>
|
||||
void saveActivity(activity, {
|
||||
occurredAt: fromLocalInput(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>노출</dt>
|
||||
<dd>
|
||||
<select
|
||||
className="studio-control"
|
||||
aria-label="활동 노출"
|
||||
value={activity.visibility}
|
||||
onChange={(event) =>
|
||||
void saveActivity(activity, { visibility: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="PUBLIC">공개</option>
|
||||
<option value="PRIVATE">비공개</option>
|
||||
</select>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="studio-row-actions">
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending || activity.origin !== "MANUAL"}
|
||||
title={
|
||||
activity.origin === "MANUAL"
|
||||
? undefined
|
||||
: "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다."
|
||||
}
|
||||
onClick={() => void removeActivity(activity)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { slugFromName } from "./slug-from-name.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
@@ -300,7 +301,11 @@ export function TaxonomyManager() {
|
||||
<article className="studio-document-row" key={project.id}>
|
||||
<p className="studio-row-label">PROJECT</p>
|
||||
<div className="studio-document-title">
|
||||
<h2>{project.name}</h2>
|
||||
<h2>
|
||||
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
|
||||
{project.name}
|
||||
</GuardedStudioLink>
|
||||
</h2>
|
||||
<p>{project.currentObjective ?? "목표 미지정"}</p>
|
||||
</div>
|
||||
<dl>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProjectEditor } from "../components/project-editor.tsx";
|
||||
|
||||
export function ProjectEditPage() {
|
||||
return <ProjectEditor />;
|
||||
}
|
||||
@@ -216,6 +216,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
||||
"TaxonomyPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_PROJECT_EDIT: runtime(
|
||||
"TECH_LOG_STUDIO_PROJECT_EDIT",
|
||||
routeModule(
|
||||
() => import("./studio/pages/project-edit-page.tsx"),
|
||||
"ProjectEditPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_RELEASES: runtime(
|
||||
"TECH_LOG_STUDIO_RELEASES",
|
||||
routeModule(
|
||||
|
||||
@@ -200,8 +200,8 @@ describe("CI gate contract", () => {
|
||||
// than taking either side's number.
|
||||
// Task 11 added TECH_LOG_STUDIO_ASSETS's manual accessibility evidence.
|
||||
// Alignment follow-up item 2 added the TechLog junit report.
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(111);
|
||||
expect(contract.artifacts).toHaveLength(132);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(112);
|
||||
expect(contract.artifacts).toHaveLength(133);
|
||||
expect(contract.stages).toHaveLength(5);
|
||||
expect(contract.retention.classes).toHaveLength(5);
|
||||
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
||||
|
||||
@@ -192,7 +192,7 @@ describe("selective Task 3 contract closure", () => {
|
||||
expect(canonical.gates).toHaveLength(27);
|
||||
expect(canonical.commands).toHaveLength(85);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(97);
|
||||
expect(canonical.artifacts).toHaveLength(132);
|
||||
expect(canonical.artifacts).toHaveLength(133);
|
||||
expect(canonical.stages).toHaveLength(5);
|
||||
expect(canonical.retention.classes).toHaveLength(5);
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ describe("TechLog production serving contract", () => {
|
||||
"^/studio/documents/[^/]+/publish$",
|
||||
"^/studio/documents/[^/]+/validation$",
|
||||
"^/studio/documents/new$",
|
||||
"^/studio/projects/[^/]+$",
|
||||
"^/studio/publications$",
|
||||
"^/studio/publications/[^/]+/preview$",
|
||||
"^/studio/releases$",
|
||||
|
||||
@@ -32,6 +32,7 @@ const techLogRouteChunks = Object.freeze({
|
||||
"/studio/pages/publication-preview-page.tsx": "route-tech-log-studio-publication-preview",
|
||||
"/studio/pages/assets-page.tsx": "route-tech-log-studio-assets",
|
||||
"/studio/pages/taxonomy-page.tsx": "route-tech-log-studio-taxonomy",
|
||||
"/studio/pages/project-edit-page.tsx": "route-tech-log-studio-project-edit",
|
||||
"/studio/pages/releases-page.tsx": "route-tech-log-studio-releases",
|
||||
"/studio/pages/studio-not-found-page.tsx": "route-tech-log-studio-not-found",
|
||||
"/public/pages/public-not-found-page.tsx": "route-not-found",
|
||||
|
||||
Reference in New Issue
Block a user