diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index a0e9eb039..ea605c524 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -12,6 +12,7 @@ * ========================================================================== */ import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import type { @@ -68,7 +69,7 @@ const TEXT = { noFieldNumber: "{value} 없음", noSheet: "스프레드시트를 못 띄움 — {value}", readFailed: "{value} 파일을 못 읽음 — {why}", - discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?", + discard: "카드에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", pills: "알약", pillsTip: "아래 알약 줄 보임 · 숨김", take: "가져오기", @@ -179,6 +180,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const numberOf = (kind: CardKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; const anyDirty = (): boolean => cards.some((c) => c.saver.dirty()); + const saveAll = async (): Promise => { + for (const c of cards) if (c.saver.dirty() && !(await c.saver.run())) throw new Error(); + }; const reload = (): void => void load(column ? { id: column, label } : null, true); /** 카드 틀 하나 — 제목 옆 = ? · 제목 줄 오른쪽 = [저장] · [〇〇 수정](빈 단추 한 모양) · 부제 = 번호 · 이름 */ @@ -461,7 +465,12 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { async function load(col: { id: string; label: string } | null, again = false): Promise { // 카드 고침을 저장 안 했으면 버릴지 물음(한 번) — 취소면 보던 열 그대로 - if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(TEXT.discard))) + if ( + !again && + anyDirty() && + col?.id !== column && + (await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === "stay" + ) return; const mine = ++seq; drop(); @@ -530,9 +539,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { }, column: () => column, dirty: anyDirty, - saveAll: async () => { - for (const c of cards) if (c.saver.dirty() && !(await c.saver.run())) throw new Error(); - }, + saveAll, discard: () => { drop(); opts.onDirty?.(false); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 8bf976944..d11baa92a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -14,7 +14,8 @@ * 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식). * ========================================================================== */ -import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { showToast } from "@ui/ui_template_elements"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { clearPart, @@ -252,9 +253,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane if ((col?.id ?? null) !== column && cards.dirty()) { // 카드에 저장 안 한 고침 — 버릴지 물음 · 머물면 표의 고른 열을 되돌림 const from = column; - void showConfirmDialog(TEXT.discardCards).then((ok) => { - if (!ok) return void (from && opts.keepColumn?.(from)); - cards.discard(); // 한 번 물었음 — 카드가 다시 묻지 않게 + void leaveOrStay(TEXT.discardCards, { + save: cards.saveAll, + stillDirty: cards.dirty, + discard: cards.discard, // 한 번 물었음 — 카드가 다시 묻지 않게 + }).then((got) => { + if (got === "stay") return void (from && opts.keepColumn?.(from)); void load(col); }); return; @@ -272,7 +276,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane const toNo = to && numbers[to.id]?.[PART_FIELD[kind]]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); // 카드에 저장 안 한 고침 — 가져온 뒤 카드를 다시 읽어 버려지니 먼저 물음 - if (cards.dirty() && !(await showConfirmDialog(TEXT.discardCards))) return; + if ( + cards.dirty() && + (await leaveOrStay(TEXT.discardCards, { save: cards.saveAll, stillDirty: cards.dirty })) === + "stay" + ) + return; const target = `${toNo} ${columnHead(to)}`; const here = { layer: getActiveLayer(), owner: getActiveOwner() }; const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner; diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts index 73c701f68..37442f0e7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M02_MasterTemplete_TreePanel_Dialogs.ts - * 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음. + * 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기. * 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel"). * ========================================================================== */ @@ -183,36 +183,3 @@ export function askFolder( }); }); } - -export type Settle = "save" | "discard" | "cancel"; - -/** 저장 안 한 표 고침 — 저장하고 계속 · 버리고 계속 · 취소 */ -export function askSettle(): Promise { - return new Promise((resolve) => { - let done = false; - const finish = (value: Settle): void => { - if (done) return; - done = true; - resolve(value); - }; - openModal({ - title: L("M02_TreeUnsaved"), - closeLabel: L("M02_TreeCancel"), - dialogClass: "m02-modal m02-treedlg", - beforeClose: () => (finish("cancel"), true), - mount: (body, close) => { - const choose = (value: Settle) => () => { - finish(value); - close(); - }; - const save = createButton({ label: L("M02_TreeSaveGo"), variant: "filled" }); - const discard = createButton({ label: L("M02_TreeDiscardGo"), variant: "danger" }); - const cancel = createButton({ label: L("M02_TreeCancel"), variant: "ghost" }); - save.addEventListener("click", choose("save")); - discard.addEventListener("click", choose("discard")); - cancel.addEventListener("click", choose("cancel")); - body.append(el("div", { className: "m02-side__row", children: [save, discard, cancel] })); - }, - }); - }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8bdf22c42..a319ab1d8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -6,6 +6,7 @@ import { setLeaveGuard } from "../A00_Common/router"; import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { t as L } from "@ui/ui_template_locale"; import { @@ -26,7 +27,6 @@ import { type StructureColumn, type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; -import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel"; import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing"; import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp"; @@ -202,19 +202,26 @@ export function createMain( event.returnValue = ""; }; window.addEventListener("beforeunload", guard); - const confirmLeave = async (): Promise => { - if (!busy()) return true; // 카드 고침(도면 · 엑셀)도 같은 물음 - if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) { - // 취소 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로 - host - .querySelector(".ss-input, .ui-sheet, iframe") - ?.focus({ preventScroll: true }); - return false; - } - dirty = false; - toDrawing.drop(); - return true; + /** + * 저장 안 한 고침 물음(떠나기 · 나무 고치기 한 몸) — 단추 셋 [취소] [버리고 넘어감] [저장 후 넘어감]. + * "clean" = 고침 없음 · "saved" · "discarded" · false = 머묾(취소 · 저장 실패 · 판 다름 · 카드 저장 실패). + * 열린 양식이 없는 화면(옵션 정의 등)은 저장할 것이 없어 2단추 그대로. + */ + const askUnsaved = async (message: string): Promise => { + if (!busy()) return "clean"; // 카드 고침(도면 · 엑셀)도 같은 물음 + const drop_ = (): void => { + dirty = false; + toDrawing.drop(); + }; + let got: "saved" | "discarded" | "stay"; + if (sel) got = await leaveOrStay(message, { save: doSave, stillDirty: busy, discard: drop_ }); + else got = (await showConfirmDialog(message)) ? (drop_(), "discarded") : "stay"; + if (got !== "stay") return got; + // 머묾 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로 + host.querySelector(".ss-input, .ui-sheet, iframe")?.focus({ preventScroll: true }); + return false; }; + const confirmLeave = async (): Promise => !!(await askUnsaved(L("M02_DiscardConfirm"))); setLeaveGuard(confirmLeave); // 머리 메뉴 · 뒤로 가기로 떠날 때도 물음 const showNotice = (nodes: (HTMLElement | string)[]): void => { @@ -573,15 +580,9 @@ export function createMain( } async function settle(): Promise { - if (!busy()) return "clean"; - const choice = await askSettle(); - if (choice === "cancel") return false; - if (choice === "save") { - await doSave(); - return dirty ? false : "changed"; // 저장이 막히면(판 다름 등) 나무도 안 고침 - } - dirty = false; // 버림 — 화면을 서버 그대로 다시 읽음 - if (sel) await open(sel); + const got = await askUnsaved(L("M02_TreeUnsaved")); + if (got === false || got === "clean") return got; + if (got === "discarded" && sel) await open(sel); // 버림 — 화면을 서버 그대로 다시 읽음 return "changed"; } diff --git a/resources/tester/test_m02_card_sheet_edit.py b/resources/tester/test_m02_card_sheet_edit.py index 42caf4113..5553e17e8 100644 --- a/resources/tester/test_m02_card_sheet_edit.py +++ b/resources/tester/test_m02_card_sheet_edit.py @@ -66,11 +66,14 @@ def test_알약은_전체_편집기와_같은_부품(): def test_저장_안_한_카드_고침_물음(): struct = _text(M02 / "M02_MasterTemplete_Structure.ts") show = struct.split("const show = ")[1].split("const pickPrice")[0] - assert "cards.dirty()" in show and "showConfirmDialog(TEXT.discardCards)" in show + assert "cards.dirty()" in show and "leaveOrStay(TEXT.discardCards," in show assert "opts.keepColumn?.(from)" in show main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert "const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting();" in main - assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle) + assert main.count("busy()") >= 2 # 떠나기 · 새로고침 막기 + assert ( + 'askUnsaved(L("M02_TreeUnsaved"))' in main + ) # 나무 고침(settle)도 같은 물음 · 카드 저장 실패까지 봄 assert main.index("await panel.saveCards();") < main.index("await panel.reload();") assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main diff --git a/resources/tester/test_m02_drawing_card.py b/resources/tester/test_m02_drawing_card.py index 7db5d6688..b0a015979 100644 --- a/resources/tester/test_m02_drawing_card.py +++ b/resources/tester/test_m02_drawing_card.py @@ -46,7 +46,7 @@ def test_메뉴는_숨기고_저장은_도면_칸만(): def test_저장_안_한_카드_고침은_물음(): cards = _text("M02_MasterTemplete_ItemCards.ts") # 물음 한 번(31-7) — 어느 카드든(도면 · 엑셀) 고침이 있으면 · 틀의 저장 표시(saver)로 봄 - assert "if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(" in cards + assert "(await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === \"stay\"" in cards assert "const anyDirty = (): boolean => cards.some((c) => c.saver.dirty());" in cards assert "dirty: anyDirty," in cards assert "dirty: () => cards.dirty()," in _text("M02_MasterTemplete_Structure.ts") diff --git a/resources/tester/test_m02_leave_dialog.py b/resources/tester/test_m02_leave_dialog.py new file mode 100644 index 000000000..594b1fc7e --- /dev/null +++ b/resources/tester/test_m02_leave_dialog.py @@ -0,0 +1,125 @@ +"""저장 안 한 고침 3단추 물음(PLAN 86-1 · `ui_template_leave_dialog.ts`) — Node 로 TS 를 돌려 판정. + +못박는 것 + - 단추 순서 [취소] [버리고 넘어감] [저장 후 넘어감] · 기본 초점 = 취소 · Esc = 취소. + - 저장 후 넘어감: 저장 성공 + 고침 없음 = saved / 저장 던짐 · 고침 남음 = stay(머묾). + - 버리고 넘어감: discard 갈고리 호출 · 취소: 아무것도 안 부름. + - M02 자리(UI_Main · Structure · ItemCards)가 이 물음을 씀. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +SCRIPT = r""" +const fs = require("fs"); +const ts = require(process.argv[1] + "/config/node_modules/typescript"); +const src = fs.readFileSync(process.argv[1] + "/ui_template/ui_template_leave_dialog.ts", "utf8"); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +let focused = null; +let overlay = null; +let buttons = []; +const node = (tag, o = {}) => ({ + tag, ...o, kids: [], on: {}, + append(...x) { this.kids.push(...x); }, + addEventListener(k, f) { this.on[k] = f; }, + remove() { overlay = null; }, +}); +const stubs = { + "./ui_template_elements": { + el: node, + createButton: (o) => { + const b = node("button", { label: o.label, variant: o.variant }); + b.focus = () => { focused = b.label; }; + b.click = o.onClick; + buttons.push(b); + return b; + }, + }, + "./ui_template_locale": { t: (k) => k }, +}; +globalThis.document = { activeElement: null, body: { append(x) { overlay = x; } } }; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n)); +const { leaveOrStay } = box.exports; + +const run = async (pick, hooks) => { + buttons = []; + focused = null; + const calls = []; + const wrap = { + save: async () => { calls.push("save"); if (hooks.throws) throw new Error("x"); }, + stillDirty: () => !!hooks.left, + discard: () => calls.push("discard"), + }; + const p = leaveOrStay("msg", wrap); + const labels = buttons.map((b) => b.label); + const first = focused; + if (pick === "esc") overlay.on.keydown({ key: "Escape", stopPropagation() {} }); + else buttons[pick].click(); + return { got: await p, calls, labels, first }; +}; + +(async () => { + const out = { + cancel: await run(0, {}), + esc: await run("esc", {}), + discard: await run(1, {}), + save: await run(2, {}), + saveThrows: await run(2, { throws: true }), + saveLeft: await run(2, { left: true }), + }; + process.stdout.write(JSON.stringify(out)); +})(); +""" + + +@pytest.fixture(scope="module") +def got() -> dict: + done = subprocess.run( + ["node", "-e", SCRIPT, str(ROOT)], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def test_단추_순서_기본_초점_취소(got): + row = got["cancel"] + assert row["labels"] == ["M02_TreeCancel", "M02_TreeDiscardGo", "M02_TreeSaveGo"] + assert row["first"] == "M02_TreeCancel" + + +def test_취소_Esc는_머묾(got): + assert got["cancel"]["got"] == "stay" and got["cancel"]["calls"] == [] + assert got["esc"]["got"] == "stay" and got["esc"]["calls"] == [] + + +def test_버리고_넘어감(got): + assert got["discard"]["got"] == "discarded" + assert got["discard"]["calls"] == ["discard"] + + +def test_저장_후_넘어감과_실패_머묾(got): + assert got["save"] == {**got["save"], "got": "saved", "calls": ["save"]} + assert got["saveThrows"]["got"] == "stay" # 저장 던짐 + assert got["saveLeft"]["got"] == "stay" # 저장 뒤에도 고침이 남음(카드 저장 실패 등) + + +def test_M02_자리가_3단추_물음을_씀(): + main = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8") + assert "leaveOrStay(message, { save: doSave, stillDirty: busy" in main + assert "askSettle" not in main + assert "!(await showConfirmDialog(L(" not in main + cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8") + assert "leaveOrStay(TEXT.discard" in cards + struct = (M02 / "M02_MasterTemplete_Structure.ts").read_text(encoding="utf-8") + assert struct.count("leaveOrStay(TEXT.discardCards") == 2 diff --git a/ui_template/ui_template_leave_dialog.ts b/ui_template/ui_template_leave_dialog.ts new file mode 100644 index 000000000..cf0a72ccb --- /dev/null +++ b/ui_template/ui_template_leave_dialog.ts @@ -0,0 +1,84 @@ +/* ============================================================================= + * ui_template_leave_dialog.ts + * 저장 안 한 고침이 있을 때 넘어가기 전 물음 — 단추 셋: [취소] [버리고 넘어감] [저장 후 넘어감](주 단추). + * 기본 초점 = 취소 · Esc = 취소 · 저장이 실패하거나 고침이 남으면 머묾. + * 몸은 공용 확인 창(`showConfirmDialog`)과 같은 틀(.ui-confirm). + * ========================================================================== */ + +import { createButton, el } from "./ui_template_elements"; +import { t as L } from "./ui_template_locale"; + +export type LeaveChoice = "save" | "discard" | "cancel"; + +/** 3단추 물음 — 닫기 · Esc = "cancel" */ +export function askLeave(message: string): Promise { + return new Promise((resolve) => { + const overlay = el("div", { className: "ui-confirm" }); + const panel = el("div", { className: "ui-confirm__panel" }); + const actions = el("div", { className: "ui-confirm__actions" }); + const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 + const done = (choice: LeaveChoice): void => { + overlay.remove(); + if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); + resolve(choice); + }; + const cancel = createButton({ + label: L("M02_TreeCancel"), + variant: "ghost", + onClick: () => done("cancel"), + }); + actions.append( + cancel, + createButton({ + label: L("M02_TreeDiscardGo"), + variant: "danger", + onClick: () => done("discard"), + }), + createButton({ + label: L("M02_TreeSaveGo"), + variant: "filled", + onClick: () => done("save"), + }), + ); + overlay.addEventListener("keydown", (ev) => { + if (ev.key !== "Escape") return; + ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 + done("cancel"); + }); + panel.append(el("p", { className: "ui-confirm__message", text: message }), actions); + overlay.append(panel); + document.body.append(overlay); + cancel.focus(); // 창에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음 + }); +} + +export interface LeaveHooks { + /** 저장 한 번 — 실패는 던지거나 `stillDirty` 로 알림(알림 글은 저장 길이 띄움) */ + save: () => Promise; + /** 저장 뒤에도 고침이 남았나 — 남으면 머묾 */ + stillDirty: () => boolean; + /** [버리고 넘어감] 때 */ + discard?: () => void | Promise; +} + +/** + * 물음 → 고름대로 처리. 넘어가도 되면 "saved" | "discarded", 머물면 "stay". + * 저장 실패(던짐) · 저장 뒤 고침 남음 · 취소 = "stay". + */ +export async function leaveOrStay( + message: string, + hooks: LeaveHooks, +): Promise<"saved" | "discarded" | "stay"> { + const choice = await askLeave(message); + if (choice === "cancel") return "stay"; + if (choice === "discard") { + await hooks.discard?.(); + return "discarded"; + } + try { + await hooks.save(); + } catch { + return "stay"; + } + return hooks.stillDirty() ? "stay" : "saved"; +} diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index e7da5b753..39a8a1f48 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -154,8 +154,8 @@ export const ui_locales_m2 = { "저장 안 한 표 고침이 있음 — 나무를 고치기 전에 처리", "Unsaved table edits — settle them before changing the tree", ], - M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], - M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], + M02_TreeSaveGo: ["저장 후 넘어감", "Save and continue"], + M02_TreeDiscardGo: ["버리고 넘어감", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], M02_PracticeTitle: ["실무문서", "Practice documents"], M02_PracticeRow: ["실무문서 목록", "Practice document list"],