feat: insert evidence directives from the TechLog asset picker
Adds an Asset Picker and upload dialog to the CASE editor so authors can insert `:::evidence` directives that reference backend assets, and opens projectWorkingCopy's two evidence gates so Instant Preview accepts a key backed by a freshly loaded READY asset instead of only the one hardcoded legacy key. The editor screen now owns the loaded Asset list so the Picker, the upload dialog, and Instant Preview all read the same array, and a freshly uploaded asset appears in the preview without a refetch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e2c1d076f4
commit
54d9bf9120
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||||||
|
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A directive attribute value is delimited by double quotes (see
|
||||||
|
* `parse-case-content.ts`'s `attributesOf`), so a value containing one would
|
||||||
|
* corrupt the directive -- and a raw newline would spill the directive across
|
||||||
|
* lines the block-directive parser does not expect inside an attribute. Both
|
||||||
|
* are stripped/folded rather than escaped: there is no escape syntax the
|
||||||
|
* parser accepts inside a directive attribute.
|
||||||
|
*/
|
||||||
|
function attributeValue(raw: string): string {
|
||||||
|
return raw.replaceAll('"', "").replace(/\s+/gu, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEvidenceDirective(
|
||||||
|
input: Readonly<{ assetKey: string; alt: string; caption: string; zoom: boolean }>,
|
||||||
|
): string {
|
||||||
|
const key = attributeValue(input.assetKey);
|
||||||
|
const alt = attributeValue(input.alt);
|
||||||
|
const caption = attributeValue(input.caption);
|
||||||
|
return `:::evidence key="${key}" alt="${alt}" caption="${caption}" zoom="${input.zoom}"\n:::`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetPicker({
|
||||||
|
gateway,
|
||||||
|
onInsert,
|
||||||
|
onLoaded,
|
||||||
|
}: Readonly<{
|
||||||
|
gateway: StudioAssetGateway;
|
||||||
|
onInsert: (directive: string) => void;
|
||||||
|
/**
|
||||||
|
* Optional so Task 10 Step 1's original test (which renders `AssetPicker`
|
||||||
|
* without it) keeps passing. When supplied, the editor screen uses this to
|
||||||
|
* own the same Asset list the Instant Preview resolver reads -- so a
|
||||||
|
* directive this Picker just inserted renders immediately instead of as a
|
||||||
|
* placeholder.
|
||||||
|
*/
|
||||||
|
onLoaded?: (assets: readonly Asset[]) => void;
|
||||||
|
}>) {
|
||||||
|
const [assets, setAssets] = useState<readonly Asset[]>([]);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
gateway
|
||||||
|
.listAssets({ managementStatus: "READY", limit: 50 }, { signal: controller.signal })
|
||||||
|
.then((page) => {
|
||||||
|
setAssets(page.items);
|
||||||
|
onLoaded?.(page.items);
|
||||||
|
})
|
||||||
|
.catch(() => setFailed(true));
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [gateway, onLoaded]);
|
||||||
|
|
||||||
|
// READY만 삽입 후보다. 서버 필터를 신뢰하되 방어적으로 한 번 더 거른다.
|
||||||
|
const selectable = assets.filter((asset) => asset.managementStatus === "READY");
|
||||||
|
|
||||||
|
if (failed) return <p className="studio-error">Asset 목록을 불러오지 못했습니다.</p>;
|
||||||
|
|
||||||
|
if (selectable.length === 0) {
|
||||||
|
return <p className="studio-asset-picker-empty">삽입할 수 있는 Asset이 없습니다. 먼저 업로드하세요.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="asset-picker">
|
||||||
|
<ul>
|
||||||
|
{selectable.map((asset) => <li key={asset.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onInsert(buildEvidenceDirective({
|
||||||
|
assetKey: asset.assetKey,
|
||||||
|
alt: asset.decorative ? "" : (asset.altText ?? ""),
|
||||||
|
caption: "",
|
||||||
|
zoom: asset.kind === "DIAGRAM",
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{asset.assetKey}
|
||||||
|
</button>
|
||||||
|
</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
|
import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
|
||||||
|
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
|
||||||
|
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
||||||
|
|
||||||
|
export type UploadState =
|
||||||
|
| { kind: "IDLE" }
|
||||||
|
| { kind: "SELECTION_FAILED"; message: string }
|
||||||
|
| { kind: "UPLOADING" }
|
||||||
|
| { kind: "TRANSPORT_FAILED"; message: string }
|
||||||
|
| { kind: "TOO_LARGE" }
|
||||||
|
| { kind: "UNSUPPORTED_TYPE" }
|
||||||
|
| { kind: "READY"; asset: Asset }
|
||||||
|
| { kind: "REJECTED"; asset: Asset }
|
||||||
|
| { kind: "QUARANTINED"; asset: Asset };
|
||||||
|
|
||||||
|
/** 업로드 전송 성공과 서버 검증 성공은 다르다. 성공 응답도 상태로 나눈다. */
|
||||||
|
export function stateForUploaded(asset: Asset): UploadState {
|
||||||
|
switch (asset.managementStatus) {
|
||||||
|
case "READY":
|
||||||
|
return { kind: "READY", asset };
|
||||||
|
case "QUARANTINED":
|
||||||
|
return { kind: "QUARANTINED", asset };
|
||||||
|
case "REJECTED":
|
||||||
|
case "ARCHIVED":
|
||||||
|
return { kind: "REJECTED", asset };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stateForError(error: unknown): UploadState {
|
||||||
|
if (isStudioGatewayError(error)) {
|
||||||
|
if (error.code === "PAYLOAD_TOO_LARGE") return { kind: "TOO_LARGE" };
|
||||||
|
if (error.code === "UNSUPPORTED_MEDIA_TYPE") return { kind: "UNSUPPORTED_TYPE" };
|
||||||
|
return { kind: "TRANSPORT_FAILED", message: error.problem.detail };
|
||||||
|
}
|
||||||
|
return { kind: "TRANSPORT_FAILED", message: "업로드를 전송하지 못했습니다." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES: Record<UploadState["kind"], string> = {
|
||||||
|
IDLE: "",
|
||||||
|
SELECTION_FAILED: "파일을 선택하지 못했습니다.",
|
||||||
|
UPLOADING: "업로드 중입니다.",
|
||||||
|
TRANSPORT_FAILED: "업로드를 전송하지 못했습니다.",
|
||||||
|
TOO_LARGE: "파일 크기가 허용 범위를 넘었습니다.",
|
||||||
|
UNSUPPORTED_TYPE: "지원하지 않는 파일 형식입니다.",
|
||||||
|
READY: "업로드했습니다.",
|
||||||
|
REJECTED: "서버 검증에서 거절되어 사용할 수 없습니다.",
|
||||||
|
QUARANTINED: "보안 검사에서 격리되어 사용할 수 없습니다.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AssetUploadDialog(props: Readonly<{
|
||||||
|
gateway: StudioAssetGateway;
|
||||||
|
kind: AssetKind;
|
||||||
|
idempotencyKey: string;
|
||||||
|
onUploaded: (asset: Asset) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}>) {
|
||||||
|
const [state, setState] = useState<UploadState>({ kind: "IDLE" });
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
|
const uploading = state.kind === "UPLOADING";
|
||||||
|
|
||||||
|
// This dialog has no `open` prop -- the caller mounts it to open it and
|
||||||
|
// unmounts it to close it (`unpublish-dialog.tsx`'s toggle-by-prop shape
|
||||||
|
// does not fit a one-shot upload flow). So the modal opens on mount and the
|
||||||
|
// unmount cleanup below closes it if the user never did.
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
dialog.showModal();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return () => {
|
||||||
|
if (dialog.open && typeof dialog.close === "function") dialog.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function submit(file: File) {
|
||||||
|
setState({ kind: "UPLOADING" });
|
||||||
|
try {
|
||||||
|
const asset = await props.gateway.uploadAsset(
|
||||||
|
{ file, kind: props.kind },
|
||||||
|
{ idempotencyKey: props.idempotencyKey },
|
||||||
|
);
|
||||||
|
const next = stateForUploaded(asset);
|
||||||
|
setState(next);
|
||||||
|
if (next.kind === "READY") props.onUploaded(asset);
|
||||||
|
} catch (error) {
|
||||||
|
setState(stateForError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trapFocus = (event: KeyboardEvent<HTMLDialogElement>) => {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = Array.from(
|
||||||
|
event.currentTarget.querySelectorAll<HTMLElement>(
|
||||||
|
"input:not([disabled]), button:not([disabled])",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!controls.length) return;
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls.at(-1)!;
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className="studio-asset-upload-dialog"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!uploading) props.onClose();
|
||||||
|
}}
|
||||||
|
onKeyDown={trapFocus}
|
||||||
|
>
|
||||||
|
<div className="studio-dialog-body">
|
||||||
|
<p className="studio-eyebrow">ASSET UPLOAD</p>
|
||||||
|
<h2 id={titleId}>Asset 업로드</h2>
|
||||||
|
<p id={descriptionId}>
|
||||||
|
업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>Asset 파일</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml,application/pdf"
|
||||||
|
disabled={uploading}
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void submit(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="studio-dialog-status" role="status" aria-live="polite">{MESSAGES[state.kind]}</p>
|
||||||
|
<div className="studio-dialog-actions">
|
||||||
|
<button type="button" disabled={uploading} onClick={props.onClose}>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,79 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
|
||||||
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
|
import { useStudioAssetGateway } from "../use-studio.ts";
|
||||||
|
import { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx";
|
||||||
|
import { AssetUploadDialog } from "./asset-upload-dialog.tsx";
|
||||||
|
|
||||||
type CaseInput = components["schemas"]["CaseInput"];
|
type CaseInput = components["schemas"]["CaseInput"];
|
||||||
|
|
||||||
export function CaseFields({ draft, onChange }: { draft: CaseInput; onChange(draft: CaseInput): void }) {
|
const ASSET_KIND_OPTIONS: ReadonlyArray<{ value: AssetKind; label: string }> = [
|
||||||
|
{ value: "IMAGE", label: "이미지" },
|
||||||
|
{ value: "DIAGRAM", label: "다이어그램" },
|
||||||
|
{ value: "ATTACHMENT", label: "첨부파일" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `:::evidence ...` is a block directive, so the parser requires a blank line
|
||||||
|
* before and after it. Inserting straight into `selectionStart`/`selectionEnd`
|
||||||
|
* without normalizing the surrounding whitespace would produce a directive
|
||||||
|
* glued to adjacent text -- a well-formed-looking block that the parser still
|
||||||
|
* rejects.
|
||||||
|
*/
|
||||||
|
function insertAtCursor(
|
||||||
|
textarea: HTMLTextAreaElement,
|
||||||
|
directive: string,
|
||||||
|
commit: (next: string) => void,
|
||||||
|
) {
|
||||||
|
const { selectionStart, selectionEnd, value } = textarea;
|
||||||
|
const prefix = value.slice(0, selectionStart);
|
||||||
|
const suffix = value.slice(selectionEnd);
|
||||||
|
const before = prefix.length === 0 || prefix.endsWith("\n\n") ? prefix : `${prefix}\n\n`;
|
||||||
|
const after = suffix.startsWith("\n") ? suffix : `\n${suffix}`;
|
||||||
|
commit(`${before}${directive}${after}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CaseFields({
|
||||||
|
draft,
|
||||||
|
onChange,
|
||||||
|
onAssetsLoaded,
|
||||||
|
onAssetUploaded,
|
||||||
|
}: {
|
||||||
|
draft: CaseInput;
|
||||||
|
onChange(draft: CaseInput): void;
|
||||||
|
/** The editor screen owns the loaded Asset list so the Picker and Instant Preview read the same array. */
|
||||||
|
onAssetsLoaded?: (assets: readonly Asset[]) => void;
|
||||||
|
onAssetUploaded?: (asset: Asset) => void;
|
||||||
|
}) {
|
||||||
|
// Asset UI must use the throwing accessor, not the nullable `assetGateway`
|
||||||
|
// field directly -- see `use-studio.ts`'s `useStudioAssetGateway` doc.
|
||||||
|
const assetGateway = useStudioAssetGateway();
|
||||||
|
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const uploadTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [uploadKind, setUploadKind] = useState<AssetKind>("IMAGE");
|
||||||
|
const [uploadKey, setUploadKey] = useState<string | null>(null);
|
||||||
const update = (patch: Partial<CaseInput>) => onChange({ ...draft, ...patch });
|
const update = (patch: Partial<CaseInput>) => onChange({ ...draft, ...patch });
|
||||||
|
|
||||||
|
const insertDirective = (directive: string) => {
|
||||||
|
const textarea = bodyRef.current;
|
||||||
|
if (!textarea) {
|
||||||
|
update({
|
||||||
|
bodyMarkdown: draft.bodyMarkdown
|
||||||
|
? `${draft.bodyMarkdown}\n\n${directive}\n`
|
||||||
|
: `${directive}\n`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
insertAtCursor(textarea, directive, (next) => update({ bodyMarkdown: next }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeUpload = () => {
|
||||||
|
setUploadKey(null);
|
||||||
|
queueMicrotask(() => uploadTriggerRef.current?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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>
|
||||||
@@ -13,8 +83,47 @@ export function CaseFields({ draft, onChange }: { draft: CaseInput; onChange(dra
|
|||||||
<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 })} /></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 })} /></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 })} /></label>
|
||||||
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea 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 })} /></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
|
||||||
|
<p className="studio-eyebrow">EVIDENCE</p>
|
||||||
|
<h3 id="studio-asset-panel-title">본문에 Asset 삽입</h3>
|
||||||
|
<p>목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.</p>
|
||||||
|
<div className="studio-asset-panel-actions">
|
||||||
|
<label className="studio-asset-kind-field">
|
||||||
|
<span>업로드 종류</span>
|
||||||
|
<select value={uploadKind} onChange={(event) => setUploadKind(event.currentTarget.value as AssetKind)}>
|
||||||
|
{ASSET_KIND_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
ref={uploadTriggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUploadKey(createLocalId("studio-asset-upload"))}
|
||||||
|
>
|
||||||
|
Asset 업로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<AssetPicker gateway={assetGateway} onLoaded={onAssetsLoaded} onInsert={insertDirective} />
|
||||||
|
</div>
|
||||||
|
{uploadKey ? (
|
||||||
|
<AssetUploadDialog
|
||||||
|
gateway={assetGateway}
|
||||||
|
kind={uploadKind}
|
||||||
|
idempotencyKey={uploadKey}
|
||||||
|
onUploaded={(asset) => {
|
||||||
|
onAssetUploaded?.(asset);
|
||||||
|
insertDirective(buildEvidenceDirective({
|
||||||
|
assetKey: asset.assetKey,
|
||||||
|
alt: asset.decorative ? "" : (asset.altText ?? ""),
|
||||||
|
caption: "",
|
||||||
|
zoom: asset.kind === "DIAGRAM",
|
||||||
|
}));
|
||||||
|
closeUpload();
|
||||||
|
}}
|
||||||
|
onClose={closeUpload}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import type {
|
import type {
|
||||||
|
Asset,
|
||||||
WorkingCopy,
|
WorkingCopy,
|
||||||
WorkingCopyInput,
|
WorkingCopyInput,
|
||||||
} from "../../../contracts/studio/contract.ts";
|
} from "../../../contracts/studio/contract.ts";
|
||||||
@@ -34,10 +35,15 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
catalog: CatalogEntry[];
|
catalog: CatalogEntry[];
|
||||||
problem: Error | null;
|
problem: Error | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
// Owned here (not by CaseFields/AssetPicker) so the Picker, the upload
|
||||||
|
// dialog, and Instant Preview all read the same array -- a freshly
|
||||||
|
// uploaded asset appears in the preview without a refetch.
|
||||||
|
const [assets, setAssets] = useState<readonly Asset[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const request = new AbortController();
|
const request = new AbortController();
|
||||||
clear();
|
clear();
|
||||||
|
setAssets([]);
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
studio.gateway.getDocument(documentId, { signal: request.signal }),
|
studio.gateway.getDocument(documentId, { signal: request.signal }),
|
||||||
...catalogTypes.map((type) => studio.gateway.getCatalog(
|
...catalogTypes.map((type) => studio.gateway.getCatalog(
|
||||||
@@ -145,5 +151,15 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <DocumentEditor controller={controller!} catalog={currentResult.catalog} />;
|
return (
|
||||||
|
<DocumentEditor
|
||||||
|
controller={controller!}
|
||||||
|
catalog={currentResult.catalog}
|
||||||
|
assets={assets}
|
||||||
|
onAssetsLoaded={setAssets}
|
||||||
|
onAssetUploaded={(asset) =>
|
||||||
|
setAssets((current) => [asset, ...current.filter((existing) => existing.id !== asset.id)])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState, type KeyboardEvent } from "react";
|
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 { DocumentEditorController } from "./document-editor-controller.ts";
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
import { CaseFields } from "./case-fields.tsx";
|
import { CaseFields } from "./case-fields.tsx";
|
||||||
import { CommonDocumentFields } from "./common-document-fields.tsx";
|
import { CommonDocumentFields } from "./common-document-fields.tsx";
|
||||||
@@ -12,7 +13,20 @@ import { ReferenceFields } from "./reference-fields.tsx";
|
|||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
|
||||||
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
|
export function DocumentEditor({
|
||||||
|
controller,
|
||||||
|
catalog,
|
||||||
|
assets,
|
||||||
|
onAssetsLoaded,
|
||||||
|
onAssetUploaded,
|
||||||
|
}: {
|
||||||
|
controller: DocumentEditorController;
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
/** Owned by `DocumentEditorScreen` so the CASE editor's Picker/Upload and Instant Preview share one list. */
|
||||||
|
assets: readonly Asset[];
|
||||||
|
onAssetsLoaded: (assets: readonly Asset[]) => void;
|
||||||
|
onAssetUploaded: (asset: Asset) => void;
|
||||||
|
}) {
|
||||||
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
|
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
|
||||||
const editTab = useRef<HTMLButtonElement>(null);
|
const editTab = useRef<HTMLButtonElement>(null);
|
||||||
const previewTab = useRef<HTMLButtonElement>(null);
|
const previewTab = useRef<HTMLButtonElement>(null);
|
||||||
@@ -50,7 +64,7 @@ export function DocumentEditor({ controller, catalog }: { controller: DocumentEd
|
|||||||
</header>
|
</header>
|
||||||
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} onUpdate={controller.update} />
|
<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} />
|
? <CaseFields draft={controller.draft} onChange={controller.replace} onAssetsLoaded={onAssetsLoaded} onAssetUploaded={onAssetUploaded} />
|
||||||
: controller.draft.kind === "REFERENCE"
|
: controller.draft.kind === "REFERENCE"
|
||||||
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
|
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
|
||||||
: controller.draft.kind === "QUESTION"
|
: controller.draft.kind === "QUESTION"
|
||||||
@@ -58,7 +72,7 @@ export function DocumentEditor({ controller, catalog }: { controller: DocumentEd
|
|||||||
: <ProjectDecisionFields draft={controller.draft} onChange={controller.replace} />}
|
: <ProjectDecisionFields draft={controller.draft} 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} />
|
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DocumentStatusRail controller={controller} />
|
<DocumentStatusRail controller={controller} />
|
||||||
|
|||||||
@@ -13,7 +13,52 @@ type CatalogEntry = components["schemas"]["CatalogEntry"];
|
|||||||
type ResolvedAsset = components["schemas"]["ResolvedAsset"];
|
type ResolvedAsset = components["schemas"]["ResolvedAsset"];
|
||||||
type PublicRenderModel = components["schemas"]["PublicRenderModel"];
|
type PublicRenderModel = components["schemas"]["PublicRenderModel"];
|
||||||
|
|
||||||
function supportsPreviewEvidenceKey(key: string): boolean {
|
/**
|
||||||
|
* `projectWorkingCopy` applies two gates to every `EVIDENCE_FIGURE` block
|
||||||
|
* before any asset resolver runs: `supportsEvidenceKey`, and an `EVIDENCE`
|
||||||
|
* `CatalogEntry` lookup by `id`/`label`/`publicPath`. Both exist to stop a
|
||||||
|
* document referencing evidence that does not exist -- so opening them for
|
||||||
|
* the Asset Picker (Task 10) means teaching them about the *loaded Asset
|
||||||
|
* list*, not bypassing them.
|
||||||
|
*
|
||||||
|
* The document catalog (`getCatalog({ type: "EVIDENCE" })`) and the Asset
|
||||||
|
* list (`listAssets`) are different sources today: the catalog only knows
|
||||||
|
* about evidence a previous publish already registered, so a key the Picker
|
||||||
|
* just inserted -- backed by a real, freshly loaded `READY` asset -- has no
|
||||||
|
* catalog row yet. This synthesizes one `EVIDENCE` `CatalogEntry` per `READY`
|
||||||
|
* asset, `label`ed with its `assetKey` -- the same field the one
|
||||||
|
* pre-existing fixture entry already uses
|
||||||
|
* (`{ type: "EVIDENCE", label: "fetch-strategy-boundary" }`) -- and both
|
||||||
|
* gates are driven off the resulting merged catalog. A key backed by no
|
||||||
|
* loaded `READY` asset and no catalog row still fails both gates; nothing
|
||||||
|
* here can forge a pass for a dangling reference.
|
||||||
|
*/
|
||||||
|
function evidenceCatalogEntries(assets: readonly Asset[]): CatalogEntry[] {
|
||||||
|
return assets
|
||||||
|
.filter((asset) => asset.managementStatus === "READY")
|
||||||
|
.map((asset) => ({
|
||||||
|
id: asset.id,
|
||||||
|
type: "EVIDENCE",
|
||||||
|
label: asset.assetKey,
|
||||||
|
publicPath: asset.publicPath ?? undefined,
|
||||||
|
dependencyRevision: asset.updatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsEvidenceKeyIn(catalog: ReadonlyArray<CatalogEntry>) {
|
||||||
|
return (key: string): boolean =>
|
||||||
|
catalog.some((entry) => entry.type === "EVIDENCE" && entry.label === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one legacy key predates the Asset gateway entirely: the document
|
||||||
|
* catalog carries a fixture `EVIDENCE` row for it (so the gates above pass
|
||||||
|
* it), but no `Asset` record backs it, so `assets` never resolves it. Kept
|
||||||
|
* narrow and separate from `supportsEvidenceKeyIn` -- that function decides
|
||||||
|
* whether a document is *allowed* to reference a key; this one only fills in
|
||||||
|
* the one pre-existing key's pixels when nothing else can.
|
||||||
|
*/
|
||||||
|
function isLegacyStaticEvidenceKey(key: string): boolean {
|
||||||
return key === "fetch-strategy-boundary";
|
return key === "fetch-strategy-boundary";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +86,7 @@ function resolveAssetDescriptor(assets: readonly Asset[]) {
|
|||||||
decorative: asset.decorative,
|
decorative: asset.decorative,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (supportsPreviewEvidenceKey(key)) {
|
if (isLegacyStaticEvidenceKey(key)) {
|
||||||
// Fixed literal, not derived: this file only ever resolves this one legacy
|
// Fixed literal, not derived: this file only ever resolves this one legacy
|
||||||
// key today, so the id only needs to be stable, not computed.
|
// key today, so the id only needs to be stable, not computed.
|
||||||
return {
|
return {
|
||||||
@@ -76,15 +121,16 @@ export function InstantPreview({
|
|||||||
assets?: readonly Asset[];
|
assets?: readonly Asset[];
|
||||||
}) {
|
}) {
|
||||||
const studio = useStudio();
|
const studio = useStudio();
|
||||||
|
const effectiveCatalog = [...catalog, ...evidenceCatalogEntries(assets)];
|
||||||
let model: PublicRenderModel | null = null;
|
let model: PublicRenderModel | null = null;
|
||||||
let issues: string[] | null = null;
|
let issues: string[] | null = null;
|
||||||
try {
|
try {
|
||||||
model = resolveCaseEvidenceAssets(
|
model = resolveCaseEvidenceAssets(
|
||||||
projectWorkingCopy(
|
projectWorkingCopy(
|
||||||
draft,
|
draft,
|
||||||
catalog,
|
effectiveCatalog,
|
||||||
{ mode: "PREVIEW", publishedAt: null },
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
supportsPreviewEvidenceKey,
|
supportsEvidenceKeyIn(effectiveCatalog),
|
||||||
),
|
),
|
||||||
resolveAssetDescriptor(assets),
|
resolveAssetDescriptor(assets),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,6 +68,18 @@
|
|||||||
.studio-app .studio-document-status-rail button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
|
.studio-app .studio-document-status-rail button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
|
||||||
.studio-app .studio-document-status-rail > p:last-child { margin: 16px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
.studio-app .studio-document-status-rail > p:last-child { margin: 16px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
||||||
|
|
||||||
|
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
|
||||||
|
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
|
||||||
|
.studio-app .studio-asset-panel h3 { margin: 0 0 10px; font-size: 19px; letter-spacing: -0.02em; }
|
||||||
|
.studio-app .studio-asset-panel > p { max-width: 640px; margin: 0 0 18px; color: var(--muted); font-size: 13px; line-height: 1.6; }
|
||||||
|
.studio-app .studio-asset-panel-actions { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-bottom: 18px; }
|
||||||
|
.studio-app .studio-asset-kind-field { display: grid; min-width: 0; gap: 8px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||||
|
.studio-app .studio-asset-kind-field select { min-height: 44px; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font: inherit; font-size: 15px; }
|
||||||
|
.studio-app .studio-asset-panel-actions button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
||||||
|
.studio-app .asset-picker ul { display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 0; list-style: none; }
|
||||||
|
.studio-app .asset-picker button { min-height: 36px; padding-inline: 12px; border: 1px solid var(--line-strong); border-radius: 999px; background: var(--paper); color: var(--ink); font-size: 13px; }
|
||||||
|
.studio-app .studio-error,
|
||||||
|
.studio-app .studio-asset-picker-empty { margin: 0; color: var(--muted); font-size: 13px; }
|
||||||
.studio-app .studio-instant-preview { min-width: 0; overflow: clip; border: 1px solid var(--line); }
|
.studio-app .studio-instant-preview { min-width: 0; overflow: clip; border: 1px solid var(--line); }
|
||||||
.studio-app .studio-instant-preview .public-record-embedded { width: 100%; max-width: none; margin: 0; padding: clamp(24px, 5vw, 56px); }
|
.studio-app .studio-instant-preview .public-record-embedded { width: 100%; max-width: none; margin: 0; padding: clamp(24px, 5vw, 56px); }
|
||||||
.studio-app .studio-preview-error { padding: 48px 0; border-bottom: 1px solid var(--line); }
|
.studio-app .studio-preview-error { padding: 48px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
.studio-app .studio-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 28px; }
|
.studio-app .studio-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 28px; }
|
||||||
.studio-app .studio-dialog-actions button { min-height: 44px; padding-inline: 14px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); }
|
.studio-app .studio-dialog-actions button { min-height: 44px; padding-inline: 14px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); }
|
||||||
.studio-app .studio-dialog-actions button:last-child { border-color: var(--signal); background: var(--signal); color: #fff; }
|
.studio-app .studio-dialog-actions button:last-child { border-color: var(--signal); background: var(--signal); color: #fff; }
|
||||||
|
.studio-app .studio-asset-upload-dialog { width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); }
|
||||||
|
.studio-app .studio-asset-upload-dialog::backdrop { background: rgba(23, 24, 27, 0.48); }
|
||||||
|
.studio-app .studio-asset-upload-dialog .studio-dialog-status { min-height: 20px; margin: 16px 0 0; color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
.studio-app .studio-page-top { display: flex; align-items: flex-end; justify-content: space-between; gap: 36px; border-bottom: 1px solid var(--line-strong); }
|
.studio-app .studio-page-top { display: flex; align-items: flex-end; justify-content: space-between; gap: 36px; border-bottom: 1px solid var(--line-strong); }
|
||||||
.studio-app .studio-page-top .studio-page-heading { padding-bottom: 42px; }
|
.studio-app .studio-page-top .studio-page-heading { padding-bottom: 42px; }
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterAll, beforeAll, test } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AssetPicker,
|
||||||
|
buildEvidenceDirective,
|
||||||
|
} from "../../../src/features/tech-log/presentation/studio/components/asset-picker.tsx";
|
||||||
|
import {
|
||||||
|
AssetUploadDialog,
|
||||||
|
stateForError,
|
||||||
|
stateForUploaded,
|
||||||
|
} from "../../../src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx";
|
||||||
|
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
|
||||||
|
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||||
|
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
|
||||||
|
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||||
|
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
|
||||||
|
import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||||
|
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||||
|
|
||||||
|
// jsdom does not implement `<dialog>` -- same polyfill `studio-save-navigation.test.tsx`
|
||||||
|
// and `studio-decision-authoring.test.tsx` already use for the other Studio dialogs.
|
||||||
|
// `beforeAll`/`afterAll` (not `beforeEach`/`afterEach`) so the polyfill stays in place
|
||||||
|
// for the whole file's run, including the global `cleanup()` from `tests/setup.ts`
|
||||||
|
// that unmounts components (and so runs dialog close-on-unmount effects) between tests.
|
||||||
|
const originalShowModal = HTMLDialogElement.prototype.showModal;
|
||||||
|
const originalClose = HTMLDialogElement.prototype.close;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLDialogElement) {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLDialogElement) {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalShowModal,
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalClose,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const READY = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
assetKey: "fetch-strategy-boundary",
|
||||||
|
kind: "DIAGRAM",
|
||||||
|
mediaType: "image/svg+xml",
|
||||||
|
originalFilename: "boundary.svg",
|
||||||
|
byteSize: 4096,
|
||||||
|
width: 1080,
|
||||||
|
height: 420,
|
||||||
|
altText: "Fetch Join 경계",
|
||||||
|
decorative: false,
|
||||||
|
managementStatus: "READY",
|
||||||
|
publicPath: "/media/fetch-strategy-boundary.svg",
|
||||||
|
usageCount: 1,
|
||||||
|
version: 1,
|
||||||
|
createdAt: "2026-08-14T01:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-14T01:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUARANTINED = { ...READY, id: "22222222-2222-4222-8222-222222222222", assetKey: "unsafe", managementStatus: "QUARANTINED" };
|
||||||
|
|
||||||
|
function gatewayOf(items: unknown[]): StudioAssetGateway {
|
||||||
|
return {
|
||||||
|
async listAssets() {
|
||||||
|
return { items, nextCursor: null } as never;
|
||||||
|
},
|
||||||
|
async uploadAsset() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
async getAsset() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
async updateAssetMetadata() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
async deleteAsset() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("builds the evidence directive with escaped attribute values", () => {
|
||||||
|
assert.equal(
|
||||||
|
buildEvidenceDirective({
|
||||||
|
assetKey: "fetch-strategy-boundary",
|
||||||
|
alt: "Fetch Join 경계",
|
||||||
|
caption: "그림 1",
|
||||||
|
zoom: true,
|
||||||
|
}),
|
||||||
|
':::evidence key="fetch-strategy-boundary" alt="Fetch Join 경계" caption="그림 1" zoom="true"\n:::',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strips a double quote and folds a newline in an attribute value", () => {
|
||||||
|
assert.equal(
|
||||||
|
buildEvidenceDirective({
|
||||||
|
assetKey: "fetch-strategy-boundary",
|
||||||
|
alt: 'he said "hi"\nsecond line',
|
||||||
|
caption: "c",
|
||||||
|
zoom: false,
|
||||||
|
}),
|
||||||
|
':::evidence key="fetch-strategy-boundary" alt="he said hi second line" caption="c" zoom="false"\n:::',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inserts the directive for the chosen asset", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const inserted: string[] = [];
|
||||||
|
render(<AssetPicker gateway={gatewayOf([READY])} onInsert={(value) => inserted.push(value)} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: /fetch-strategy-boundary/ }));
|
||||||
|
|
||||||
|
assert.equal(inserted.length, 1);
|
||||||
|
assert.ok(inserted[0]!.includes('key="fetch-strategy-boundary"'));
|
||||||
|
assert.ok(inserted[0]!.startsWith(":::evidence "));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not offer a QUARANTINED asset for insertion", async () => {
|
||||||
|
render(<AssetPicker gateway={gatewayOf([READY, QUARANTINED])} onInsert={() => {}} />);
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: /fetch-strategy-boundary/ });
|
||||||
|
assert.equal(screen.queryByRole("button", { name: /unsafe/ }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports the loaded Asset list once listAssets resolves", async () => {
|
||||||
|
const loaded: readonly Asset[][] = [];
|
||||||
|
render(
|
||||||
|
<AssetPicker
|
||||||
|
gateway={gatewayOf([READY, QUARANTINED])}
|
||||||
|
onInsert={() => {}}
|
||||||
|
onLoaded={(assets) => (loaded as Asset[][]).push([...assets])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: /fetch-strategy-boundary/ });
|
||||||
|
assert.equal(loaded.length, 1);
|
||||||
|
assert.equal(loaded[0]!.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- upload outcome/error classification: pure functions, tested apart from the dialog ---
|
||||||
|
|
||||||
|
test("stateForUploaded maps a READY asset to the READY outcome", () => {
|
||||||
|
const asset = { ...READY } as unknown as Asset;
|
||||||
|
assert.deepEqual(stateForUploaded(asset), { kind: "READY", asset });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stateForUploaded maps a QUARANTINED asset to the QUARANTINED outcome", () => {
|
||||||
|
const asset = { ...READY, managementStatus: "QUARANTINED" } as unknown as Asset;
|
||||||
|
assert.deepEqual(stateForUploaded(asset), { kind: "QUARANTINED", asset });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stateForUploaded maps a REJECTED asset to the REJECTED outcome", () => {
|
||||||
|
const asset = { ...READY, managementStatus: "REJECTED" } as unknown as Asset;
|
||||||
|
assert.deepEqual(stateForUploaded(asset), { kind: "REJECTED", asset });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stateForUploaded maps an ARCHIVED asset to the REJECTED outcome", () => {
|
||||||
|
const asset = { ...READY, managementStatus: "ARCHIVED" } as unknown as Asset;
|
||||||
|
assert.deepEqual(stateForUploaded(asset), { kind: "REJECTED", asset });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stateForError maps PAYLOAD_TOO_LARGE to TOO_LARGE", () => {
|
||||||
|
const error = new StudioGatewayError({
|
||||||
|
type: "https://techlog.local/problems/payload-too-large",
|
||||||
|
title: "PAYLOAD_TOO_LARGE",
|
||||||
|
status: 413,
|
||||||
|
detail: "파일이 너무 큽니다.",
|
||||||
|
code: "PAYLOAD_TOO_LARGE",
|
||||||
|
});
|
||||||
|
assert.deepEqual(stateForError(error), { kind: "TOO_LARGE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stateForError maps UNSUPPORTED_MEDIA_TYPE to UNSUPPORTED_TYPE", () => {
|
||||||
|
const error = new StudioGatewayError({
|
||||||
|
type: "https://techlog.local/problems/unsupported-media-type",
|
||||||
|
title: "UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
status: 415,
|
||||||
|
detail: "지원하지 않는 형식입니다.",
|
||||||
|
code: "UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
});
|
||||||
|
assert.deepEqual(stateForError(error), { kind: "UNSUPPORTED_TYPE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stateForError maps any other gateway error to TRANSPORT_FAILED with the server detail", () => {
|
||||||
|
const error = new StudioGatewayError({
|
||||||
|
type: "https://techlog.local/problems/studio-unavailable",
|
||||||
|
title: "STUDIO_UNAVAILABLE",
|
||||||
|
status: 503,
|
||||||
|
detail: "Studio가 잠시 응답하지 않습니다.",
|
||||||
|
code: "STUDIO_UNAVAILABLE",
|
||||||
|
});
|
||||||
|
assert.deepEqual(stateForError(error), {
|
||||||
|
kind: "TRANSPORT_FAILED",
|
||||||
|
message: "Studio가 잠시 응답하지 않습니다.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stateForError maps a non-gateway error to a generic TRANSPORT_FAILED", () => {
|
||||||
|
assert.deepEqual(stateForError(new Error("network down")), {
|
||||||
|
kind: "TRANSPORT_FAILED",
|
||||||
|
message: "업로드를 전송하지 못했습니다.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- AssetUploadDialog: the 8 upload/outcome states the design calls for ---
|
||||||
|
|
||||||
|
function uploadOnlyGateway(
|
||||||
|
uploadAsset: StudioAssetGateway["uploadAsset"],
|
||||||
|
): StudioAssetGateway {
|
||||||
|
return {
|
||||||
|
async listAssets() {
|
||||||
|
return { items: [], nextCursor: null };
|
||||||
|
},
|
||||||
|
uploadAsset,
|
||||||
|
async getAsset() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
async updateAssetMetadata() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
async deleteAsset() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseFile(file: File) {
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_FILE = new File(["<svg/>"], "boundary.svg", { type: "image/svg+xml" });
|
||||||
|
|
||||||
|
test("reports a selection failure and never calls uploadAsset when no file is chosen", () => {
|
||||||
|
let calls = 0;
|
||||||
|
const gateway = uploadOnlyGateway(async () => {
|
||||||
|
calls += 1;
|
||||||
|
return READY as never;
|
||||||
|
});
|
||||||
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { files: [] } });
|
||||||
|
|
||||||
|
assert.equal(screen.getByRole("status").textContent, "파일을 선택하지 못했습니다.");
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows an uploading status and disables the file input while the transport is pending", async () => {
|
||||||
|
let resolveUpload!: (asset: Asset) => void;
|
||||||
|
const gateway = uploadOnlyGateway(
|
||||||
|
() => new Promise<Asset>((resolve) => { resolveUpload = resolve; }),
|
||||||
|
);
|
||||||
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
|
||||||
|
|
||||||
|
chooseFile(SAMPLE_FILE);
|
||||||
|
|
||||||
|
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드 중입니다."));
|
||||||
|
assert.equal((document.querySelector('input[type="file"]') as HTMLInputElement).disabled, true);
|
||||||
|
|
||||||
|
resolveUpload(READY as unknown as Asset);
|
||||||
|
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드했습니다."));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only calls onUploaded and shows success once the server returns READY", async () => {
|
||||||
|
const uploaded: Asset[] = [];
|
||||||
|
const gateway = uploadOnlyGateway(async () => READY as never);
|
||||||
|
render(
|
||||||
|
<AssetUploadDialog
|
||||||
|
gateway={gateway}
|
||||||
|
kind="DIAGRAM"
|
||||||
|
idempotencyKey="k1"
|
||||||
|
onUploaded={(asset) => uploaded.push(asset)}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
chooseFile(SAMPLE_FILE);
|
||||||
|
|
||||||
|
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드했습니다."));
|
||||||
|
assert.equal(uploaded.length, 1);
|
||||||
|
assert.equal(uploaded[0]!.assetKey, "fetch-strategy-boundary");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a QUARANTINED server outcome never calls onUploaded, even though the transport succeeded", async () => {
|
||||||
|
const uploaded: Asset[] = [];
|
||||||
|
const gateway = uploadOnlyGateway(async () => ({ ...READY, managementStatus: "QUARANTINED" }) as never);
|
||||||
|
render(
|
||||||
|
<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
chooseFile(SAMPLE_FILE);
|
||||||
|
|
||||||
|
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "보안 검사에서 격리되어 사용할 수 없습니다."));
|
||||||
|
assert.equal(uploaded.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a REJECTED server outcome never calls onUploaded", async () => {
|
||||||
|
const uploaded: Asset[] = [];
|
||||||
|
const gateway = uploadOnlyGateway(async () => ({ ...READY, managementStatus: "REJECTED" }) as never);
|
||||||
|
render(
|
||||||
|
<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
chooseFile(SAMPLE_FILE);
|
||||||
|
|
||||||
|
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "서버 검증에서 거절되어 사용할 수 없습니다."));
|
||||||
|
assert.equal(uploaded.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a PAYLOAD_TOO_LARGE transport rejection shows the size-exceeded message", async () => {
|
||||||
|
const gateway = uploadOnlyGateway(async () => {
|
||||||
|
throw new StudioGatewayError({
|
||||||
|
type: "https://techlog.local/problems/payload-too-large",
|
||||||
|
title: "PAYLOAD_TOO_LARGE",
|
||||||
|
status: 413,
|
||||||
|
detail: "파일이 너무 큽니다.",
|
||||||
|
code: "PAYLOAD_TOO_LARGE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
|
||||||
|
|
||||||
|
chooseFile(SAMPLE_FILE);
|
||||||
|
|
||||||
|
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "파일 크기가 허용 범위를 넘었습니다."));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an UNSUPPORTED_MEDIA_TYPE transport rejection shows the unsupported-type message", async () => {
|
||||||
|
const gateway = uploadOnlyGateway(async () => {
|
||||||
|
throw new StudioGatewayError({
|
||||||
|
type: "https://techlog.local/problems/unsupported-media-type",
|
||||||
|
title: "UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
status: 415,
|
||||||
|
detail: "지원하지 않는 형식입니다.",
|
||||||
|
code: "UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
|
||||||
|
|
||||||
|
chooseFile(SAMPLE_FILE);
|
||||||
|
|
||||||
|
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "지원하지 않는 파일 형식입니다."));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a plain network failure shows the generic transport-failed message", async () => {
|
||||||
|
const gateway = uploadOnlyGateway(async () => {
|
||||||
|
throw new Error("offline");
|
||||||
|
});
|
||||||
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
|
||||||
|
|
||||||
|
chooseFile(SAMPLE_FILE);
|
||||||
|
|
||||||
|
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드를 전송하지 못했습니다."));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("focuses the file input on open, traps Tab inside the dialog, and calls onClose from the close button", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let closed = 0;
|
||||||
|
const gateway = uploadOnlyGateway(async () => READY as never);
|
||||||
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => (closed += 1)} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const closeButton = screen.getByRole("button", { name: "닫기" });
|
||||||
|
assert.equal(document.activeElement, input);
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
await user.tab();
|
||||||
|
assert.equal(document.activeElement, closeButton);
|
||||||
|
await user.tab();
|
||||||
|
assert.equal(document.activeElement, input);
|
||||||
|
|
||||||
|
await user.click(closeButton);
|
||||||
|
assert.equal(closed, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- CaseFields end-to-end: cursor-preserving insertion + Instant Preview resolves it live ---
|
||||||
|
|
||||||
|
test("inserts the directive at the saved cursor position and the live preview renders it without an error panel", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const gateway = createMockStudioGateway();
|
||||||
|
const DIAGRAM_ASSET = {
|
||||||
|
...READY,
|
||||||
|
id: "33333333-3333-4333-8333-333333333331",
|
||||||
|
assetKey: "cursor-test-diagram",
|
||||||
|
altText: "커서 삽입 테스트 다이어그램",
|
||||||
|
publicPath: "/media/cursor-test-diagram.svg",
|
||||||
|
};
|
||||||
|
const assetGateway = gatewayOf([DIAGRAM_ASSET]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/edit`]}>
|
||||||
|
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
|
||||||
|
<DocumentEditorScreen documentId={FIXTURE_IDS.redisAdapterCase} />
|
||||||
|
</StudioProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = (await screen.findByLabelText("본문 Markdown")) as HTMLTextAreaElement;
|
||||||
|
const body = textarea.value;
|
||||||
|
const cursor = body.indexOf("\n\n");
|
||||||
|
assert.ok(cursor > 0);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(cursor, cursor);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: /cursor-test-diagram/ }));
|
||||||
|
|
||||||
|
const expectedDirective = buildEvidenceDirective({
|
||||||
|
assetKey: "cursor-test-diagram",
|
||||||
|
alt: "커서 삽입 테스트 다이어그램",
|
||||||
|
caption: "",
|
||||||
|
zoom: true,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
textarea.value,
|
||||||
|
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
||||||
|
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
||||||
|
|
||||||
|
assert.equal(within(panel).queryByRole("alert"), null);
|
||||||
|
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
|
||||||
|
// the zoom trigger, once inside the (closed) zoom dialog -- both share the
|
||||||
|
// same alt text, so assert on the first (the visible trigger figure).
|
||||||
|
const [image] = within(panel).getAllByAltText("커서 삽입 테스트 다이어그램");
|
||||||
|
assert.equal(image!.getAttribute("src"), "/media/cursor-test-diagram.svg");
|
||||||
|
});
|
||||||
@@ -19,9 +19,14 @@ function renderEditor(
|
|||||||
documentId: string,
|
documentId: string,
|
||||||
gateway: StudioGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(),
|
gateway: StudioGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(),
|
||||||
) {
|
) {
|
||||||
|
// CASE editors mount the Asset Picker (Task 10), which uses the throwing
|
||||||
|
// `useStudioAssetGateway()` accessor -- a test harness that renders it must
|
||||||
|
// supply `createAssetGateway`, the same as `StudioShell` always does.
|
||||||
|
const assetGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT)
|
||||||
|
.input.createStudioAssetGateway();
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
|
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
|
||||||
<StudioProvider createGateway={() => gateway}>
|
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
|
||||||
<DocumentEditorScreen documentId={documentId} />
|
<DocumentEditorScreen documentId={documentId} />
|
||||||
</StudioProvider>
|
</StudioProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
|
|||||||
@@ -79,9 +79,14 @@ function Announcement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEditor(documentId: string, gateway: StudioGateway) {
|
function renderEditor(documentId: string, gateway: StudioGateway) {
|
||||||
|
// CASE editors mount the Asset Picker (Task 10), which uses the throwing
|
||||||
|
// `useStudioAssetGateway()` accessor -- a test harness that renders it must
|
||||||
|
// supply `createAssetGateway`, the same as `StudioShell` always does.
|
||||||
|
const assetGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT)
|
||||||
|
.input.createStudioAssetGateway();
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
|
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
|
||||||
<StudioProvider createGateway={() => gateway}>
|
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
|
||||||
<DocumentEditorScreen documentId={documentId} />
|
<DocumentEditorScreen documentId={documentId} />
|
||||||
<Announcement />
|
<Announcement />
|
||||||
</StudioProvider>
|
</StudioProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user