From daf093c39996cd5d4757fda040d8d29cf94843ca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 08:41:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EB=82=98=EB=AC=B4=20M02=20=EB=82=98?= =?UTF-8?q?=EB=AC=B4=20=EA=BC=B4=20=E2=80=94=20=EC=B8=B5=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EC=85=8B=20=C2=B7=20=EC=9E=8E=20=3D=20=EB=A7=88?= =?UTF-8?q?=EC=A7=80=EB=A7=89=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EB=A8=BC=EC=A0=80=20=C2=B7=20=EC=99=BC=EC=AA=BD=20?= =?UTF-8?q?=ED=8F=AD=20320px(173-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- resources/tester/test_173_2_picker_tree.py | 62 +++++++++++++++++++ resources/tester/ui/item-picker-data.test.ts | 34 ++++++++-- ui_template/ui_template_item_picker.css | 19 ++++-- ui_template/ui_template_item_picker.ts | 65 +++++++++++++++++--- ui_template/ui_template_item_picker_data.ts | 17 +++-- 5 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 resources/tester/test_173_2_picker_tree.py diff --git a/resources/tester/test_173_2_picker_tree.py b/resources/tester/test_173_2_picker_tree.py new file mode 100644 index 000000000..af60cd0e6 --- /dev/null +++ b/resources/tester/test_173_2_picker_tree.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""PLAN 173-2 — 고르기 모달 왼쪽 나무 = M02 마스터 나무 꼴. + +자료 쪽(잎 글 · 폴더 먼저 · 툴팁)은 Node 시험(`ui/item-picker-data.test.ts`)으로, +층 단추 셋 · 뿌리 줄 없음 · 빈 자리 메뉴 · 폭 320px 은 소스로 본다. +""" + +import os +import subprocess + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +UI = os.path.join(ROOT, "ui_template") + + +def _text(name: str) -> str: + return open(os.path.join(UI, name), encoding="utf-8").read() + + +def test_자료_시험_통과_잎_글_폴더_먼저(): + proc = subprocess.run( + [ + "node", + "--experimental-strip-types", + os.path.join(ROOT, "resources", "tester", "ui", "item-picker-data.test.ts"), + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + assert "통과" in proc.stdout + + +def test_층_단추_셋_뿌리_줄_없음(): + ts = _text("ui_template_item_picker.ts") + assert "children: [search.root, layerBar, treeHost]" in ts + assert "...PICKER_LAYERS.map((one) =>" in ts + assert 'variant: one === layer ? "filled" : "ghost"' in ts + # 고른 층의 자식부터 그림(뿌리 줄 없음) + assert "groups.find((group) => group.id === layer)?.children ?? []" in ts + # 처음 층 = 지금 항목 층 · 없으면 개인 · 없으면 마스터 + assert "layer = layerOf(start);" in ts + # 공용 줄 부품 그대로 + assert "createTreeRow(node, caret, depth" in ts + + +def test_개인_맨_위_새_항목은_빈_자리_오른쪽_클릭(): + ts = _text("ui_template_item_picker.ts") + assert 'treeHost.addEventListener("contextmenu"' in ts + assert 'closest(".ui-tree__row")' in ts + assert "folder: { layer, path: [] }" in ts + + +def test_왼쪽_칸_폭_320_테두리_없음(): + css = _text("ui_template_item_picker.css") + assert "grid-template-columns: var(--wf-left-panel-width) minmax(320px, 1fr);" in css + tree = css[css.index(".ui-item-picker__tree {") :] + tree = tree[: tree.index("}")] + assert "border" not in tree + theme = _text("ui_template_theme.css") + assert "--wf-left-panel-width: 320px;" in theme diff --git a/resources/tester/ui/item-picker-data.test.ts b/resources/tester/ui/item-picker-data.test.ts index 256912023..85f797150 100644 --- a/resources/tester/ui/item-picker-data.test.ts +++ b/resources/tester/ui/item-picker-data.test.ts @@ -9,6 +9,7 @@ import { folderLines, isCurrent, itemName, + leafLabel, pathIds, pathOf, rowId, @@ -120,14 +121,14 @@ assert.equal(size?.label, "1.2m"); assert.deepEqual(size?.folder, { layer: "system", path: ["돌기슭막이", "메쌓기", "1.2m"] }); assert.deepEqual(tree[2].folder, { layer: "personal", path: [] }); // 그룹 줄 = 빈 경로 const leaf = size?.children?.[0]; -assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유"); -assert.equal(leaf?.tip, "마스터 · SS000001"); // 툴팁 = 층 이름 + 키 +assert.equal(leaf?.label, "기초유"); // 잎 글 = 경로 마지막 이름(M02 나무 꼴 · 173-2) +assert.equal(leaf?.tip, "돌기슭막이 메쌓기 1.2m 기초유 · 마스터 · SS000001"); // 툴팁 = 전체 이름 · 층 · 키 assert.equal(leaf?.count, null); assert.ok(leaf?.row); -// 경로가 짧은 항목은 얕은 폴더에(옹벽 H=2m = 폴더 옹벽 · 잎 「옹벽 H=2m」) +// 경로가 짧은 항목은 얕은 폴더에(옹벽 H=2m = 폴더 옹벽 · 잎 「H=2m」) assert.equal(tree[1].children?.[1].label, "옹벽"); -assert.equal(tree[1].children?.[1].children?.[0].label, "옹벽 H=2m"); +assert.equal(tree[1].children?.[1].children?.[0].label, "H=2m"); assert.equal(tree[1].children?.[1].children?.[0].folder, undefined); // 잎에는 folder 없음 // 서버가 준 `경로` 가 종류 · 분류보다 앞 — 이름 = 경로 이음 · 자리표는 뺌 @@ -148,6 +149,31 @@ const deepTree = buildTree([deep], labels)[2]; assert.equal(deepTree.children?.[0].children?.[0].children?.[0].children?.[0].label, "라"); assert.equal(deepTree.count, 1); +// 잎 글 = 마지막 이름 · 비면 열 id · 키 +assert.equal(leafLabel(rows[6]), "H=2m"); +assert.equal(leafLabel(fresh), "n1"); +assert.equal(leafLabel({ key: "SS000009", 열: "" }), "SS000009"); + +// 폴더 먼저 · 잎 뒤 — 같은 갈래 안은 서버 차례 그대로(173-2) +const mixed = buildTree( + [ + row("SS000501", "m1", "잎하나", ["", "", ""], "personal"), + row("SS000502", "m2", "가폴더", ["잎", "", ""], "personal"), + row("SS000503", "m3", "잎둘", ["", "", ""], "personal"), + row("SS000504", "m4", "나폴더", ["잎", "", ""], "personal"), + ], + labels, +)[2]; +assert.deepEqual( + mixed.children?.map((k) => [k.label, !!k.row]), + [ + ["가폴더", false], + ["나폴더", false], + ["잎하나", true], + ["잎둘", true], + ], +); + // 빈 그룹도 한 줄은 남음(회사 없는 사람의 회사 · 개인 그룹) const onlySystem = buildTree( rows.filter((r) => r.layer === "system"), diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index 8fe59c9e3..b84f490ed 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -3,7 +3,8 @@ .ui-item-picker__cols { display: grid; - grid-template-columns: minmax(220px, 3fr) minmax(320px, 9fr); + /* 왼쪽 = M02 좌측 패널 폭(320px · 173-2) */ + grid-template-columns: var(--wf-left-panel-width) minmax(320px, 1fr); gap: var(--spacing-12); flex: 1; min-height: 0; @@ -17,18 +18,28 @@ min-width: 0; } +.ui-item-picker__layers { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +/* 나무 상자 = M02 나무 꼴(테두리 없음 · 긴 이름은 줄바꿈 · 173-2) */ .ui-item-picker__tree { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); overflow: auto; } +.ui-item-picker__tree .ui-tree__row { + min-width: 0; + overflow-wrap: anywhere; +} + .ui-item-picker__detail { display: flex; flex-direction: column; diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index 82dc34790..fe68930e6 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -8,6 +8,7 @@ * * 26-3b — 카드 · 편집 · [재적용] · [개인에 저장] 은 **고르는 구조물의 복사본**(`structureId`) 기준. * 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정). + * 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭. * ========================================================================== */ import "./ui_template_item_picker.css"; @@ -17,6 +18,7 @@ import { buildTree, filterRows, isCurrent, + PICKER_LAYERS, layerOf, ownerOf, pathIds, @@ -30,7 +32,13 @@ import { } from "./ui_template_item_picker_data"; import { t } from "./ui_template_locale"; import { openModal } from "./ui_template_modal"; -import { createTreeRow, renderTree, type TreeMenuItem, type TreeNode } from "./ui_template_tree"; +import { + createTreeRow, + openTreeMenu, + renderTree, + type TreeMenuItem, + type TreeNode, +} from "./ui_template_tree"; import { askFields } from "./ui_template_item_picker_ask"; import { projectItems, type CreatedItem } from "./ui_template_item_picker_project"; import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; @@ -119,11 +127,14 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { let rows: PickerRow[] = []; let chosen: PickerRow | null = null; + /** 보이는 층 하나(M02 층 단추 꼴) */ + let layer: PickerLayer = "personal"; /** 접었다 펼친 것을 다시 그려도 남기려고 펼친 줄 id 를 기억 */ const opened = new Set(); const leafButtons = new Map(); const search = createInputField({ placeholder: t("M02_PickerSearch"), compact: true }); + const layerBar = el("div", { className: "ui-item-picker__layers" }); const treeHost = el("div", { className: "ui-item-picker__tree" }); const detailView = createItemDetail({ projectId: opts.projectId ?? null, @@ -138,7 +149,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise toTree(child, searching)), }); + /** 층 단추 셋 — 고른 층 = filled · 찾는 중엔 층마다 맞은 수 */ + const paintLayers = (groups: PickNode[], searching: boolean): void => { + layerBar.replaceChildren( + ...PICKER_LAYERS.map((one) => { + const name = labels().layers[one]; + const count = groups.find((group) => group.id === one)?.count ?? 0; + const button = createButton({ + label: searching ? `${name} (${count})` : name, + variant: one === layer ? "filled" : "ghost", + onClick: () => { + layer = one; + paintTree(); + }, + }); + button.setAttribute("aria-pressed", String(one === layer)); + return button; + }), + ); + }; + const paintTree = (): void => { leafButtons.clear(); const text = search.input.value; const searching = text.trim() !== ""; - const groups = buildTree(filterRows(rows, text), labels()) - // 찾는 중에는 결과가 없는 그룹을 감춤 - .filter((group) => !searching || (group.count ?? 0) > 0) - .map((group) => toTree(group, searching)); - if (!groups.length) { - treeHost.replaceChildren(hint(t("M02_PickerNoMatch"))); + const groups = buildTree(filterRows(rows, text), labels()); + paintLayers(groups, searching); + // 뿌리 줄 없음 — 고른 층의 폴더 · 항목부터(M02 29-6 꼴) + const nodes = (groups.find((group) => group.id === layer)?.children ?? []).map((node) => + toTree(node, searching), + ); + if (!nodes.length) { + treeHost.replaceChildren(hint(t(searching ? "M02_PickerNoMatch" : "M02_TreeEmpty"))); return; } treeHost.replaceChildren( - ...renderTree(groups, (node, caret, depth, onClick) => { + ...renderTree(nodes, (node, caret, depth, onClick) => { const button = createTreeRow(node, caret, depth, () => { onClick(); if (!node.children?.length) return; @@ -243,9 +279,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise layerOf(row) === "personal") ? "personal" : "system"); + layer = rows.some((row) => layerOf(row) === "personal") ? "personal" : "system"; } paintTree(); paintDetail(); @@ -253,6 +290,14 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { + if ((event.target as HTMLElement).closest(".ui-tree__row")) return; + const items = menuOf({ id: layer, label: "", count: 0, folder: { layer, path: [] } }); + if (!items) return; + event.preventDefault(); + openTreeMenu(event, items()); + }); applyButton.addEventListener( "click", () => chosen && finish({ action: "apply", item: toSummary(chosen) }), diff --git a/ui_template/ui_template_item_picker_data.ts b/ui_template/ui_template_item_picker_data.ts index 598364652..45dd71628 100644 --- a/ui_template/ui_template_item_picker_data.ts +++ b/ui_template/ui_template_item_picker_data.ts @@ -71,6 +71,9 @@ export function pathOf(row: PickerRow): string[] { /** 보이는 항목 이름 — 경로를 이음(비면 열 id · 키) */ export const itemName = (row: PickerRow): string => pathOf(row).join(" ") || row.열 || row.key; +/** 나무 잎 글 — 경로 마지막 이름(M02 나무와 같게 · 173-2) · 비면 열 id · 키 */ +export const leafLabel = (row: PickerRow): string => pathOf(row).at(-1) || row.열 || row.key; + /** 마우스를 올렸을 때 — 층 이름 + 키 */ export const rowTip = (row: PickerRow, labels: TreeLabels): string => `${labels.layers[layerOf(row)]} · ${row.key}`; @@ -115,7 +118,13 @@ export interface TreeLabels { const folderId = (layer: PickerLayer, path: string[]): string => [layer, ...path].join("|"); -/** 그룹 → 폴더(경로 앞부분 · 깊이 자유) → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */ +/** 폴더 먼저 · 잎 뒤(M02 나무와 같게 · 173-2) — 같은 갈래 안은 서버 차례 그대로(안정 정렬) */ +function foldersFirst(node: PickNode): PickNode { + node.children?.sort((a, b) => Number(!!a.row) - Number(!!b.row)).forEach(foldersFirst); + return node; +} + +/** 그룹 → 폴더(경로 앞부분 · 깊이 자유) → 항목(잎). 폴더 먼저 · 빈 그룹도 한 줄 남김. */ export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] { return PICKER_LAYERS.map((layer) => { const root: PickNode = { @@ -142,8 +151,8 @@ export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] { }); parent.children?.push({ id: rowId(row), - label: itemName(row), - tip: rowTip(row, labels), + label: leafLabel(row), + tip: `${itemName(row)} · ${rowTip(row, labels)}`, count: null, row, }); @@ -152,7 +161,7 @@ export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] { if (node) node.count = (node.count ?? 0) + 1; } } - return root; + return foldersFirst(root); }); }