fix: 오류 수정
This commit is contained in:
@@ -38,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"contractSet": {
|
"contractSet": {
|
||||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||||
"setDigest": "sha256:8b5bcbc235bc825483002374f84d433e2668a613e270e6558934fa277bd62762",
|
"setDigest": "sha256:7ee35548d2a84b744f8c17c7b785a79b328dcf756f5c9df8d77d37ff1535b087",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/management-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/public-contract",
|
"packageId": "@tech-log/public-contract",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
|
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "b195b29"
|
"sourceRevision": "b195b29"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -162,12 +162,27 @@ export function createHttpPublicContentGateway(
|
|||||||
kind: "CASE",
|
kind: "CASE",
|
||||||
problem: (body.problemSummary as string) ?? "",
|
problem: (body.problemSummary as string) ?? "",
|
||||||
conclusion: (body.conclusionSummary as string) ?? "",
|
conclusion: (body.conclusionSummary as string) ?? "",
|
||||||
environment: ((body.environmentSummary as readonly string[]) ?? []).join(", "),
|
// `environmentSummary` 는 검증 환경과 재현 조건을 그 순서로 담는다 — 서버가 비어 있지
|
||||||
// The Case document renders a verification line. The contract has no
|
// 않은 것만 순서대로 넣는다. 예전에는 둘을 쉼표로 이어 붙여 한 칸에 넣고 재현 조건 칸은
|
||||||
// field for it — verification lives in the body — so it stays empty
|
// "계약에 없다"며 비워 두었는데, 계약에는 있었고 채우는 쪽이 없었을 뿐이다.
|
||||||
// rather than being guessed from a heading.
|
environment: ((body.environmentSummary as readonly string[]) ?? [])[0] ?? "",
|
||||||
verification: "",
|
verification: ((body.environmentSummary as readonly string[]) ?? [])[1] ?? "",
|
||||||
lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string),
|
lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string),
|
||||||
|
content: (body.content as string) ?? "",
|
||||||
|
bodyAssets: Object.freeze(
|
||||||
|
((body.bodyAssets as readonly Readonly<Record<string, unknown>>[]) ?? []).map((asset) =>
|
||||||
|
Object.freeze({
|
||||||
|
assetKey: asset.assetKey as string,
|
||||||
|
assetId: asset.assetId as string,
|
||||||
|
url: asset.url as string,
|
||||||
|
contentType: asset.contentType as string,
|
||||||
|
altText: (asset.altText as string) ?? "",
|
||||||
|
width: (asset.width as number) ?? null,
|
||||||
|
height: (asset.height as number) ?? null,
|
||||||
|
decorative: Boolean(asset.decorative),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
sections: markdownSections(body.content as string),
|
sections: markdownSections(body.content as string),
|
||||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ export function knowledgeListItemToRecord(item: Readonly<Record<string, unknown>
|
|||||||
environment: "",
|
environment: "",
|
||||||
verification: "",
|
verification: "",
|
||||||
lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string),
|
lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string),
|
||||||
|
// 목록 항목은 본문을 담지 않는다 — 본문은 상세 조회에서만 온다.
|
||||||
|
content: "",
|
||||||
|
bodyAssets: Object.freeze([]),
|
||||||
sections: Object.freeze([]),
|
sections: Object.freeze([]),
|
||||||
}) as CaseRecord;
|
}) as CaseRecord;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ type PublicRecordBase = {
|
|||||||
relations: ReadonlyArray<PublicRelation>;
|
relations: ReadonlyArray<PublicRelation>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 본문이 `:::evidence key="..."` 로 가리키는 Asset. 계약의 `BodyAsset` 과 같은 모양이다. */
|
||||||
|
export type PublicBodyAsset = {
|
||||||
|
assetKey: string;
|
||||||
|
assetId: string;
|
||||||
|
url: string;
|
||||||
|
contentType: string;
|
||||||
|
altText: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
decorative: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CaseRecord = PublicRecordBase & {
|
export type CaseRecord = PublicRecordBase & {
|
||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
problem: string;
|
problem: string;
|
||||||
@@ -42,6 +54,9 @@ export type CaseRecord = PublicRecordBase & {
|
|||||||
environment: string;
|
environment: string;
|
||||||
verification: string;
|
verification: string;
|
||||||
lastVerifiedLabel: string;
|
lastVerifiedLabel: string;
|
||||||
|
/** 본문 Markdown 원문. 정적 기록은 문서 화면이 자체 본문을 쓰므로 비어 있다. */
|
||||||
|
content: string;
|
||||||
|
bodyAssets: ReadonlyArray<PublicBodyAsset>;
|
||||||
sections: ReadonlyArray<RecordSection>;
|
sections: ReadonlyArray<RecordSection>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,6 +188,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
|
|||||||
environment: "PostgreSQL 16 · Hibernate 6 · Spring Data JPA",
|
environment: "PostgreSQL 16 · Hibernate 6 · Spring Data JPA",
|
||||||
verification: "FeedItem 100개, Zipf 편중 Highlight/Mention",
|
verification: "FeedItem 100개, Zipf 편중 Highlight/Mention",
|
||||||
lastVerifiedLabel: "2026.08.11",
|
lastVerifiedLabel: "2026.08.11",
|
||||||
|
content: "",
|
||||||
|
bodyAssets: [],
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "fix-the-problem",
|
id: "fix-the-problem",
|
||||||
@@ -256,6 +273,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
|
|||||||
environment: "Spring Boot · Redis · Testcontainers",
|
environment: "Spring Boot · Redis · Testcontainers",
|
||||||
verification: "동일한 Port 계약으로 In-memory와 Redis Adapter 계약 테스트 실행",
|
verification: "동일한 Port 계약으로 In-memory와 Redis Adapter 계약 테스트 실행",
|
||||||
lastVerifiedLabel: "2026.08.07",
|
lastVerifiedLabel: "2026.08.07",
|
||||||
|
content: "",
|
||||||
|
bodyAssets: [],
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "ownership",
|
id: "ownership",
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ type PublicRecordBase = {
|
|||||||
relations: ReadonlyArray<PublicRelation>;
|
relations: ReadonlyArray<PublicRelation>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문이 `:::evidence key="..."` 로 가리키는 Asset.
|
||||||
|
*
|
||||||
|
* <p>본문에는 key 만 있고 `/media/{assetId}` 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이
|
||||||
|
* 의도된 성질이다 — 공개 화면이 key 를 주소로 바꾸려면 이 대응이 함께 와야 한다.
|
||||||
|
*/
|
||||||
|
export type PublicBodyAsset = {
|
||||||
|
assetKey: string;
|
||||||
|
assetId: string;
|
||||||
|
url: string;
|
||||||
|
contentType: string;
|
||||||
|
altText: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
decorative: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CaseRecord = PublicRecordBase & {
|
export type CaseRecord = PublicRecordBase & {
|
||||||
kind: "CASE";
|
kind: "CASE";
|
||||||
problem: string;
|
problem: string;
|
||||||
@@ -43,6 +60,14 @@ export type CaseRecord = PublicRecordBase & {
|
|||||||
environment: string;
|
environment: string;
|
||||||
verification: string;
|
verification: string;
|
||||||
lastVerifiedLabel: string;
|
lastVerifiedLabel: string;
|
||||||
|
/**
|
||||||
|
* 본문 Markdown 원문.
|
||||||
|
*
|
||||||
|
* <p>`sections` 는 이것을 제목·문단·불릿으로만 줄인 것이라 표·코드·callout·evidence 가 사라진다.
|
||||||
|
* 문서 화면은 원문을 직접 파싱한다.
|
||||||
|
*/
|
||||||
|
content: string;
|
||||||
|
bodyAssets: ReadonlyArray<PublicBodyAsset>;
|
||||||
sections: ReadonlyArray<RecordSection>;
|
sections: ReadonlyArray<RecordSection>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"packageId": "@tech-log/public-contract",
|
"packageId": "@tech-log/public-contract",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
|
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
||||||
"sourceRevision": "b195b29",
|
"sourceRevision": "b195b29",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getPublicSite",
|
"getPublicSite",
|
||||||
|
|||||||
@@ -373,6 +373,30 @@ export interface components {
|
|||||||
height?: number;
|
height?: number;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
/** @description 본문이 `:::evidence key="..."` 로 가리키는 Asset 이다.
|
||||||
|
*
|
||||||
|
* 본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데, `/media/{assetId}` 는 UUID
|
||||||
|
* 로만 서빙한다 — 주소가 추측 불가능한 것이 의도된 성질이므로 key 에서 주소를 만들 수
|
||||||
|
* 없다. 그래서 공개 화면이 key 를 해석할 수 있도록, 게시된 기록이 실제로 참조하는 Asset 을
|
||||||
|
* 함께 준다.
|
||||||
|
*
|
||||||
|
* 목록은 게시 시점에 고정된 `PUBLISHED` scope 의 참조에서 온다. 게시 이후 작업본이 Asset
|
||||||
|
* 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않는다.
|
||||||
|
* */
|
||||||
|
BodyAsset: {
|
||||||
|
assetKey: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
assetId: string;
|
||||||
|
url: string;
|
||||||
|
contentType: string;
|
||||||
|
altText?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
/** @description 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개
|
||||||
|
* 화면도 같은 값을 보고 `alt` 를 정해야 판정과 표시가 어긋나지 않는다.
|
||||||
|
* */
|
||||||
|
decorative: boolean;
|
||||||
|
};
|
||||||
RelatedEntry: {
|
RelatedEntry: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
type: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT" | "PROJECT_DECISION" | "RELEASE";
|
type: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT" | "PROJECT_DECISION" | "RELEASE";
|
||||||
@@ -523,6 +547,10 @@ export interface components {
|
|||||||
tags: components["schemas"]["TagSummary"][];
|
tags: components["schemas"]["TagSummary"][];
|
||||||
primaryProject?: components["schemas"]["ProjectSummary"];
|
primaryProject?: components["schemas"]["ProjectSummary"];
|
||||||
coverAsset?: components["schemas"]["AssetReference"];
|
coverAsset?: components["schemas"]["AssetReference"];
|
||||||
|
/** @description 본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한
|
||||||
|
* Asset 이 더 이상 서빙되지 않는 경우다.
|
||||||
|
* */
|
||||||
|
bodyAssets?: components["schemas"]["BodyAsset"][];
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: Tech Log Public API
|
title: Tech Log Public API
|
||||||
version: 2.0.0
|
version: 2.1.0
|
||||||
description: |
|
description: |
|
||||||
Tech Log 공개 조회 계약이다. 인증이 필요하지 않다.
|
Tech Log 공개 조회 계약이다. 인증이 필요하지 않다.
|
||||||
|
|
||||||
@@ -815,6 +815,47 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
contentType:
|
contentType:
|
||||||
type: string
|
type: string
|
||||||
|
BodyAsset:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
본문이 `:::evidence key="..."` 로 가리키는 Asset 이다.
|
||||||
|
|
||||||
|
본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데, `/media/{assetId}` 는 UUID
|
||||||
|
로만 서빙한다 — 주소가 추측 불가능한 것이 의도된 성질이므로 key 에서 주소를 만들 수
|
||||||
|
없다. 그래서 공개 화면이 key 를 해석할 수 있도록, 게시된 기록이 실제로 참조하는 Asset 을
|
||||||
|
함께 준다.
|
||||||
|
|
||||||
|
목록은 게시 시점에 고정된 `PUBLISHED` scope 의 참조에서 온다. 게시 이후 작업본이 Asset
|
||||||
|
을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않는다.
|
||||||
|
required:
|
||||||
|
- assetKey
|
||||||
|
- assetId
|
||||||
|
- url
|
||||||
|
- contentType
|
||||||
|
- decorative
|
||||||
|
properties:
|
||||||
|
assetKey:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 200
|
||||||
|
assetId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
contentType:
|
||||||
|
type: string
|
||||||
|
altText:
|
||||||
|
type: string
|
||||||
|
width:
|
||||||
|
type: integer
|
||||||
|
height:
|
||||||
|
type: integer
|
||||||
|
decorative:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개
|
||||||
|
화면도 같은 값을 보고 `alt` 를 정해야 판정과 표시가 어긋나지 않는다.
|
||||||
RelatedEntry:
|
RelatedEntry:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -1224,6 +1265,13 @@ components:
|
|||||||
$ref: '#/components/schemas/ProjectSummary'
|
$ref: '#/components/schemas/ProjectSummary'
|
||||||
coverAsset:
|
coverAsset:
|
||||||
$ref: '#/components/schemas/AssetReference'
|
$ref: '#/components/schemas/AssetReference'
|
||||||
|
bodyAssets:
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한
|
||||||
|
Asset 이 더 이상 서빙되지 않는 경우다.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/BodyAsset'
|
||||||
publishedAt: *id003
|
publishedAt: *id003
|
||||||
updatedAt: *id003
|
updatedAt: *id003
|
||||||
lastVerifiedAt: *id003
|
lastVerifiedAt: *id003
|
||||||
|
|||||||
@@ -180,7 +180,42 @@ function resolvePublicEvidenceAssetDescriptor(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시된 본문을 블록으로 바꾼다.
|
||||||
|
*
|
||||||
|
* <p>예전에는 하드코딩된 슬러그 하나만 진짜 파서를 탔고 나머지는 모두 {@link genericCaseBlocks}
|
||||||
|
* 를 거쳤다 — 정규식이 `##` 제목과 `-` 불릿만 알아보므로 표·코드·callout 은 물론 evidence
|
||||||
|
* directive 까지 글자 그대로 문단이 되어 공개 화면에 그대로 보였다.
|
||||||
|
*
|
||||||
|
* <p>본문이 지원하지 않는 문법을 담고 있으면 화면 전체를 잃는 대신 예전 방식으로 돌아간다.
|
||||||
|
* 읽는 사람에게는 덜 정확한 화면이 빈 화면보다 낫다.
|
||||||
|
*/
|
||||||
|
function caseBodyBlocks(record: CaseRecord): CaseAuthoringBlock[] {
|
||||||
|
if (record.slug === "collection-fetch-join-pagination") {
|
||||||
|
return parseCaseContent(fetchJoinBody);
|
||||||
|
}
|
||||||
|
if (!record.content.trim()) return genericCaseBlocks(record);
|
||||||
|
try {
|
||||||
|
return parseCaseContent(record.content);
|
||||||
|
} catch {
|
||||||
|
return genericCaseBlocks(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
||||||
|
/*
|
||||||
|
본문은 evidence 를 key 로만 가리키고 `/media/{assetId}` 는 UUID 로만 서빙하므로, 계약이 함께
|
||||||
|
준 `bodyAssets` 로 key 를 주소로 바꾼다. 대응이 없는 key 는 그 블록을 지운다 — 해석기가
|
||||||
|
던지면 문서 전체가 사라지고, 남겨 두면 주소 없는 그림 자리가 남는다.
|
||||||
|
*/
|
||||||
|
const assetsByKey = new Map(record.bodyAssets.map((asset) => [asset.assetKey, asset]));
|
||||||
|
const blocks = caseBodyBlocks(record).filter(
|
||||||
|
(block) =>
|
||||||
|
block.type !== "EVIDENCE_FIGURE" ||
|
||||||
|
record.slug === "collection-fetch-join-pagination" ||
|
||||||
|
assetsByKey.has(block.key),
|
||||||
|
);
|
||||||
|
|
||||||
const model = resolveCaseEvidenceAssets(
|
const model = resolveCaseEvidenceAssets(
|
||||||
{
|
{
|
||||||
...publicRenderModelBase(record),
|
...publicRenderModelBase(record),
|
||||||
@@ -190,18 +225,37 @@ export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
|||||||
environment: record.environment,
|
environment: record.environment,
|
||||||
reproduction: record.verification,
|
reproduction: record.verification,
|
||||||
lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"),
|
lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"),
|
||||||
bodyBlocks:
|
bodyBlocks: blocks,
|
||||||
record.slug === "collection-fetch-join-pagination"
|
},
|
||||||
? parseCaseContent(fetchJoinBody)
|
(key) => {
|
||||||
: genericCaseBlocks(record),
|
const asset = assetsByKey.get(key);
|
||||||
|
if (!asset) return resolvePublicEvidenceAssetDescriptor(key);
|
||||||
|
return {
|
||||||
|
assetId: asset.assetId,
|
||||||
|
assetKey: asset.assetKey,
|
||||||
|
mediaType: asset.contentType,
|
||||||
|
publicPath: asset.url,
|
||||||
|
width: asset.width,
|
||||||
|
height: asset.height,
|
||||||
|
decorative: asset.decorative,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
resolvePublicEvidenceAssetDescriptor,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicRecordRenderer
|
<PublicRecordRenderer
|
||||||
model={model}
|
model={model}
|
||||||
resolveEvidenceAsset={resolvePublicEvidenceAsset}
|
resolveEvidenceAsset={(key) => {
|
||||||
|
const asset = assetsByKey.get(key);
|
||||||
|
if (!asset) return resolvePublicEvidenceAsset(key);
|
||||||
|
return {
|
||||||
|
src: asset.url,
|
||||||
|
width: asset.width ?? 0,
|
||||||
|
height: asset.height ?? 0,
|
||||||
|
triggerLabel: `${asset.altText || asset.assetKey} 크게 보기`,
|
||||||
|
dialogLabel: asset.altText || asset.assetKey,
|
||||||
|
};
|
||||||
|
}}
|
||||||
resolvePublishedLabel={(path) =>
|
resolvePublishedLabel={(path) =>
|
||||||
path === record.path ? record.publishedLabel : undefined
|
path === record.path ? record.publishedLabel : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,20 @@ import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
|
|||||||
import { useStudioAssetGateway } from "../use-studio.ts";
|
import { useStudioAssetGateway } from "../use-studio.ts";
|
||||||
import { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx";
|
import { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx";
|
||||||
import { AssetUploadDialog } from "./asset-upload-dialog.tsx";
|
import { AssetUploadDialog } from "./asset-upload-dialog.tsx";
|
||||||
|
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||||
|
|
||||||
type CaseInput = components["schemas"]["CaseInput"];
|
type CaseInput = components["schemas"]["CaseInput"];
|
||||||
|
|
||||||
|
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
|
||||||
|
export const CASE_FIELD_PATHS = [
|
||||||
|
"/problem",
|
||||||
|
"/conclusion",
|
||||||
|
"/environment",
|
||||||
|
"/reproduction",
|
||||||
|
"/lastVerifiedOn",
|
||||||
|
"/bodyMarkdown",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const ASSET_KIND_OPTIONS: ReadonlyArray<{ value: AssetKind; label: string }> = [
|
const ASSET_KIND_OPTIONS: ReadonlyArray<{ value: AssetKind; label: string }> = [
|
||||||
{ value: "IMAGE", label: "이미지" },
|
{ value: "IMAGE", label: "이미지" },
|
||||||
{ value: "DIAGRAM", label: "다이어그램" },
|
{ value: "DIAGRAM", label: "다이어그램" },
|
||||||
@@ -36,11 +47,14 @@ function insertAtCursor(
|
|||||||
|
|
||||||
export function CaseFields({
|
export function CaseFields({
|
||||||
draft,
|
draft,
|
||||||
|
issues,
|
||||||
onChange,
|
onChange,
|
||||||
onAssetsObserved,
|
onAssetsObserved,
|
||||||
onAssetUploaded,
|
onAssetUploaded,
|
||||||
}: {
|
}: {
|
||||||
draft: CaseInput;
|
draft: CaseInput;
|
||||||
|
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
|
||||||
|
issues: readonly FieldIssue[];
|
||||||
onChange(draft: CaseInput): void;
|
onChange(draft: CaseInput): void;
|
||||||
/**
|
/**
|
||||||
* The editor screen owns the resolution catalog Instant Preview reads, and
|
* The editor screen owns the resolution catalog Instant Preview reads, and
|
||||||
@@ -81,12 +95,12 @@ export function CaseFields({
|
|||||||
<section className="studio-editor-section" aria-labelledby="studio-case-fields-title">
|
<section className="studio-editor-section" aria-labelledby="studio-case-fields-title">
|
||||||
<div className="studio-editor-section-heading"><p className="studio-eyebrow">CASE</p><h2 id="studio-case-fields-title">문제와 검증</h2></div>
|
<div className="studio-editor-section-heading"><p className="studio-eyebrow">CASE</p><h2 id="studio-case-fields-title">문제와 검증</h2></div>
|
||||||
<div className="studio-field-grid">
|
<div className="studio-field-grid">
|
||||||
<label className="studio-field studio-field--wide"><span>문제</span><textarea value={draft.problem} onChange={(event) => update({ problem: event.currentTarget.value })} /></label>
|
<label className="studio-field studio-field--wide"><span>문제</span><textarea value={draft.problem} onChange={(event) => update({ problem: event.currentTarget.value })} /><FieldNotice issues={issues} path="/problem" /></label>
|
||||||
<label className="studio-field studio-field--wide"><span>결론</span><textarea value={draft.conclusion} onChange={(event) => update({ conclusion: event.currentTarget.value })} /></label>
|
<label className="studio-field studio-field--wide"><span>결론</span><textarea value={draft.conclusion} onChange={(event) => update({ conclusion: event.currentTarget.value })} /><FieldNotice issues={issues} path="/conclusion" /></label>
|
||||||
<label className="studio-field"><span>검증 환경</span><textarea value={draft.environment} onChange={(event) => update({ environment: event.currentTarget.value })} /></label>
|
<label className="studio-field"><span>검증 환경</span><textarea value={draft.environment} onChange={(event) => update({ environment: event.currentTarget.value })} /><FieldNotice issues={issues} path="/environment" /></label>
|
||||||
<label className="studio-field"><span>재현 조건</span><textarea value={draft.reproduction} onChange={(event) => update({ reproduction: event.currentTarget.value })} /></label>
|
<label className="studio-field"><span>재현 조건</span><textarea value={draft.reproduction} onChange={(event) => update({ reproduction: event.currentTarget.value })} /><FieldNotice issues={issues} path="/reproduction" /></label>
|
||||||
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /></label>
|
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
|
||||||
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /></label>
|
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
|
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
|
||||||
<p className="studio-eyebrow">EVIDENCE</p>
|
<p className="studio-eyebrow">EVIDENCE</p>
|
||||||
|
|||||||
@@ -1,33 +1,48 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
||||||
|
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||||
import { RelationEditor } from "./relation-editor.tsx";
|
import { RelationEditor } from "./relation-editor.tsx";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
|
||||||
|
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. 나머지는 게시 버튼 옆에 남는다. */
|
||||||
|
export const COMMON_FIELD_PATHS = [
|
||||||
|
"/title",
|
||||||
|
"/slug",
|
||||||
|
"/summary",
|
||||||
|
"/topicId",
|
||||||
|
"/projectId",
|
||||||
|
"/relations",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function CommonDocumentFields({
|
export function CommonDocumentFields({
|
||||||
draft,
|
draft,
|
||||||
topics,
|
topics,
|
||||||
projects,
|
projects,
|
||||||
relations,
|
relations,
|
||||||
|
issues,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
draft: WorkingCopyInput;
|
draft: WorkingCopyInput;
|
||||||
topics: CatalogEntry[];
|
topics: CatalogEntry[];
|
||||||
projects: CatalogEntry[];
|
projects: CatalogEntry[];
|
||||||
relations: CatalogEntry[];
|
relations: CatalogEntry[];
|
||||||
|
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
|
||||||
|
issues: readonly FieldIssue[];
|
||||||
onUpdate(patch: Partial<WorkingCopyInput>): void;
|
onUpdate(patch: Partial<WorkingCopyInput>): void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="studio-editor-section" aria-labelledby="studio-common-fields-title">
|
<section className="studio-editor-section" aria-labelledby="studio-common-fields-title">
|
||||||
<div className="studio-editor-section-heading"><p className="studio-eyebrow">DOCUMENT</p><h2 id="studio-common-fields-title">기본 정보</h2></div>
|
<div className="studio-editor-section-heading"><p className="studio-eyebrow">DOCUMENT</p><h2 id="studio-common-fields-title">기본 정보</h2></div>
|
||||||
<div className="studio-field-grid">
|
<div className="studio-field-grid">
|
||||||
<label className="studio-field studio-field--wide"><span>제목</span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /></label>
|
<label className="studio-field studio-field--wide"><span>제목</span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /><FieldNotice issues={issues} path="/title" /></label>
|
||||||
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
|
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /><FieldNotice issues={issues} path="/slug" /></label>
|
||||||
<label className="studio-field studio-field--wide"><span>요약</span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /></label>
|
<label className="studio-field studio-field--wide"><span>요약</span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /><FieldNotice issues={issues} path="/summary" /></label>
|
||||||
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value="">선택하지 않음</option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value="">선택하지 않음</option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select><FieldNotice issues={issues} path="/topicId" /></label>
|
||||||
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value="">미지정</option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value="">미지정</option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select><FieldNotice issues={issues} path="/projectId" /></label>
|
||||||
</div>
|
</div>
|
||||||
<RelationEditor evidence={draft.kind === "PROJECT_DECISION"} relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
|
<RelationEditor evidence={draft.kind === "PROJECT_DECISION"} relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
|
||||||
|
<FieldNotice issues={issues} path="/relations" />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,26 @@ export type DocumentEditorController = {
|
|||||||
version: number;
|
version: number;
|
||||||
issues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
|
issues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
|
||||||
}> | null;
|
}> | null;
|
||||||
|
/**
|
||||||
|
* 게시가 진행 중인가. 한 번의 클릭이 저장·검증·미리보기·게시 네 번의 왕복을 만들므로, 그
|
||||||
|
* 사이에 다시 누르면 같은 문서를 두 번 게시하려 든다.
|
||||||
|
*/
|
||||||
|
publishing: boolean;
|
||||||
|
/** 게시가 실패한 이유. 서버가 준 문구를 그대로 쓴다. */
|
||||||
|
publishError: string;
|
||||||
|
/**
|
||||||
|
* 게시를 막은 검증 항목. 예전에는 이것을 보려면 검증 화면으로 나가야 했다 — 고칠 칸은 편집
|
||||||
|
* 화면에 있는데 무엇이 모자란지는 다른 화면에 있었다. 지금은 막힌 자리에서 바로 보여 준다.
|
||||||
|
*/
|
||||||
|
publishIssues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
|
||||||
update(patch: Partial<WorkingCopyInput>): void;
|
update(patch: Partial<WorkingCopyInput>): void;
|
||||||
replace(draft: WorkingCopyInput): void;
|
replace(draft: WorkingCopyInput): void;
|
||||||
save(): Promise<void>;
|
save(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다. 백엔드는 게시 요청에 신선한 검증 id 와
|
||||||
|
* 미리보기 id, 그리고 현재 경고를 모두 확인했다는 목록을 요구한다 —
|
||||||
|
* `PublishStudioDocumentUseCase` 가 셋을 모두 검사한다. 그 셋은 작성자에게 물어볼 것이 없으므로
|
||||||
|
* 여기서 채운다. 작성자가 밟는 단계는 저장과 게시 둘뿐이다.
|
||||||
|
*/
|
||||||
|
publish(): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
* 돌아와서 어느 칸이었는지 기억해야 했다.
|
* 돌아와서 어느 칸이었는지 기억해야 했다.
|
||||||
*/
|
*/
|
||||||
const [validationSource, setValidationSource] = useState<WorkingCopyDetail | null>(null);
|
const [validationSource, setValidationSource] = useState<WorkingCopyDetail | null>(null);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [publishError, setPublishError] = useState("");
|
||||||
|
const [publishIssues, setPublishIssues] = useState<readonly ValidationIssue[]>([]);
|
||||||
const observeAssets = useCallback((observed: readonly Asset[]) => {
|
const observeAssets = useCallback((observed: readonly Asset[]) => {
|
||||||
setAssets((current) => mergeAssetCatalog(current, observed));
|
setAssets((current) => mergeAssetCatalog(current, observed));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -173,6 +176,112 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
}
|
}
|
||||||
}, [begin, editor, setStatus, studio]);
|
}, [begin, editor, setStatus, studio]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다.
|
||||||
|
*
|
||||||
|
* <p>백엔드는 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두 확인했다는
|
||||||
|
* 목록을 요구한다(`PublishStudioDocumentUseCase`). 예전에는 그 셋을 작성자가 세 화면을 차례로
|
||||||
|
* 밟아 만들었다 — 검증 화면에서 버튼을 누르고, 미리보기 화면에서 또 누르고, 게시 화면에서
|
||||||
|
* 경고를 하나씩 체크했다.
|
||||||
|
*
|
||||||
|
* <p>그 셋은 작성자에게 물어볼 것이 없다. 검증 결과는 서버가 판정하고, 미리보기는 그 판정에서
|
||||||
|
* 만들어지며, 경고는 게시를 막지 않는다. 그래서 여기서 잇달아 부른다. 서버가 지키던 불변식은
|
||||||
|
* 그대로다 — 사라진 것은 작성자가 밟던 화면뿐이다.
|
||||||
|
*
|
||||||
|
* <p>막는 것은 `ERROR` 뿐이다. 그때는 게시를 멈추고 지적을 그 칸 아래에 보여 준다.
|
||||||
|
*/
|
||||||
|
const publish = useCallback(async () => {
|
||||||
|
const current = editor;
|
||||||
|
if (!current || current.status === "SAVING" || current.status === "CONFLICT") return;
|
||||||
|
setPublishError("");
|
||||||
|
setPublishIssues([]);
|
||||||
|
setPublishing(true);
|
||||||
|
const id = current.documentId;
|
||||||
|
try {
|
||||||
|
// 게시는 저장된 버전을 대상으로 한다. 편집 중인 값이 있으면 먼저 맞춘다 — 그러지 않으면
|
||||||
|
// 방금 고친 칸이 반영되지 않은 채 검증받는다.
|
||||||
|
let version = current.saved.version;
|
||||||
|
if (current.status === "DIRTY") {
|
||||||
|
const slug = current.draft.slug.trim() || slugFromName(current.draft.title);
|
||||||
|
if (slug && !SLUG_SHAPE.test(slug)) {
|
||||||
|
setPublishError("slug 은 영문 소문자·숫자·하이픈만 쓸 수 있습니다. 비워 두면 제목에서 만들어 드립니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const draft = slug === current.draft.slug
|
||||||
|
? current.draft
|
||||||
|
: ({ ...current.draft, slug } as typeof current.draft);
|
||||||
|
setStatus("SAVING");
|
||||||
|
const saved = await studio.gateway.saveDocument(
|
||||||
|
id,
|
||||||
|
{ expectedVersion: version, document: draft },
|
||||||
|
{ idempotencyKey: createLocalId("studio-publish-save") },
|
||||||
|
);
|
||||||
|
begin(saved.document, inputOf(saved.document));
|
||||||
|
setValidationSource(saved);
|
||||||
|
version = saved.document.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await studio.gateway.validateDocument(
|
||||||
|
id,
|
||||||
|
{ expectedVersion: version },
|
||||||
|
{ idempotencyKey: createLocalId("studio-publish-validate") },
|
||||||
|
);
|
||||||
|
if (report.status === "INVALID") {
|
||||||
|
setPublishIssues(report.issues);
|
||||||
|
setPublishError("게시할 수 없습니다. 표시한 칸을 채워 주세요.");
|
||||||
|
studio.setRequestAnnouncement("게시할 수 없습니다. 표시한 칸을 채워 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = await studio.gateway.createPreview(
|
||||||
|
id,
|
||||||
|
{ expectedVersion: version, validationId: report.validationId },
|
||||||
|
{ idempotencyKey: createLocalId("studio-publish-preview") },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 경고는 게시를 막지 않는다. 서버는 "현재 경고를 모두 확인했다"는 목록을 요구할 뿐이므로
|
||||||
|
// 방금 받은 경고를 그대로 넘긴다.
|
||||||
|
const acknowledged = [
|
||||||
|
...new Set(
|
||||||
|
report.issues
|
||||||
|
.filter((issue) => issue.severity === "WARNING")
|
||||||
|
.map((issue) => issue.code),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
const result = await studio.gateway.publishDocument(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
expectedVersion: version,
|
||||||
|
validationId: report.validationId,
|
||||||
|
previewId: preview.previewId,
|
||||||
|
acknowledgedWarningCodes: acknowledged,
|
||||||
|
},
|
||||||
|
{ idempotencyKey: createLocalId("studio-publish") },
|
||||||
|
);
|
||||||
|
// 경고를 남긴 채 게시했다면 그 사실은 남겨 둔다 — 게시가 되었다고 해서 지적이 사라진 것은
|
||||||
|
// 아니다.
|
||||||
|
setPublishIssues(report.issues.filter((issue) => issue.severity === "WARNING"));
|
||||||
|
studio.setRequestAnnouncement("게시했습니다.");
|
||||||
|
studio.clearEditor();
|
||||||
|
studio.navigateInternal(
|
||||||
|
`/studio/publications/${result.event.publicationEventId}/preview`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (isStudioGatewayError(error) && error.code === "VERSION_CONFLICT") {
|
||||||
|
setStatus("CONFLICT");
|
||||||
|
} else if (current.status === "DIRTY") {
|
||||||
|
setStatus("DIRTY");
|
||||||
|
}
|
||||||
|
const detail = isStudioGatewayError(error)
|
||||||
|
? error.problem.detail
|
||||||
|
: "게시하지 못했습니다. 다시 시도해 주세요.";
|
||||||
|
setPublishError(detail);
|
||||||
|
studio.setRequestAnnouncement(detail);
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
}, [begin, editor, setStatus, studio]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 검증의 신선도는 시각에 달렸는데, 시계는 렌더마다 새 함수다. 그것을 효과 의존성에 넣었더니
|
* 검증의 신선도는 시각에 달렸는데, 시계는 렌더마다 새 함수다. 그것을 효과 의존성에 넣었더니
|
||||||
* 문서를 끝없이 다시 불러왔다 — 화면이 정착하지 못해 탭 전환조차 먹히지 않았다. 시각은
|
* 문서를 끝없이 다시 불러왔다 — 화면이 정착하지 못해 탭 전환조차 먹히지 않았다. 시각은
|
||||||
@@ -188,15 +297,19 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
status: editor.status,
|
status: editor.status,
|
||||||
saveError,
|
saveError,
|
||||||
validation,
|
validation,
|
||||||
|
publishing,
|
||||||
|
publishError,
|
||||||
|
publishIssues,
|
||||||
update(patch) {
|
update(patch) {
|
||||||
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
|
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
|
||||||
},
|
},
|
||||||
replace(draft) {
|
replace(draft) {
|
||||||
if (draft.kind === editor.draft.kind) updateDraft(draft);
|
if (draft.kind === editor.draft.kind) updateDraft(draft);
|
||||||
},
|
},
|
||||||
|
publish,
|
||||||
save,
|
save,
|
||||||
};
|
};
|
||||||
}, [documentId, editor, save, saveError, updateDraft, validation]);
|
}, [documentId, editor, publish, publishError, publishIssues, publishing, save, saveError, updateDraft, validation]);
|
||||||
|
|
||||||
const currentResult = result?.key === requestKey ? result : null;
|
const currentResult = result?.key === requestKey ? result : null;
|
||||||
const problem = currentResult?.problem ?? null;
|
const problem = currentResult?.problem ?? null;
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { useRef, useState, type KeyboardEvent } from "react";
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import type { Asset } from "../../../contracts/studio/contract.ts";
|
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
import { CaseFields } from "./case-fields.tsx";
|
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
|
||||||
import { CommonDocumentFields } from "./common-document-fields.tsx";
|
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
|
||||||
import { DocumentStatusRail } from "./document-status-rail.tsx";
|
import { DocumentStatusRail } from "./document-status-rail.tsx";
|
||||||
import { InstantPreview } from "./instant-preview.tsx";
|
import { InstantPreview } from "./instant-preview.tsx";
|
||||||
import { ProjectDecisionFields } from "./project-decision-fields.tsx";
|
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
|
||||||
import { QuestionFields } from "./question-fields.tsx";
|
import { issuesOutside } from "./field-issues.tsx";
|
||||||
import { ReferenceFields } from "./reference-fields.tsx";
|
import { QUESTION_FIELD_PATHS, QuestionFields } from "./question-fields.tsx";
|
||||||
|
import { REFERENCE_FIELD_PATHS, ReferenceFields } from "./reference-fields.tsx";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
|
||||||
@@ -48,6 +49,23 @@ export function DocumentEditor({
|
|||||||
const relations = catalog.filter(({ type }) => type === "RELATION");
|
const relations = catalog.filter(({ type }) => type === "RELATION");
|
||||||
const evidence = catalog.filter(({ type }) => type === "EVIDENCE");
|
const evidence = catalog.filter(({ type }) => type === "EVIDENCE");
|
||||||
|
|
||||||
|
/*
|
||||||
|
게시를 막는 것이 무엇인지 그 칸 아래에 적는다. 예전에는 이 목록이 화면 맨 위에 한 덩어리로
|
||||||
|
있었고, 작성자는 `/topicId` 같은 경로를 읽고 어느 칸인지 스스로 찾아야 했다.
|
||||||
|
|
||||||
|
어느 칸에도 붙지 못한 것은 게시 버튼 옆에 남긴다 — 사라지면 이유를 말해 주지 않는 실패만
|
||||||
|
남는다.
|
||||||
|
*/
|
||||||
|
const issues = controller.publishIssues;
|
||||||
|
const kindPaths = controller.draft.kind === "CASE"
|
||||||
|
? CASE_FIELD_PATHS
|
||||||
|
: controller.draft.kind === "REFERENCE"
|
||||||
|
? REFERENCE_FIELD_PATHS
|
||||||
|
: controller.draft.kind === "QUESTION"
|
||||||
|
? QUESTION_FIELD_PATHS
|
||||||
|
: DECISION_FIELD_PATHS;
|
||||||
|
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="studio-editor-page">
|
<div className="studio-editor-page">
|
||||||
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
|
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
|
||||||
@@ -62,49 +80,20 @@ export function DocumentEditor({
|
|||||||
<h1>문서 편집</h1>
|
<h1>문서 편집</h1>
|
||||||
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
||||||
</header>
|
</header>
|
||||||
{/*
|
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
|
||||||
게시를 막는 것이 무엇인지 고치는 자리에서 보여 준다. 예전에는 별도 검증 화면으로
|
|
||||||
나가야만 알 수 있었고, 돌아와서는 어느 칸이었는지 기억해야 했다.
|
|
||||||
|
|
||||||
검증은 저장된 버전을 기준으로 도므로 이 목록도 그 버전의 것이다 — 저장한 뒤 다시
|
|
||||||
검증하기 전까지는 방금 고친 것이 아직 반영되지 않는다. 그 사실을 숨기지 않는다.
|
|
||||||
*/}
|
|
||||||
{controller.validation && controller.validation.issues.length ? (
|
|
||||||
<section
|
|
||||||
className={`studio-editor-validation${controller.validation.current ? "" : " studio-editor-validation--stale"}`}
|
|
||||||
role={controller.validation.current ? "alert" : "status"}
|
|
||||||
aria-label="검증에서 지적된 항목"
|
|
||||||
>
|
|
||||||
<p className="studio-editor-validation-title">
|
|
||||||
{controller.validation.current
|
|
||||||
? "게시하려면 아래를 채워야 합니다"
|
|
||||||
: `버전 ${controller.validation.version} 검사에서 지적된 항목입니다 — 저장 후 다시 검증하면 갱신됩니다`}
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
{controller.validation.issues.map((issue) => (
|
|
||||||
<li key={`${issue.code}-${issue.path}`} data-severity={issue.severity}>
|
|
||||||
<span>{issue.severity === "ERROR" ? "필수" : "확인"}</span>
|
|
||||||
{issue.message}
|
|
||||||
<code>{issue.path}</code>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} onUpdate={controller.update} />
|
|
||||||
{controller.draft.kind === "CASE"
|
{controller.draft.kind === "CASE"
|
||||||
? <CaseFields draft={controller.draft} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
|
? <CaseFields draft={controller.draft} issues={issues} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
|
||||||
: controller.draft.kind === "REFERENCE"
|
: controller.draft.kind === "REFERENCE"
|
||||||
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
|
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
|
||||||
: controller.draft.kind === "QUESTION"
|
: controller.draft.kind === "QUESTION"
|
||||||
? <QuestionFields draft={controller.draft} evidence={evidence} onChange={controller.replace} />
|
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
|
||||||
: <ProjectDecisionFields draft={controller.draft} onChange={controller.replace} />}
|
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
|
||||||
</div>
|
</div>
|
||||||
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
|
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
|
||||||
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DocumentStatusRail controller={controller} />
|
<DocumentStatusRail controller={controller} unplacedIssues={unplaced} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import type { FieldIssue } from "./field-issues.tsx";
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
CLEAN: "저장됨",
|
CLEAN: "저장됨",
|
||||||
@@ -15,36 +15,42 @@ const kindLabels = {
|
|||||||
PROJECT_DECISION: "Decision",
|
PROJECT_DECISION: "Decision",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function DocumentStatusRail({ controller }: { controller: DocumentEditorController }) {
|
export function DocumentStatusRail({
|
||||||
|
controller,
|
||||||
|
unplacedIssues,
|
||||||
|
}: {
|
||||||
|
controller: DocumentEditorController;
|
||||||
|
/**
|
||||||
|
* 어느 칸에도 붙지 못한 지적. 화면에 없는 칸을 가리키는 것들이며, 여기 남기지 않으면 이유를
|
||||||
|
* 말해 주지 않는 실패만 남는다.
|
||||||
|
*/
|
||||||
|
unplacedIssues: readonly FieldIssue[];
|
||||||
|
}) {
|
||||||
|
const busy = controller.status === "SAVING" || controller.publishing;
|
||||||
return (
|
return (
|
||||||
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
|
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
|
||||||
<p className="studio-eyebrow">WORKING COPY</p>
|
<p className="studio-eyebrow">WORKING COPY</p>
|
||||||
<h2 id="studio-document-status-title">작업 상태</h2>
|
<h2 id="studio-document-status-title">작업 상태</h2>
|
||||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||||
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
<button type="button" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||||
{/*
|
{/*
|
||||||
게시까지의 길을 통째로 보여 준다. 예전에는 다음 한 칸("저장본 검증")만 있었고, 작성자는
|
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
|
||||||
검증 → 미리보기 → 게시를 하나씩 밟아 본 뒤에야 게시 화면이 있다는 것을 알 수 있었다 —
|
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
|
||||||
"게시" 라는 말이 어디에도 먼저 나오지 않으니 게시하는 방법을 알기 어려웠다.
|
확인했다는 목록을 요구하기 때문이다(`PublishStudioDocumentUseCase`).
|
||||||
|
|
||||||
앞 단계를 마치지 않으면 뒤 단계가 거절하는 것은 그대로다. 여기서 바꾼 것은 순서를
|
그 셋은 작성자에게 물어볼 것이 없다. 검증은 서버가 판정하고, 미리보기는 그 판정으로부터
|
||||||
숨기지 않는 것뿐이다.
|
만들어지며, 경고는 게시를 막지 않는다. 그래서 세 요청을 이 버튼 뒤로 옮겼다. 계약도
|
||||||
|
백엔드도 그대로다 — 사라진 것은 작성자가 밟던 화면이지 서버가 지키던 불변식이 아니다.
|
||||||
*/}
|
*/}
|
||||||
<nav className="studio-editor-flow" aria-label="게시까지의 단계">
|
<button
|
||||||
<ol>
|
className="studio-primary-button"
|
||||||
{[
|
type="button"
|
||||||
{ label: "검증", href: `/studio/documents/${controller.saved.id}/validation` },
|
onClick={() => { void controller.publish(); }}
|
||||||
{ label: "미리보기", href: `/studio/documents/${controller.saved.id}/preview` },
|
disabled={busy || controller.status === "CONFLICT"}
|
||||||
{ label: "게시", href: `/studio/documents/${controller.saved.id}/publish` },
|
>
|
||||||
].map((step, index) => (
|
{controller.publishing ? "게시 중…" : "게시"}
|
||||||
<li key={step.href}>
|
</button>
|
||||||
<span aria-hidden="true">{index + 1}</span>
|
|
||||||
<GuardedStudioLink href={step.href}>{step.label}</GuardedStudioLink>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
{/*
|
{/*
|
||||||
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||||
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||||
@@ -54,9 +60,20 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
|
|||||||
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
||||||
) : controller.saveError ? (
|
) : controller.saveError ? (
|
||||||
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
||||||
|
) : controller.publishError ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
||||||
) : (
|
) : (
|
||||||
<p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>
|
<p>불완전한 초안도 저장할 수 있습니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.</p>
|
||||||
)}
|
)}
|
||||||
|
{unplacedIssues.length ? (
|
||||||
|
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
|
||||||
|
{unplacedIssues.map((issue) => (
|
||||||
|
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
|
||||||
|
{issue.message} <code>{issue.path}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
|
||||||
|
export type FieldIssue = components["schemas"]["ValidationIssue"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증 항목을 그 항목이 가리키는 칸 옆으로 나눠 주기 위한 것들.
|
||||||
|
*
|
||||||
|
* <p>예전에는 게시하기 전에 검증 화면으로 나가서 버튼을 누르고, 지적을 읽고, 편집 화면으로
|
||||||
|
* 돌아와 어느 칸이었는지 기억해서 고쳐야 했다. 검증 결과는 이미 `path` 로 어느 칸인지 말하고
|
||||||
|
* 있었으므로 — `/title`, `/topicId`, `/problem` — 그 자리에 그대로 붙이면 화면을 오갈 이유가
|
||||||
|
* 없어진다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `path` 가 가리키는 칸의 지적을 고른다.
|
||||||
|
*
|
||||||
|
* <p>정확히 같은 경로뿐 아니라 그 아래 경로도 함께 고른다. 배열 칸은 서버가
|
||||||
|
* `/relations/0/targetId` 처럼 항목을 짚어 주는데, 화면에는 `relations` 라는 칸 하나만 있기
|
||||||
|
* 때문이다. 이렇게 하지 않으면 그런 지적은 어느 칸에도 붙지 못하고 사라진다.
|
||||||
|
*/
|
||||||
|
export function issuesFor(
|
||||||
|
issues: readonly FieldIssue[],
|
||||||
|
path: string,
|
||||||
|
): readonly FieldIssue[] {
|
||||||
|
return issues.filter(
|
||||||
|
(issue) => issue.path === path || issue.path.startsWith(`${path}/`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `paths` 중 어느 것에도 붙지 않는 지적. 게시 버튼 옆에 남겨 두기 위한 것이다 — 화면에 없는
|
||||||
|
* 칸을 가리키는 지적이 조용히 사라지면, 작성자는 이유를 말해 주지 않는 실패만 보게 된다.
|
||||||
|
*/
|
||||||
|
export function issuesOutside(
|
||||||
|
issues: readonly FieldIssue[],
|
||||||
|
paths: readonly string[],
|
||||||
|
): readonly FieldIssue[] {
|
||||||
|
return issues.filter(
|
||||||
|
(issue) =>
|
||||||
|
!paths.some(
|
||||||
|
(path) => issue.path === path || issue.path.startsWith(`${path}/`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한 칸에 붙는 지적을 그 칸 아래에 적는다. `ERROR` 는 게시를 막고 `WARNING` 은 막지 않으므로
|
||||||
|
* 둘을 다른 색으로 구분하되, 둘 다 읽히도록 `role` 을 준다.
|
||||||
|
*/
|
||||||
|
export function FieldNotice({
|
||||||
|
issues,
|
||||||
|
path,
|
||||||
|
}: {
|
||||||
|
issues: readonly FieldIssue[];
|
||||||
|
path: string;
|
||||||
|
}) {
|
||||||
|
const matched = issuesFor(issues, path);
|
||||||
|
if (matched.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{matched.map((issue) => (
|
||||||
|
<span
|
||||||
|
key={`${issue.code}:${issue.path}`}
|
||||||
|
className={`studio-field-notice studio-field-notice--${issue.severity.toLowerCase()}`}
|
||||||
|
data-severity={issue.severity}
|
||||||
|
role={issue.severity === "ERROR" ? "alert" : "status"}
|
||||||
|
>
|
||||||
|
{issue.message}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||||
|
|
||||||
type ProjectDecisionInput = components["schemas"]["ProjectDecisionInput"];
|
type ProjectDecisionInput = components["schemas"]["ProjectDecisionInput"];
|
||||||
|
|
||||||
|
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
|
||||||
|
export const DECISION_FIELD_PATHS = [
|
||||||
|
"/decisionStatus",
|
||||||
|
"/decidedOn",
|
||||||
|
"/statement",
|
||||||
|
"/rationale",
|
||||||
|
"/consequences",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function ProjectDecisionFields({
|
export function ProjectDecisionFields({
|
||||||
draft,
|
draft,
|
||||||
|
issues,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
draft: ProjectDecisionInput;
|
draft: ProjectDecisionInput;
|
||||||
|
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
|
||||||
|
issues: readonly FieldIssue[];
|
||||||
onChange(draft: ProjectDecisionInput): void;
|
onChange(draft: ProjectDecisionInput): void;
|
||||||
}) {
|
}) {
|
||||||
const update = (patch: Partial<ProjectDecisionInput>) => {
|
const update = (patch: Partial<ProjectDecisionInput>) => {
|
||||||
@@ -41,6 +54,7 @@ export function ProjectDecisionFields({
|
|||||||
<option value="PROPOSED">PROPOSED</option>
|
<option value="PROPOSED">PROPOSED</option>
|
||||||
<option value="ADOPTED">ADOPTED</option>
|
<option value="ADOPTED">ADOPTED</option>
|
||||||
</select>
|
</select>
|
||||||
|
<FieldNotice issues={issues} path="/decisionStatus" />
|
||||||
</label>
|
</label>
|
||||||
<label className="studio-field">
|
<label className="studio-field">
|
||||||
<span>결정일</span>
|
<span>결정일</span>
|
||||||
@@ -51,6 +65,7 @@ export function ProjectDecisionFields({
|
|||||||
update({ decidedOn: event.currentTarget.value || null });
|
update({ decidedOn: event.currentTarget.value || null });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<FieldNotice issues={issues} path="/decidedOn" />
|
||||||
</label>
|
</label>
|
||||||
<label className="studio-field studio-field--wide">
|
<label className="studio-field studio-field--wide">
|
||||||
<span>결정문</span>
|
<span>결정문</span>
|
||||||
@@ -58,6 +73,7 @@ export function ProjectDecisionFields({
|
|||||||
value={draft.statement}
|
value={draft.statement}
|
||||||
onChange={(event) => update({ statement: event.currentTarget.value })}
|
onChange={(event) => update({ statement: event.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
|
<FieldNotice issues={issues} path="/statement" />
|
||||||
</label>
|
</label>
|
||||||
<label className="studio-field studio-field--wide">
|
<label className="studio-field studio-field--wide">
|
||||||
<span>판단 이유</span>
|
<span>판단 이유</span>
|
||||||
@@ -65,6 +81,7 @@ export function ProjectDecisionFields({
|
|||||||
value={draft.rationale}
|
value={draft.rationale}
|
||||||
onChange={(event) => update({ rationale: event.currentTarget.value })}
|
onChange={(event) => update({ rationale: event.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
|
<FieldNotice issues={issues} path="/rationale" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<OrderedTextList
|
<OrderedTextList
|
||||||
@@ -73,6 +90,7 @@ export function ProjectDecisionFields({
|
|||||||
items={draft.consequences}
|
items={draft.consequences}
|
||||||
onChange={(consequences) => update({ consequences })}
|
onChange={(consequences) => update({ consequences })}
|
||||||
/>
|
/>
|
||||||
|
<FieldNotice issues={issues} path="/consequences" />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
|
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||||
|
|
||||||
|
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
|
||||||
|
export const QUESTION_FIELD_PATHS = [
|
||||||
|
"/questionStatus",
|
||||||
|
"/facts",
|
||||||
|
"/assumptions",
|
||||||
|
"/unknowns",
|
||||||
|
"/constraints",
|
||||||
|
"/options",
|
||||||
|
"/nextValidation",
|
||||||
|
"/resolution",
|
||||||
|
] as const;
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
type QuestionInput = components["schemas"]["QuestionInput"];
|
type QuestionInput = components["schemas"]["QuestionInput"];
|
||||||
type QuestionOption = components["schemas"]["QuestionOption"];
|
type QuestionOption = components["schemas"]["QuestionOption"];
|
||||||
@@ -10,7 +23,7 @@ function orderedOptions(options: QuestionOption[]): QuestionOption[] {
|
|||||||
return options.map((option, order) => ({ ...option, order }));
|
return options.map((option, order) => ({ ...option, order }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionInput; evidence: CatalogEntry[]; onChange(draft: QuestionInput): void }) {
|
export function QuestionFields({ draft, evidence, issues, onChange }: { draft: QuestionInput; evidence: CatalogEntry[]; issues: readonly FieldIssue[]; onChange(draft: QuestionInput): void }) {
|
||||||
const update = (patch: Partial<QuestionInput>) => onChange({ ...draft, ...patch });
|
const update = (patch: Partial<QuestionInput>) => onChange({ ...draft, ...patch });
|
||||||
const moveOption = (index: number, delta: -1 | 1) => {
|
const moveOption = (index: number, delta: -1 | 1) => {
|
||||||
const target = index + delta;
|
const target = index + delta;
|
||||||
@@ -26,12 +39,17 @@ export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionI
|
|||||||
const value = event.currentTarget.value;
|
const value = event.currentTarget.value;
|
||||||
if (value === "RESOLVED") update({ questionStatus: "RESOLVED", resolution: draft.resolution ?? { summary: "", evidenceTargetId: null, linkLabel: "" } });
|
if (value === "RESOLVED") update({ questionStatus: "RESOLVED", resolution: draft.resolution ?? { summary: "", evidenceTargetId: null, linkLabel: "" } });
|
||||||
else update({ questionStatus: value === "OPEN" ? "OPEN" : null, resolution: null });
|
else update({ questionStatus: value === "OPEN" ? "OPEN" : null, resolution: null });
|
||||||
}}><option value="">아직 정하지 않음</option><option value="OPEN">OPEN</option><option value="RESOLVED">RESOLVED</option></select></label>
|
}}><option value="">아직 정하지 않음</option><option value="OPEN">OPEN</option><option value="RESOLVED">RESOLVED</option></select><FieldNotice issues={issues} path="/questionStatus" /></label>
|
||||||
<OrderedTextList label="사실" fieldId="studio-field-facts" items={draft.facts} onChange={(facts) => update({ facts })} />
|
<OrderedTextList label="사실" fieldId="studio-field-facts" items={draft.facts} onChange={(facts) => update({ facts })} />
|
||||||
|
<FieldNotice issues={issues} path="/facts" />
|
||||||
<OrderedTextList label="가정" fieldId="studio-field-assumptions" items={draft.assumptions} onChange={(assumptions) => update({ assumptions })} />
|
<OrderedTextList label="가정" fieldId="studio-field-assumptions" items={draft.assumptions} onChange={(assumptions) => update({ assumptions })} />
|
||||||
|
<FieldNotice issues={issues} path="/assumptions" />
|
||||||
<OrderedTextList label="미지수" fieldId="studio-field-unknowns" items={draft.unknowns} onChange={(unknowns) => update({ unknowns })} />
|
<OrderedTextList label="미지수" fieldId="studio-field-unknowns" items={draft.unknowns} onChange={(unknowns) => update({ unknowns })} />
|
||||||
|
<FieldNotice issues={issues} path="/unknowns" />
|
||||||
<OrderedTextList label="제약" fieldId="studio-field-constraints" items={draft.constraints} onChange={(constraints) => update({ constraints })} />
|
<OrderedTextList label="제약" fieldId="studio-field-constraints" items={draft.constraints} onChange={(constraints) => update({ constraints })} />
|
||||||
|
<FieldNotice issues={issues} path="/constraints" />
|
||||||
<fieldset id="studio-field-options" className="studio-ordered-list" tabIndex={-1}><legend>선택지</legend>
|
<fieldset id="studio-field-options" className="studio-ordered-list" tabIndex={-1}><legend>선택지</legend>
|
||||||
|
<FieldNotice issues={issues} path="/options" />
|
||||||
{draft.options.length === 0 ? <p>아직 입력한 선택지가 없습니다.</p> : null}
|
{draft.options.length === 0 ? <p>아직 입력한 선택지가 없습니다.</p> : null}
|
||||||
{draft.options.map((option, index) => <div className="studio-ordered-item" key={option.id}>
|
{draft.options.map((option, index) => <div className="studio-ordered-item" key={option.id}>
|
||||||
<label><span>선택지 {index + 1} 제목</span><input value={option.title} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
|
<label><span>선택지 {index + 1} 제목</span><input value={option.title} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
|
||||||
@@ -40,8 +58,9 @@ export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionI
|
|||||||
</div>)}
|
</div>)}
|
||||||
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createLocalId("option"), title: "", description: "", order: draft.options.length }]) }); }}>선택지 추가</button>
|
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createLocalId("option"), title: "", description: "", order: draft.options.length }]) }); }}>선택지 추가</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<label className="studio-field studio-field--wide"><span>다음 검증</span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /></label>
|
<label className="studio-field studio-field--wide"><span>다음 검증</span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /><FieldNotice issues={issues} path="/nextValidation" /></label>
|
||||||
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend>해결 내용</legend>
|
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend>해결 내용</legend>
|
||||||
|
<FieldNotice issues={issues} path="/resolution" />
|
||||||
<label className="studio-field studio-field--wide"><span>해결 요약</span><textarea value={draft.resolution.summary} onChange={(event) => update({ resolution: { ...draft.resolution!, summary: event.currentTarget.value } })} /></label>
|
<label className="studio-field studio-field--wide"><span>해결 요약</span><textarea value={draft.resolution.summary} onChange={(event) => update({ resolution: { ...draft.resolution!, summary: event.currentTarget.value } })} /></label>
|
||||||
<label className="studio-field"><span>해결 근거</span><select value={draft.resolution.evidenceTargetId ?? ""} onChange={(event) => update({ resolution: { ...draft.resolution!, evidenceTargetId: event.currentTarget.value || null } })}><option value="">근거 선택</option>{evidence.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
<label className="studio-field"><span>해결 근거</span><select value={draft.resolution.evidenceTargetId ?? ""} onChange={(event) => update({ resolution: { ...draft.resolution!, evidenceTargetId: event.currentTarget.value || null } })}><option value="">근거 선택</option>{evidence.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||||
<label className="studio-field"><span>근거 링크 문구</span><input value={draft.resolution.linkLabel} onChange={(event) => update({ resolution: { ...draft.resolution!, linkLabel: event.currentTarget.value } })} /></label>
|
<label className="studio-field"><span>근거 링크 문구</span><input value={draft.resolution.linkLabel} onChange={(event) => update({ resolution: { ...draft.resolution!, linkLabel: event.currentTarget.value } })} /></label>
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
|
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||||
|
|
||||||
type ReferenceInput = components["schemas"]["ReferenceInput"];
|
type ReferenceInput = components["schemas"]["ReferenceInput"];
|
||||||
type ReferenceRule = components["schemas"]["ReferenceRule"];
|
type ReferenceRule = components["schemas"]["ReferenceRule"];
|
||||||
|
|
||||||
|
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
|
||||||
|
export const REFERENCE_FIELD_PATHS = [
|
||||||
|
"/purpose",
|
||||||
|
"/rules",
|
||||||
|
"/applyWhen",
|
||||||
|
"/exceptions",
|
||||||
|
"/examples",
|
||||||
|
"/verifiedOn",
|
||||||
|
] as const;
|
||||||
|
|
||||||
function orderedRules(rules: ReferenceRule[]): ReferenceRule[] {
|
function orderedRules(rules: ReferenceRule[]): ReferenceRule[] {
|
||||||
return rules.map((rule, order) => ({ ...rule, order }));
|
return rules.map((rule, order) => ({ ...rule, order }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; onChange(draft: ReferenceInput): void }) {
|
export function ReferenceFields({ draft, issues, onChange }: { draft: ReferenceInput; issues: readonly FieldIssue[]; onChange(draft: ReferenceInput): void }) {
|
||||||
const update = (patch: Partial<ReferenceInput>) => onChange({ ...draft, ...patch });
|
const update = (patch: Partial<ReferenceInput>) => onChange({ ...draft, ...patch });
|
||||||
const moveRule = (index: number, delta: -1 | 1) => {
|
const moveRule = (index: number, delta: -1 | 1) => {
|
||||||
const target = index + delta;
|
const target = index + delta;
|
||||||
@@ -21,8 +32,9 @@ export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; on
|
|||||||
return (
|
return (
|
||||||
<section className="studio-editor-section" aria-labelledby="studio-reference-fields-title">
|
<section className="studio-editor-section" aria-labelledby="studio-reference-fields-title">
|
||||||
<div className="studio-editor-section-heading"><p className="studio-eyebrow">REFERENCE</p><h2 id="studio-reference-fields-title">재사용할 기준</h2></div>
|
<div className="studio-editor-section-heading"><p className="studio-eyebrow">REFERENCE</p><h2 id="studio-reference-fields-title">재사용할 기준</h2></div>
|
||||||
<label className="studio-field studio-field--wide"><span>목적</span><textarea value={draft.purpose} onChange={(event) => update({ purpose: event.currentTarget.value })} /></label>
|
<label className="studio-field studio-field--wide"><span>목적</span><textarea value={draft.purpose} onChange={(event) => update({ purpose: event.currentTarget.value })} /><FieldNotice issues={issues} path="/purpose" /></label>
|
||||||
<fieldset className="studio-ordered-list"><legend>규칙</legend>
|
<fieldset className="studio-ordered-list"><legend>규칙</legend>
|
||||||
|
<FieldNotice issues={issues} path="/rules" />
|
||||||
{draft.rules.length === 0 ? <p>아직 입력한 규칙이 없습니다.</p> : null}
|
{draft.rules.length === 0 ? <p>아직 입력한 규칙이 없습니다.</p> : null}
|
||||||
{draft.rules.map((rule, index) => <div className="studio-ordered-item" key={rule.id}>
|
{draft.rules.map((rule, index) => <div className="studio-ordered-item" key={rule.id}>
|
||||||
<label><span>규칙 {index + 1} 제목</span><input value={rule.title} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
|
<label><span>규칙 {index + 1} 제목</span><input value={rule.title} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
|
||||||
@@ -32,9 +44,12 @@ export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; on
|
|||||||
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createLocalId("rule"), title: "", body: "", order: draft.rules.length }]) }); }}>규칙 추가</button>
|
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createLocalId("rule"), title: "", body: "", order: draft.rules.length }]) }); }}>규칙 추가</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
|
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
|
||||||
|
<FieldNotice issues={issues} path="/applyWhen" />
|
||||||
<OrderedTextList label="예외" items={draft.exceptions} onChange={(exceptions) => update({ exceptions })} />
|
<OrderedTextList label="예외" items={draft.exceptions} onChange={(exceptions) => update({ exceptions })} />
|
||||||
|
<FieldNotice issues={issues} path="/exceptions" />
|
||||||
<OrderedTextList label="예시" items={draft.examples} onChange={(examples) => update({ examples })} />
|
<OrderedTextList label="예시" items={draft.examples} onChange={(examples) => update({ examples })} />
|
||||||
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.verifiedOn ?? ""} onChange={(event) => update({ verifiedOn: event.currentTarget.value || null })} /></label>
|
<FieldNotice issues={issues} path="/examples" />
|
||||||
|
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.verifiedOn ?? ""} onChange={(event) => update({ verifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/verifiedOn" /></label>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,31 @@ export function TaxonomyManager() {
|
|||||||
setPending(true);
|
setPending(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await managementGateway.createProject(title);
|
const created = await managementGateway.createProject(title);
|
||||||
|
/*
|
||||||
|
생성 계약은 이름만 받는데(`CreateDraftRequest`), slug 가 없는 프로젝트는 공개 화면에
|
||||||
|
나타날 수 없다 — 공개 계약의 `ProjectSummary` 는 `slug` 와 `path` 를 요구하므로 서버는
|
||||||
|
slug 가 빈 프로젝트를 통째로 생략한다. 기록에 프로젝트를 붙여 게시해도 공개 문서의
|
||||||
|
프로젝트 칸이 비어 있던 이유가 이것이다.
|
||||||
|
|
||||||
|
그래서 만든 직후에 이름에서 만든 slug 를 채운다. 주제가 `{name, slug}` 를 함께 보내는
|
||||||
|
것과 같은 규칙이고(`slugFromName`), 한글 이름도 로마자로 옮겨 유효한 slug 가 된다.
|
||||||
|
*/
|
||||||
|
const project = await managementGateway.getProject(created.id);
|
||||||
|
await managementGateway.updateProject(created.id, {
|
||||||
|
expectedVersion: project.version,
|
||||||
|
name: project.name,
|
||||||
|
slug: slugFromName(project.name),
|
||||||
|
oneLinePurpose: project.oneLinePurpose ?? "",
|
||||||
|
purposeMarkdown: project.purposeMarkdown ?? "",
|
||||||
|
boundaryMarkdown: project.boundaryMarkdown ?? "",
|
||||||
|
phase: project.phase,
|
||||||
|
technologyLabels: project.technologyLabels ?? [],
|
||||||
|
targetVisibility:
|
||||||
|
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
|
||||||
|
? project.targetVisibility
|
||||||
|
: "PRIVATE",
|
||||||
|
});
|
||||||
setProjectTitle("");
|
setProjectTitle("");
|
||||||
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
|
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
|
||||||
reload();
|
reload();
|
||||||
|
|||||||
@@ -137,3 +137,25 @@
|
|||||||
.studio-app .studio-editor-validation li span { min-width: 30px; color: var(--danger, #b4232a); font-size: 11px; font-weight: 700; }
|
.studio-app .studio-editor-validation li span { min-width: 30px; color: var(--danger, #b4232a); font-size: 11px; font-weight: 700; }
|
||||||
.studio-app .studio-editor-validation li[data-severity="WARNING"] span { color: var(--muted); }
|
.studio-app .studio-editor-validation li[data-severity="WARNING"] span { color: var(--muted); }
|
||||||
.studio-app .studio-editor-validation code { color: var(--faint); font-size: 11px; }
|
.studio-app .studio-editor-validation code { color: var(--faint); font-size: 11px; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
칸 아래에 붙는 지적. 검증 결과를 화면 맨 위 한 덩어리로 모아 두는 대신 그 칸 옆에 두면,
|
||||||
|
작성자는 `/topicId` 같은 경로를 읽고 어느 칸인지 스스로 찾을 필요가 없다.
|
||||||
|
|
||||||
|
`.studio-field` 는 세로 흐름이므로 별도의 배치가 필요 없다 — 입력 바로 다음 줄에 놓인다.
|
||||||
|
*/
|
||||||
|
.studio-app .studio-field-notice { display: block; margin-top: 6px; font-size: 12px; line-height: 1.5; }
|
||||||
|
.studio-app .studio-field-notice--error { color: var(--danger, #b4232a); }
|
||||||
|
.studio-app .studio-field-notice--warning { color: var(--muted); }
|
||||||
|
.studio-app .studio-editor-unplaced-issues { display: grid; gap: 6px; margin: 10px 0 0; padding: 0; list-style: none; }
|
||||||
|
.studio-app .studio-editor-unplaced-issues li { font-size: 12px; color: var(--danger, #b4232a); }
|
||||||
|
.studio-app .studio-editor-unplaced-issues li[data-severity="WARNING"] { color: var(--muted); }
|
||||||
|
.studio-app .studio-editor-unplaced-issues code { color: var(--faint); font-size: 11px; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
저장과 게시는 되돌릴 수 있는 정도가 다르다 — 하나는 초안을 남기고, 하나는 공개한다. 둘이
|
||||||
|
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
|
||||||
|
*/
|
||||||
|
.studio-app .studio-document-status-rail button + button { margin-top: 10px; }
|
||||||
|
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
|
||||||
|
.studio-app .studio-document-status-rail button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
|
||||||
|
|||||||
@@ -104,18 +104,15 @@ describe("TechLog Studio document editor", () => {
|
|||||||
expect(screen.getByRole("complementary", { name: "작업 상태" })).toHaveTextContent(
|
expect(screen.getByRole("complementary", { name: "작업 상태" })).toHaveTextContent(
|
||||||
"저장 버전4종류CASE",
|
"저장 버전4종류CASE",
|
||||||
);
|
);
|
||||||
// 편집기는 다음 한 칸이 아니라 게시까지의 길을 보여 준다. 예전에는 "저장본 검증" 하나뿐이라,
|
// 작성자가 밟는 단계는 저장과 게시 둘뿐이다. 예전에는 검증 → 미리보기 → 게시 세 화면을
|
||||||
// 작성자가 그 단계를 밟아 본 뒤에야 뒤에 미리보기와 게시가 있다는 것을 알 수 있었다.
|
// 차례로 거쳐야 했는데, 그 셋은 백엔드가 게시 요청에 요구하는 값을 만들기 위한 것이지
|
||||||
const flow = screen.getByRole("navigation", { name: "게시까지의 단계" });
|
// 작성자에게 물어볼 것이 아니었다 — 지금은 게시 버튼 뒤에서 잇달아 부른다.
|
||||||
expect(
|
const rail = screen.getByRole("complementary", { name: "작업 상태" });
|
||||||
within(flow)
|
expect(within(rail).getAllByRole("button").map((button) => button.textContent)).toEqual([
|
||||||
.getAllByRole("link")
|
"저장",
|
||||||
.map((link) => [link.textContent, link.getAttribute("href")]),
|
"게시",
|
||||||
).toEqual([
|
|
||||||
["검증", `/studio/documents/${FIXTURE_IDS.redisAdapterCase}/validation`],
|
|
||||||
["미리보기", `/studio/documents/${FIXTURE_IDS.redisAdapterCase}/preview`],
|
|
||||||
["게시", `/studio/documents/${FIXTURE_IDS.redisAdapterCase}/publish`],
|
|
||||||
]);
|
]);
|
||||||
|
expect(within(rail).queryAllByRole("link")).toHaveLength(0);
|
||||||
|
|
||||||
const editTab = screen.getByRole("tab", { name: "편집" });
|
const editTab = screen.getByRole("tab", { name: "편집" });
|
||||||
const previewTab = screen.getByRole("tab", { name: "즉시 미리보기" });
|
const previewTab = screen.getByRole("tab", { name: "즉시 미리보기" });
|
||||||
|
|||||||
@@ -820,7 +820,12 @@ describe("candidate archive and provider upload boundaries", () => {
|
|||||||
await expect(readFile(path.join(cgroupRoot, "pids.max"), "utf8")).resolves.toBe("64\n");
|
await expect(readFile(path.join(cgroupRoot, "pids.max"), "utf8")).resolves.toBe("64\n");
|
||||||
await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
|
await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
|
||||||
expect(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
expect(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||||
expect(showProcessArguments(execution.child.pid)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
// Read once, while the supervisor is still alive, and reuse below. `argv`
|
||||||
|
// does not change over a process's life, so the recorded value says the
|
||||||
|
// same thing a second lookup would -- except that by the time the run has
|
||||||
|
// completed there is no process left to look up, and `ps` failed.
|
||||||
|
const supervisorArguments = showProcessArguments(execution.child.pid);
|
||||||
|
expect(supervisorArguments).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||||
const reportIdentity = await lstat(fixture.reportPath);
|
const reportIdentity = await lstat(fixture.reportPath);
|
||||||
const result = await execution.completion;
|
const result = await execution.completion;
|
||||||
tree.stop();
|
tree.stop();
|
||||||
@@ -837,7 +842,7 @@ describe("candidate archive and provider upload boundaries", () => {
|
|||||||
const directChildPids = tree.directChildPids();
|
const directChildPids = tree.directChildPids();
|
||||||
const directChildArguments = tree.directChildArguments();
|
const directChildArguments = tree.directChildArguments();
|
||||||
const observedArguments = [
|
const observedArguments = [
|
||||||
showProcessArguments(execution.child.pid),
|
supervisorArguments,
|
||||||
...directChildArguments,
|
...directChildArguments,
|
||||||
...processArguments,
|
...processArguments,
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user