feat(M02): 좌측 새로 · 본떠 만들기 · 지우기를 표 양식 · 도면 양식 컨테이너 안으로 · 본떠 만들기 흠 고침

- 맨 아래 단추 줄 없앰 · 표 양식 · 도면 양식 컨테이너마다 [새로] [본떠 만들기] [지우기] · 새로 = 그 종류로 이름만 · 본떠 · 지우기 = 그 컨테이너에서 고른 양식이 있을 때만 켜짐 · 구조물 도면 · 면적산출 · 재료산출 컨테이너는 단추 없음 · 시스템 관리자만
- 본떠 만들기 흠: 표 복사본의 머리 이름(양식)이 원본 이름으로 남던 것을 새 이름으로
- 이름 규칙: 80자 넘는 이름이 서버 500 이던 것 · 앞 밑줄 이름이 새 프로젝트 복사를 깨던 것을 서버 · 화면 둘 다 막음
- 시험: 복사 독립 · 새 프로젝트 복사 · 이름 규칙 · 컨테이너 안 단추

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
This commit is contained in:
2026-09-28 17:08:53 +09:00
co-authored by Claude Sonnet 5
parent c4735e4669
commit 6198fad176
5 changed files with 90 additions and 42 deletions
@@ -60,7 +60,13 @@ def version_of(raw: bytes) -> str:
def _path(kind: str, name: str) -> Path:
if kind not in KINDS:
raise StoreError(404, f"없는 종류 「{kind}」")
if not name or name != name.strip() or name.startswith(".") or _BAD_NAME.search(name):
if (
not name
or name != name.strip()
or name.startswith((".", "_")) # `_` 도 막음 — 새 프로젝트 복사(layers.check_name)가 거절함
or len(name) > 80 # 파일 이름이 길면 OS 가 죽음(500) — 화면 규칙과 같은 80자
or _BAD_NAME.search(name)
):
raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」")
return FOLDER / kind / f"{name}.json"
@@ -9,7 +9,6 @@
import {
createButton,
createInputField,
createSelectField,
el,
showConfirmDialog,
showToast,
@@ -87,7 +86,10 @@ export interface SideHandle {
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
const badName = (name: string): boolean =>
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith(".");
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) ||
name.startsWith(".") ||
name.startsWith("_") ||
name.length > 80;
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const failed = (error: unknown): void =>
@@ -113,25 +115,30 @@ export function buildSide(opt: SideOptions): SideHandle {
const columnBodies = COLUMN_KINDS.map(() =>
el("div", { className: "m02-side__items ui-sidebar-list" }),
);
/** 새로 · 본떠 만들기 · 지우기 — 표 · 도면 컨테이너 안에 하나씩(그 종류로 동작) · 관리자만 */
const editButtons = new Map<Kind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
const actionRow = (kind: Kind): HTMLElement[] => {
if (!canEdit(layer, opt.isAdmin)) return [];
const btnNew = createButton({ label: L("M02_New"), variant: "filled" });
const copy = createButton({ label: L("M02_Copy"), variant: "ghost" });
const del = createButton({ label: L("M02_Delete"), variant: "danger" });
editButtons.set(kind, { copy, del });
btnNew.addEventListener("click", () => newTemplate(kind));
copy.addEventListener("click", copyTemplate);
del.addEventListener("click", () => void deleteCurrent());
return [el("div", { className: "m02-side__row", children: [btnNew, copy, del] })];
};
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
section(L("M02_KindTable"), bodies.get("table")!),
section(L("M02_KindDrawing"), bodies.get("drawing")!),
section(L("M02_KindTable"), bodies.get("table")!, ...actionRow("table")),
section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])),
],
});
attachCollapsible(listHost);
const btnNew = createButton({ label: L("M02_New"), variant: "filled" });
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost" });
const btnDel = createButton({ label: L("M02_Delete"), variant: "danger" });
const editRow = el("div", {
className: "m02-side__row ui-sidebar-card",
children: [btnNew, btnCopy, btnDel],
});
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost, editRow] });
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] });
/** 고름 표시 — `picked`(방금 누름) 이 있으면 그것 · 없으면 지금 열린 것과 비교 */
const isPicked = (kind: Kind, name: string): boolean => {
@@ -186,11 +193,10 @@ export function buildSide(opt: SideOptions): SideHandle {
const paintColumns = (): void =>
COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key));
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
function paintButtons(): void {
const editable = canEdit(layer, opt.isAdmin);
btnNew.disabled = !editable;
btnCopy.disabled = !editable || !currentInfo();
btnDel.disabled = !editable || !currentInfo();
const cur = currentInfo();
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
}
async function select(info: TemplateInfo): Promise<void> {
@@ -243,16 +249,8 @@ export function buildSide(opt: SideOptions): SideHandle {
}
/* --- 시스템 층 새로 · 본떠 · 지우기(표 · 도면만 — 구조물 도면은 집계표 열이 정함) --- */
const nameDialog = (
title: string,
withKind: boolean,
onOk: (kind: Kind, name: string) => Promise<void>,
): void => {
const nameDialog = (title: string, onOk: (name: string) => Promise<void>): void => {
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
const kind = createSelectField({
label: L("M02_Kind"),
options: KINDS.map((k) => ({ value: k.kind, text: k.label() })),
});
openModal({
title,
closeLabel: L("M02_Close"),
@@ -265,7 +263,7 @@ export function buildSide(opt: SideOptions): SideHandle {
if (badName(text)) return showToast(L("M02_BadName"), "error");
ok.disabled = true; // 연달아 눌러도 한 번만
try {
await onOk(kind.select.value as Kind, text);
await onOk(text);
close();
} catch (error) {
if (error instanceof StaleError) showToast(L("M02_NameExists"), "error");
@@ -274,7 +272,7 @@ export function buildSide(opt: SideOptions): SideHandle {
ok.disabled = false;
}
});
body.append(...(withKind ? [kind.root] : []), name.root, ok);
body.append(name.root, ok);
name.input.focus();
},
});
@@ -287,22 +285,25 @@ export function buildSide(opt: SideOptions): SideHandle {
if (info) await select(info);
};
btnNew.addEventListener("click", () =>
nameDialog(L("M02_NewTitle"), true, async (kind, name) => {
/** 이 컨테이너 종류의 새 양식 — 종류 고르기 없이 이름만 */
function newTemplate(kind: Kind): void {
nameDialog(L("M02_NewTitle"), async (name) => {
await saveTemplate("system", kind, name, null, "", stubDoc(kind, name));
await openCreated(kind, name);
}),
);
btnCopy.addEventListener("click", () => {
});
}
function copyTemplate(): void {
const from = currentInfo();
if (!from) return;
nameDialog(L("M02_CopyTitle"), false, async (_kind, name) => {
nameDialog(L("M02_CopyTitle"), async (name) => {
const got = await readTemplate("system", from.종류, from.이름, null);
await saveTemplate("system", from.종류, name, null, "", got.문서);
// 표 문서 머리 이름(`양식`)도 새 이름으로 — 안 바꾸면 복사본이 원본 이름을 달고 있음
const doc = from.종류 === "table" ? { ...(got.문서 as object), 양식: name } : got.문서;
await saveTemplate("system", from.종류, name, null, "", doc);
await openCreated(from.종류, name);
});
});
btnDel.addEventListener("click", async () => {
}
async function deleteCurrent(): Promise<void> {
await refresh(); // 저장 직후여도 최신 판으로
const info = currentInfo();
if (!info) return;
@@ -315,7 +316,7 @@ export function buildSide(opt: SideOptions): SideHandle {
} catch (error) {
failed(error);
}
});
}
void refresh();
return {
+34 -1
View File
@@ -5,6 +5,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_MasterTemplete_Router import router
@@ -47,10 +48,42 @@ def test_new_save_reopen_delete_and_stale(client):
def test_bad_names_and_kinds(client):
for bad in ("a..b", ".숨김", "a%5Cb", "a%3Ab"):
for bad in ("a..b", ".숨김", "a%5Cb", "a%3Ab", "_밑줄", "가" * 81):
got = client.put(f"/api/m02/templates/table/{bad}", json={"판": "", "문서": {"열": []}})
assert got.status_code == 400 and "쓸 수 없는" in got.json()["detail"]
assert client.put("/api/m02/templates/other/x", json={"판": "", "문서": {}}).status_code == 404
edge = "가" * 80 # 80자는 됨
assert (
client.put(
f"/api/m02/templates/table/{edge}", json={"판": "", "문서": {"열": []}}
).status_code
== 200
)
def test_copy_is_independent_and_seeds_new_project(client, tmp_path, monkeypatch):
"""본떠 만들기 = 읽어 새 이름으로 새로 저장 — 원본은 안 바뀜 · 새 프로젝트 복사에 실림."""
doc = {"format": 6, "entities": [{"type": "line"}], "drawing_area": [1, 2, 3, 4]}
client.put("/api/m02/templates/drawing/원본", json={"판": "", "문서": doc})
got = client.get("/api/m02/templates/drawing/원본").json()
made = client.put("/api/m02/templates/drawing/복사", json={"판": "", "문서": got["문서"]})
assert made.status_code == 200
assert (
client.put("/api/m02/templates/drawing/복사", json={"판": "", "문서": doc}).status_code
== 409
)
copy = client.get("/api/m02/templates/drawing/복사").json()
assert copy["문서"] == doc and copy["판"] == got["판"] # 내용 같음
client.put(
"/api/m02/templates/drawing/복사",
json={"판": copy["판"], "문서": {**doc, "entities": [{"type": "line"}, {"type": "arc"}]}},
)
assert client.get("/api/m02/templates/drawing/원본").json()["문서"] == doc # 원본 그대로
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path)
copied = layers.seed_project(tmp_path / "seed")
assert {"drawing/원본", "drawing/복사"} <= set(copied["작업본"])
client.delete("/api/m02/templates/drawing/복사")
assert [r["이름"] for r in client.get("/api/m02/templates").json()] == ["원본"]
def test_empty_doc_rejected_and_file_untouched(client):
@@ -140,6 +140,14 @@ def test_미리보기_CAD_패널은_숨기지_않고_접힌_채로_엶():
assert "isInspectorCollapsed()" in _text(cad / "components/Toolbar.tsx")
def test_좌측_새로_본떠_지우기는_표_도면_컨테이너_안에_하나씩():
side = _text(SIDE)
assert 'actionRow("table")' in side and 'actionRow("drawing")' in side
assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만
assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만
assert "양식: name" in side.split("function copyTemplate")[1] # 복사본 머리 이름 = 새 이름
def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘():
ribbon = _text(ROOT / "B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx")
assert "if (collapsed) setPeek(true);" in ribbon # 탭 = 잠깐 펼침(접힘 모드 유지)
+2 -2
View File
@@ -42,8 +42,8 @@ export const ui_locales_m2 = {
M02_NoSpreadsheet: ["산출근거 편집 부품이 아직 없음", "Cost basis editor not available yet"],
M02_NameExists: ["이미 있는 이름", "That name already exists"],
M02_BadName: [
'쓸 수 없는 글자 — / \\ : * ? " < > | 와 .. · 앞의 점은 안 됨',
'Invalid name — no / \\ : * ? " < > | or .. or leading dot',
'쓸 수 없는 이름 — / \\ : * ? " < > | 와 .. · 앞의 점 · 밑줄은 안 됨 · 80자까지',
'Invalid name — no / \\ : * ? " < > | or .. or leading dot/underscore · up to 80 characters',
],
M02_DiscardConfirm: [
"저장 안 한 고침이 있음 — 버리고 넘어갈까?",