fix: 오류 수정
This commit is contained in:
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"contractSet": {
|
||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||
"setDigest": "sha256:8b5bcbc235bc825483002374f84d433e2668a613e270e6558934fa277bd62762",
|
||||
"setDigest": "sha256:7ee35548d2a84b744f8c17c7b785a79b328dcf756f5c9df8d77d37ff1535b087",
|
||||
"packages": [
|
||||
{
|
||||
"packageId": "@tech-log/management-contract",
|
||||
@@ -49,8 +49,8 @@
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.0.0",
|
||||
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
|
||||
"version": "2.1.0",
|
||||
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "b195b29"
|
||||
},
|
||||
|
||||
@@ -162,12 +162,27 @@ export function createHttpPublicContentGateway(
|
||||
kind: "CASE",
|
||||
problem: (body.problemSummary as string) ?? "",
|
||||
conclusion: (body.conclusionSummary as string) ?? "",
|
||||
environment: ((body.environmentSummary as readonly string[]) ?? []).join(", "),
|
||||
// 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.
|
||||
verification: "",
|
||||
// `environmentSummary` 는 검증 환경과 재현 조건을 그 순서로 담는다 — 서버가 비어 있지
|
||||
// 않은 것만 순서대로 넣는다. 예전에는 둘을 쉼표로 이어 붙여 한 칸에 넣고 재현 조건 칸은
|
||||
// "계약에 없다"며 비워 두었는데, 계약에는 있었고 채우는 쪽이 없었을 뿐이다.
|
||||
environment: ((body.environmentSummary as readonly string[]) ?? [])[0] ?? "",
|
||||
verification: ((body.environmentSummary as readonly string[]) ?? [])[1] ?? "",
|
||||
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),
|
||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||
}
|
||||
|
||||
@@ -194,6 +194,9 @@ export function knowledgeListItemToRecord(item: Readonly<Record<string, unknown>
|
||||
environment: "",
|
||||
verification: "",
|
||||
lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string),
|
||||
// 목록 항목은 본문을 담지 않는다 — 본문은 상세 조회에서만 온다.
|
||||
content: "",
|
||||
bodyAssets: Object.freeze([]),
|
||||
sections: Object.freeze([]),
|
||||
}) as CaseRecord;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,18 @@ type PublicRecordBase = {
|
||||
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 & {
|
||||
kind: "CASE";
|
||||
problem: string;
|
||||
@@ -42,6 +54,9 @@ export type CaseRecord = PublicRecordBase & {
|
||||
environment: string;
|
||||
verification: string;
|
||||
lastVerifiedLabel: string;
|
||||
/** 본문 Markdown 원문. 정적 기록은 문서 화면이 자체 본문을 쓰므로 비어 있다. */
|
||||
content: string;
|
||||
bodyAssets: ReadonlyArray<PublicBodyAsset>;
|
||||
sections: ReadonlyArray<RecordSection>;
|
||||
};
|
||||
|
||||
@@ -173,6 +188,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
|
||||
environment: "PostgreSQL 16 · Hibernate 6 · Spring Data JPA",
|
||||
verification: "FeedItem 100개, Zipf 편중 Highlight/Mention",
|
||||
lastVerifiedLabel: "2026.08.11",
|
||||
content: "",
|
||||
bodyAssets: [],
|
||||
sections: [
|
||||
{
|
||||
id: "fix-the-problem",
|
||||
@@ -256,6 +273,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
|
||||
environment: "Spring Boot · Redis · Testcontainers",
|
||||
verification: "동일한 Port 계약으로 In-memory와 Redis Adapter 계약 테스트 실행",
|
||||
lastVerifiedLabel: "2026.08.07",
|
||||
content: "",
|
||||
bodyAssets: [],
|
||||
sections: [
|
||||
{
|
||||
id: "ownership",
|
||||
|
||||
@@ -36,6 +36,23 @@ type PublicRecordBase = {
|
||||
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 & {
|
||||
kind: "CASE";
|
||||
problem: string;
|
||||
@@ -43,6 +60,14 @@ export type CaseRecord = PublicRecordBase & {
|
||||
environment: string;
|
||||
verification: string;
|
||||
lastVerifiedLabel: string;
|
||||
/**
|
||||
* 본문 Markdown 원문.
|
||||
*
|
||||
* <p>`sections` 는 이것을 제목·문단·불릿으로만 줄인 것이라 표·코드·callout·evidence 가 사라진다.
|
||||
* 문서 화면은 원문을 직접 파싱한다.
|
||||
*/
|
||||
content: string;
|
||||
bodyAssets: ReadonlyArray<PublicBodyAsset>;
|
||||
sections: ReadonlyArray<RecordSection>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.0.0",
|
||||
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
|
||||
"version": "2.1.0",
|
||||
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
||||
"sourceRevision": "b195b29",
|
||||
"operationIds": [
|
||||
"getPublicSite",
|
||||
|
||||
@@ -373,6 +373,30 @@ export interface components {
|
||||
height?: number;
|
||||
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: {
|
||||
/** @enum {string} */
|
||||
type: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT" | "PROJECT_DECISION" | "RELEASE";
|
||||
@@ -523,6 +547,10 @@ export interface components {
|
||||
tags: components["schemas"]["TagSummary"][];
|
||||
primaryProject?: components["schemas"]["ProjectSummary"];
|
||||
coverAsset?: components["schemas"]["AssetReference"];
|
||||
/** @description 본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한
|
||||
* Asset 이 더 이상 서빙되지 않는 경우다.
|
||||
* */
|
||||
bodyAssets?: components["schemas"]["BodyAsset"][];
|
||||
/** Format: date-time */
|
||||
publishedAt: string;
|
||||
/** Format: date-time */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tech Log Public API
|
||||
version: 2.0.0
|
||||
version: 2.1.0
|
||||
description: |
|
||||
Tech Log 공개 조회 계약이다. 인증이 필요하지 않다.
|
||||
|
||||
@@ -815,6 +815,47 @@ components:
|
||||
type: integer
|
||||
contentType:
|
||||
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:
|
||||
type: object
|
||||
required:
|
||||
@@ -1224,6 +1265,13 @@ components:
|
||||
$ref: '#/components/schemas/ProjectSummary'
|
||||
coverAsset:
|
||||
$ref: '#/components/schemas/AssetReference'
|
||||
bodyAssets:
|
||||
type: array
|
||||
description: |
|
||||
본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한
|
||||
Asset 이 더 이상 서빙되지 않는 경우다.
|
||||
items:
|
||||
$ref: '#/components/schemas/BodyAsset'
|
||||
publishedAt: *id003
|
||||
updatedAt: *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 }) {
|
||||
/*
|
||||
본문은 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(
|
||||
{
|
||||
...publicRenderModelBase(record),
|
||||
@@ -190,18 +225,37 @@ export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
||||
environment: record.environment,
|
||||
reproduction: record.verification,
|
||||
lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"),
|
||||
bodyBlocks:
|
||||
record.slug === "collection-fetch-join-pagination"
|
||||
? parseCaseContent(fetchJoinBody)
|
||||
: genericCaseBlocks(record),
|
||||
bodyBlocks: blocks,
|
||||
},
|
||||
(key) => {
|
||||
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 (
|
||||
<PublicRecordRenderer
|
||||
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) =>
|
||||
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 { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx";
|
||||
import { AssetUploadDialog } from "./asset-upload-dialog.tsx";
|
||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||
|
||||
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 }> = [
|
||||
{ value: "IMAGE", label: "이미지" },
|
||||
{ value: "DIAGRAM", label: "다이어그램" },
|
||||
@@ -36,11 +47,14 @@ function insertAtCursor(
|
||||
|
||||
export function CaseFields({
|
||||
draft,
|
||||
issues,
|
||||
onChange,
|
||||
onAssetsObserved,
|
||||
onAssetUploaded,
|
||||
}: {
|
||||
draft: CaseInput;
|
||||
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
|
||||
issues: readonly FieldIssue[];
|
||||
onChange(draft: CaseInput): void;
|
||||
/**
|
||||
* 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">
|
||||
<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">
|
||||
<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.conclusion} onChange={(event) => update({ conclusion: event.currentTarget.value })} /></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.reproduction} onChange={(event) => update({ reproduction: event.currentTarget.value })} /></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 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>문제</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 })} /><FieldNotice issues={issues} path="/conclusion" /></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 })} /><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 })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
|
||||
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
|
||||
</div>
|
||||
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
|
||||
<p className="studio-eyebrow">EVIDENCE</p>
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||
import { RelationEditor } from "./relation-editor.tsx";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
|
||||
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. 나머지는 게시 버튼 옆에 남는다. */
|
||||
export const COMMON_FIELD_PATHS = [
|
||||
"/title",
|
||||
"/slug",
|
||||
"/summary",
|
||||
"/topicId",
|
||||
"/projectId",
|
||||
"/relations",
|
||||
] as const;
|
||||
|
||||
export function CommonDocumentFields({
|
||||
draft,
|
||||
topics,
|
||||
projects,
|
||||
relations,
|
||||
issues,
|
||||
onUpdate,
|
||||
}: {
|
||||
draft: WorkingCopyInput;
|
||||
topics: CatalogEntry[];
|
||||
projects: CatalogEntry[];
|
||||
relations: CatalogEntry[];
|
||||
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
|
||||
issues: readonly FieldIssue[];
|
||||
onUpdate(patch: Partial<WorkingCopyInput>): void;
|
||||
}) {
|
||||
return (
|
||||
<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-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"><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 studio-field--wide"><span>요약</span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /></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>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 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"] })} /><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 })} /><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><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><FieldNotice issues={issues} path="/projectId" /></label>
|
||||
</div>
|
||||
<RelationEditor evidence={draft.kind === "PROJECT_DECISION"} relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
|
||||
<FieldNotice issues={issues} path="/relations" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,26 @@ export type DocumentEditorController = {
|
||||
version: number;
|
||||
issues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
|
||||
}> | null;
|
||||
/**
|
||||
* 게시가 진행 중인가. 한 번의 클릭이 저장·검증·미리보기·게시 네 번의 왕복을 만들므로, 그
|
||||
* 사이에 다시 누르면 같은 문서를 두 번 게시하려 든다.
|
||||
*/
|
||||
publishing: boolean;
|
||||
/** 게시가 실패한 이유. 서버가 준 문구를 그대로 쓴다. */
|
||||
publishError: string;
|
||||
/**
|
||||
* 게시를 막은 검증 항목. 예전에는 이것을 보려면 검증 화면으로 나가야 했다 — 고칠 칸은 편집
|
||||
* 화면에 있는데 무엇이 모자란지는 다른 화면에 있었다. 지금은 막힌 자리에서 바로 보여 준다.
|
||||
*/
|
||||
publishIssues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
|
||||
update(patch: Partial<WorkingCopyInput>): void;
|
||||
replace(draft: WorkingCopyInput): 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 [publishing, setPublishing] = useState(false);
|
||||
const [publishError, setPublishError] = useState("");
|
||||
const [publishIssues, setPublishIssues] = useState<readonly ValidationIssue[]>([]);
|
||||
const observeAssets = useCallback((observed: readonly Asset[]) => {
|
||||
setAssets((current) => mergeAssetCatalog(current, observed));
|
||||
}, []);
|
||||
@@ -173,6 +176,112 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
}
|
||||
}, [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,
|
||||
saveError,
|
||||
validation,
|
||||
publishing,
|
||||
publishError,
|
||||
publishIssues,
|
||||
update(patch) {
|
||||
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
|
||||
},
|
||||
replace(draft) {
|
||||
if (draft.kind === editor.draft.kind) updateDraft(draft);
|
||||
},
|
||||
publish,
|
||||
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 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 { Asset } from "../../../contracts/studio/contract.ts";
|
||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||
import { CaseFields } from "./case-fields.tsx";
|
||||
import { CommonDocumentFields } from "./common-document-fields.tsx";
|
||||
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
|
||||
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
|
||||
import { DocumentStatusRail } from "./document-status-rail.tsx";
|
||||
import { InstantPreview } from "./instant-preview.tsx";
|
||||
import { ProjectDecisionFields } from "./project-decision-fields.tsx";
|
||||
import { QuestionFields } from "./question-fields.tsx";
|
||||
import { ReferenceFields } from "./reference-fields.tsx";
|
||||
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
|
||||
import { issuesOutside } from "./field-issues.tsx";
|
||||
import { QUESTION_FIELD_PATHS, QuestionFields } from "./question-fields.tsx";
|
||||
import { REFERENCE_FIELD_PATHS, ReferenceFields } from "./reference-fields.tsx";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
|
||||
@@ -48,6 +49,23 @@ export function DocumentEditor({
|
||||
const relations = catalog.filter(({ type }) => type === "RELATION");
|
||||
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 (
|
||||
<div className="studio-editor-page">
|
||||
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
|
||||
@@ -62,49 +80,20 @@ export function DocumentEditor({
|
||||
<h1>문서 편집</h1>
|
||||
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
||||
</header>
|
||||
{/*
|
||||
게시를 막는 것이 무엇인지 고치는 자리에서 보여 준다. 예전에는 별도 검증 화면으로
|
||||
나가야만 알 수 있었고, 돌아와서는 어느 칸이었는지 기억해야 했다.
|
||||
|
||||
검증은 저장된 버전을 기준으로 도므로 이 목록도 그 버전의 것이다 — 저장한 뒤 다시
|
||||
검증하기 전까지는 방금 고친 것이 아직 반영되지 않는다. 그 사실을 숨기지 않는다.
|
||||
*/}
|
||||
{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} />
|
||||
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
|
||||
{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"
|
||||
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
|
||||
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
|
||||
: controller.draft.kind === "QUESTION"
|
||||
? <QuestionFields draft={controller.draft} evidence={evidence} onChange={controller.replace} />
|
||||
: <ProjectDecisionFields draft={controller.draft} onChange={controller.replace} />}
|
||||
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
|
||||
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
|
||||
</div>
|
||||
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
|
||||
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
||||
</div>
|
||||
</div>
|
||||
<DocumentStatusRail controller={controller} />
|
||||
<DocumentStatusRail controller={controller} unplacedIssues={unplaced} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import type { FieldIssue } from "./field-issues.tsx";
|
||||
|
||||
const labels = {
|
||||
CLEAN: "저장됨",
|
||||
@@ -15,36 +15,42 @@ const kindLabels = {
|
||||
PROJECT_DECISION: "Decision",
|
||||
} 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 (
|
||||
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
|
||||
<p className="studio-eyebrow">WORKING COPY</p>
|
||||
<h2 id="studio-document-status-title">작업 상태</h2>
|
||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||
<button type="button" onClick={() => { void controller.save(); }} disabled={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="게시까지의 단계">
|
||||
<ol>
|
||||
{[
|
||||
{ label: "검증", href: `/studio/documents/${controller.saved.id}/validation` },
|
||||
{ label: "미리보기", href: `/studio/documents/${controller.saved.id}/preview` },
|
||||
{ label: "게시", href: `/studio/documents/${controller.saved.id}/publish` },
|
||||
].map((step, index) => (
|
||||
<li key={step.href}>
|
||||
<span aria-hidden="true">{index + 1}</span>
|
||||
<GuardedStudioLink href={step.href}>{step.label}</GuardedStudioLink>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
<button
|
||||
className="studio-primary-button"
|
||||
type="button"
|
||||
onClick={() => { void controller.publish(); }}
|
||||
disabled={busy || controller.status === "CONFLICT"}
|
||||
>
|
||||
{controller.publishing ? "게시 중…" : "게시"}
|
||||
</button>
|
||||
{/*
|
||||
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||
@@ -54,9 +60,20 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
|
||||
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
||||
) : controller.saveError ? (
|
||||
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
||||
) : controller.publishError ? (
|
||||
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
||||
) : (
|
||||
<p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||
|
||||
type ProjectDecisionInput = components["schemas"]["ProjectDecisionInput"];
|
||||
|
||||
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
|
||||
export const DECISION_FIELD_PATHS = [
|
||||
"/decisionStatus",
|
||||
"/decidedOn",
|
||||
"/statement",
|
||||
"/rationale",
|
||||
"/consequences",
|
||||
] as const;
|
||||
|
||||
export function ProjectDecisionFields({
|
||||
draft,
|
||||
issues,
|
||||
onChange,
|
||||
}: {
|
||||
draft: ProjectDecisionInput;
|
||||
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
|
||||
issues: readonly FieldIssue[];
|
||||
onChange(draft: ProjectDecisionInput): void;
|
||||
}) {
|
||||
const update = (patch: Partial<ProjectDecisionInput>) => {
|
||||
@@ -41,6 +54,7 @@ export function ProjectDecisionFields({
|
||||
<option value="PROPOSED">PROPOSED</option>
|
||||
<option value="ADOPTED">ADOPTED</option>
|
||||
</select>
|
||||
<FieldNotice issues={issues} path="/decisionStatus" />
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>결정일</span>
|
||||
@@ -51,6 +65,7 @@ export function ProjectDecisionFields({
|
||||
update({ decidedOn: event.currentTarget.value || null });
|
||||
}}
|
||||
/>
|
||||
<FieldNotice issues={issues} path="/decidedOn" />
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>결정문</span>
|
||||
@@ -58,6 +73,7 @@ export function ProjectDecisionFields({
|
||||
value={draft.statement}
|
||||
onChange={(event) => update({ statement: event.currentTarget.value })}
|
||||
/>
|
||||
<FieldNotice issues={issues} path="/statement" />
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>판단 이유</span>
|
||||
@@ -65,6 +81,7 @@ export function ProjectDecisionFields({
|
||||
value={draft.rationale}
|
||||
onChange={(event) => update({ rationale: event.currentTarget.value })}
|
||||
/>
|
||||
<FieldNotice issues={issues} path="/rationale" />
|
||||
</label>
|
||||
</div>
|
||||
<OrderedTextList
|
||||
@@ -73,6 +90,7 @@ export function ProjectDecisionFields({
|
||||
items={draft.consequences}
|
||||
onChange={(consequences) => update({ consequences })}
|
||||
/>
|
||||
<FieldNotice issues={issues} path="/consequences" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { FieldNotice, type FieldIssue } from "./field-issues.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 QuestionInput = components["schemas"]["QuestionInput"];
|
||||
type QuestionOption = components["schemas"]["QuestionOption"];
|
||||
@@ -10,7 +23,7 @@ function orderedOptions(options: QuestionOption[]): QuestionOption[] {
|
||||
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 moveOption = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
@@ -26,12 +39,17 @@ export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionI
|
||||
const value = event.currentTarget.value;
|
||||
if (value === "RESOLVED") update({ questionStatus: "RESOLVED", resolution: draft.resolution ?? { summary: "", evidenceTargetId: null, linkLabel: "" } });
|
||||
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 })} />
|
||||
<FieldNotice issues={issues} path="/facts" />
|
||||
<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 })} />
|
||||
<FieldNotice issues={issues} path="/unknowns" />
|
||||
<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>
|
||||
<FieldNotice issues={issues} path="/options" />
|
||||
{draft.options.length === 0 ? <p>아직 입력한 선택지가 없습니다.</p> : null}
|
||||
{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>
|
||||
@@ -40,8 +58,9 @@ export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionI
|
||||
</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>
|
||||
</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>
|
||||
<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"><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>
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||
|
||||
type ReferenceInput = components["schemas"]["ReferenceInput"];
|
||||
type ReferenceRule = components["schemas"]["ReferenceRule"];
|
||||
|
||||
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
|
||||
export const REFERENCE_FIELD_PATHS = [
|
||||
"/purpose",
|
||||
"/rules",
|
||||
"/applyWhen",
|
||||
"/exceptions",
|
||||
"/examples",
|
||||
"/verifiedOn",
|
||||
] as const;
|
||||
|
||||
function orderedRules(rules: ReferenceRule[]): ReferenceRule[] {
|
||||
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 moveRule = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
@@ -21,8 +32,9 @@ export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; on
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
<FieldNotice issues={issues} path="/rules" />
|
||||
{draft.rules.length === 0 ? <p>아직 입력한 규칙이 없습니다.</p> : null}
|
||||
{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>
|
||||
@@ -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>
|
||||
</fieldset>
|
||||
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
|
||||
<FieldNotice issues={issues} path="/applyWhen" />
|
||||
<OrderedTextList label="예외" items={draft.exceptions} onChange={(exceptions) => update({ exceptions })} />
|
||||
<FieldNotice issues={issues} path="/exceptions" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,31 @@ export function TaxonomyManager() {
|
||||
setPending(true);
|
||||
setError("");
|
||||
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("");
|
||||
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
|
||||
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[data-severity="WARNING"] span { color: var(--muted); }
|
||||
.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(
|
||||
"저장 버전4종류CASE",
|
||||
);
|
||||
// 편집기는 다음 한 칸이 아니라 게시까지의 길을 보여 준다. 예전에는 "저장본 검증" 하나뿐이라,
|
||||
// 작성자가 그 단계를 밟아 본 뒤에야 뒤에 미리보기와 게시가 있다는 것을 알 수 있었다.
|
||||
const flow = screen.getByRole("navigation", { name: "게시까지의 단계" });
|
||||
expect(
|
||||
within(flow)
|
||||
.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`],
|
||||
// 작성자가 밟는 단계는 저장과 게시 둘뿐이다. 예전에는 검증 → 미리보기 → 게시 세 화면을
|
||||
// 차례로 거쳐야 했는데, 그 셋은 백엔드가 게시 요청에 요구하는 값을 만들기 위한 것이지
|
||||
// 작성자에게 물어볼 것이 아니었다 — 지금은 게시 버튼 뒤에서 잇달아 부른다.
|
||||
const rail = screen.getByRole("complementary", { name: "작업 상태" });
|
||||
expect(within(rail).getAllByRole("button").map((button) => button.textContent)).toEqual([
|
||||
"저장",
|
||||
"게시",
|
||||
]);
|
||||
expect(within(rail).queryAllByRole("link")).toHaveLength(0);
|
||||
|
||||
const editTab = 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, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
|
||||
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 result = await execution.completion;
|
||||
tree.stop();
|
||||
@@ -837,7 +842,7 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
const directChildPids = tree.directChildPids();
|
||||
const directChildArguments = tree.directChildArguments();
|
||||
const observedArguments = [
|
||||
showProcessArguments(execution.child.pid),
|
||||
supervisorArguments,
|
||||
...directChildArguments,
|
||||
...processArguments,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user