feat(대시보드): 334-1 ☰ 구역 바로가기 — 누름 = 서랍 닫힘 + 그 구역으로 굴림 · 전처리 잠금 길 기준 · 서랍이 집 없는 요소 받음
- ui_template_workflow_layout: 시스템 관리자 전용 판정 = 칸 번호 대신 전처리 길 - app_shell: setDrawerSteps 가 부모 없는 요소도 받음(닫을 때 안 옮김) - B01 buildPage: 그린 구역 그대로 · 화면 차례 · 아이콘 구역 키 짝 · 떠난 뒤엔 안 맡김 - general_layout.css: B·M 구역 scroll-margin-top = ☰ 자리 - 새 시험 test_225_drawer_shortcuts(앞) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
@@ -118,10 +118,11 @@ function buildBrand(): HTMLButtonElement {
|
||||
|
||||
/* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠.
|
||||
* 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */
|
||||
let drawerSteps: { node: HTMLElement; home: HTMLElement } | null = null;
|
||||
let drawerSteps: { node: HTMLElement; home: HTMLElement | null } | null = null;
|
||||
|
||||
/** 집(진행 판) 없는 요소 = 서랍이 늘 가짐 · 닫을 때 안 옮김(B01 · M01 · M02 바로가기) */
|
||||
export function setDrawerSteps(node: HTMLElement | null): void {
|
||||
drawerSteps = node?.parentElement ? { node, home: node.parentElement } : null;
|
||||
drawerSteps = node ? { node, home: node.parentElement } : null;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: [keyof typeof ui_locales, string][] = [
|
||||
@@ -166,7 +167,8 @@ function buildDrawer(header: HTMLElement): HTMLElement[] {
|
||||
burger.setAttribute("aria-expanded", String(open));
|
||||
if (!drawerSteps) return;
|
||||
if (open) steps.append(drawerSteps.node);
|
||||
else if (steps.contains(drawerSteps.node)) drawerSteps.home.append(drawerSteps.node);
|
||||
else if (drawerSteps.home && steps.contains(drawerSteps.node))
|
||||
drawerSteps.home.append(drawerSteps.node);
|
||||
};
|
||||
burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open")));
|
||||
scrim.addEventListener("click", () => setOpen(false));
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { section } from "@ui/ui_template_general_blocks";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { createStepBar } from "@ui/ui_template_workflow_layout";
|
||||
import { setDrawerSteps } from "../A00_Common/app_shell";
|
||||
import { currentRoute, navigateTo } from "../A00_Common/router";
|
||||
import {
|
||||
fetchAllCompanies,
|
||||
fetchAllUsers,
|
||||
@@ -231,6 +233,38 @@ function buildSystemSettingsSection(state: DashboardState): HTMLElement {
|
||||
return card;
|
||||
}
|
||||
|
||||
/** ☰ 구역 바로가기 아이콘(334) — 구역 제목 키로 짝(EN 글에서도 같음) */
|
||||
const SECTION_ICONS: [Parameters<typeof L>[0], string][] = [
|
||||
["B01_Dashboard_Resources", "📊"],
|
||||
["B01_Dashboard_SystemSettings", "⚙️"],
|
||||
["B01_Dashboard_Projects", "🗂️"],
|
||||
["B01_Temp_Section", "📦"],
|
||||
["B01_Dashboard_Users", "👥"],
|
||||
["B01_Dashboard_Members", "👥"],
|
||||
["B01_Dashboard_JoinRequests", "📨"],
|
||||
["B01_Dashboard_Companies", "🏢"],
|
||||
["B01_Dashboard_Company", "🏢"],
|
||||
["B01_Dashboard_Profile", "👤"],
|
||||
["B01_Account_Section_Security", "🔒"],
|
||||
["B01_Dashboard_AuditLogs", "📜"],
|
||||
];
|
||||
|
||||
/** ☰ 구역 바로가기(334) — 그린 구역 그대로 · 화면 차례 · 누름 = 서랍 닫힘 + 그 구역으로 굴림 */
|
||||
function mountSectionShortcuts(grid: HTMLElement): void {
|
||||
// 자료 기다리는 사이 다른 쪽으로 떠났으면 맡기지 않음
|
||||
if (currentRoute() !== ROUTES.B01_ACCOUNT) return;
|
||||
const sections = [...grid.children] as HTMLElement[];
|
||||
const titles = sections.map((s) => s.querySelector(".ui-card__title")?.textContent ?? "");
|
||||
const icons = titles.map((title) => SECTION_ICONS.find(([key]) => L(key) === title)?.[1] ?? "");
|
||||
const bar = createStepBar(titles, -1, {
|
||||
orientation: "vertical",
|
||||
icons,
|
||||
routes: titles,
|
||||
onStepClick: (index) => sections[index].scrollIntoView({ behavior: "smooth", block: "start" }),
|
||||
});
|
||||
setDrawerSteps(bar);
|
||||
}
|
||||
|
||||
function buildPage(state: DashboardState): HTMLElement {
|
||||
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
|
||||
const page = document.createElement("div");
|
||||
@@ -311,6 +345,7 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
|
||||
}
|
||||
page.append(grid);
|
||||
mountSectionShortcuts(grid);
|
||||
page.append(buildKnowledgeChat());
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b01-dashboard",
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""PLAN 334 — ☰ 바로가기(요구 225-1 · 225-2).
|
||||
|
||||
① Node — 진행 단계 「시스템 관리자 전용」 = 전처리 길 기준(칸 번호 아님) · 대시보드 둘째 칸은 안 꺼짐
|
||||
② 실제 브라우저(sub2 화면 서버 5177 · 꺼지면 건너뜀) — 대시보드 ☰ 이름 = 구역 제목 차례(역할 셋) ·
|
||||
「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5177") # sub2 화면 서버(OWNERS 자기 줄)
|
||||
LAYOUT = ROOT / "ui_template" / "ui_template_workflow_layout.ts"
|
||||
STEPS = ["파일입력", "전처리", "종단설계"]
|
||||
WF_ROUTES = ["b03-file-input", "b04-preprocess", "b05-profile"]
|
||||
|
||||
|
||||
def _fn(src: str, name: str) -> str:
|
||||
found = re.search(rf"^(?:export )?function {name}\([\s\S]*?^}}", src, re.M)
|
||||
assert found, f"{name} 를 못 찾음"
|
||||
return found.group(0).removeprefix("export ")
|
||||
|
||||
|
||||
def _node(script: str, *args: str):
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script, *args],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
FAKE_DOM = (
|
||||
"const mk = (): any => { const cls = new Set<string>(); return {"
|
||||
" children: [] as any[], textContent: '', title: '', disabled: false, on: null as any,"
|
||||
" set className(v: string) { cls.clear(); v.split(' ').filter(Boolean).forEach((c) => cls.add(c)); },"
|
||||
" get className() { return [...cls].join(' '); },"
|
||||
" classList: { add: (...c: string[]) => c.forEach((x) => cls.add(x)),"
|
||||
" remove: (c: string) => cls.delete(c), contains: (c: string) => cls.has(c),"
|
||||
" toggle: (c: string, on: boolean) => (on ? cls.add(c) : cls.delete(c)) },"
|
||||
" setAttribute() {}, append(...c: any[]) { this.children.push(...c); },"
|
||||
" addEventListener(_: string, f: any) { this.on = f; } }; };\n"
|
||||
"const document = { createElement: () => mk() };\n"
|
||||
)
|
||||
|
||||
|
||||
def _step_bar(role: str, routes: list[str]) -> list[bool]:
|
||||
"""역할 · 길 목록으로 그린 단계 단추의 꺼짐 — 역할 물음이 끝난 뒤"""
|
||||
src = _fn(LAYOUT.read_text(encoding="utf-8"), "createStepBar")
|
||||
script = (
|
||||
FAKE_DOM
|
||||
+ "const ROUTES = { B01_ACCOUNT: 'b01-account', B04_PREPROCESS: 'b04-preprocess' };\n"
|
||||
"const t = (k: string) => k;\nconst navigateTo = () => {};\n"
|
||||
"const getDashboardRole = async () => process.argv[1];\n"
|
||||
+ src.replace("import.meta.env.DEV", "false")
|
||||
+ "\nconst bar = createStepBar(JSON.parse(process.argv[2]), -1,"
|
||||
" { routes: JSON.parse(process.argv[3]) });"
|
||||
"\nawait new Promise((r) => setTimeout(r, 0));"
|
||||
"\nconsole.log(JSON.stringify(bar.children.map((b: any) => b.disabled)));"
|
||||
)
|
||||
return _node(script, role, json.dumps(STEPS), json.dumps(routes))
|
||||
|
||||
|
||||
def test_전처리_잠금은_길_기준():
|
||||
# 프로젝트 진행 단계 — 그대로(전처리만 시스템 관리자 전용)
|
||||
assert _step_bar("ADMIN", WF_ROUTES) == [False, True, False]
|
||||
assert _step_bar("USER", WF_ROUTES) == [False, True, False]
|
||||
assert _step_bar("SYSTEM_ADMIN", WF_ROUTES) == [False, False, False]
|
||||
# 대시보드 구역 바로가기 — 둘째 칸이 회사 관리자 · 일반에서 안 꺼짐
|
||||
titles = ["시스템 설정", "프로젝트", "임시 보관함"]
|
||||
assert _step_bar("ADMIN", titles) == [False, False, False]
|
||||
assert _step_bar("USER", titles) == [False, False, False]
|
||||
|
||||
|
||||
# ── 실제 브라우저 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _up() -> bool:
|
||||
try:
|
||||
urllib.request.urlopen(BASE, timeout=3)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _cred(who: str) -> dict:
|
||||
if who == "시스템 관리자":
|
||||
return json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
|
||||
accounts = json.loads((ROOT / "tmp" / "verify_accounts.json").read_text(encoding="utf-8"))
|
||||
return {k: accounts[who][k] for k in ("email", "password")}
|
||||
|
||||
|
||||
def _page(browser, who: str, route: str, size=(1280, 700)):
|
||||
ctx = browser.new_context(viewport={"width": size[0], "height": size[1]})
|
||||
r = ctx.request.post(f"{BASE}/api/auth/login/request", data=_cred(who), timeout=60000)
|
||||
assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음
|
||||
page = ctx.new_page()
|
||||
page.goto(f"{BASE}/#/{route}", wait_until="domcontentloaded")
|
||||
return page
|
||||
|
||||
|
||||
DRAWER_LABELS = (
|
||||
"[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step-label')]"
|
||||
".map((e) => e.textContent.trim())"
|
||||
)
|
||||
DRAWER_DISABLED = (
|
||||
"[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step')]"
|
||||
".map((e) => e.disabled)"
|
||||
)
|
||||
SECTION_TITLES = (
|
||||
"[...document.querySelector('.b01-dashboard__grid').children]"
|
||||
".map((s) => s.querySelector('.ui-card__title').textContent.trim())"
|
||||
)
|
||||
DRAWER_OPEN = "!!document.querySelector('.is-drawer-open')"
|
||||
|
||||
|
||||
def _open_drawer(page):
|
||||
page.click(".app-burger")
|
||||
page.wait_for_selector(".app-drawer__steps .ui-workflow-layout__step")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐")
|
||||
@pytest.mark.parametrize("who, count", [("시스템 관리자", 10), ("회사관리자", 8), ("일반", 6)])
|
||||
def test_대시보드_구역_바로가기(who, count):
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
page = _page(browser, who, "b01-account")
|
||||
page.wait_for_selector(".b01-dashboard__grid", timeout=60000)
|
||||
page.wait_for_load_state("networkidle")
|
||||
titles = page.evaluate(SECTION_TITLES)
|
||||
assert len(titles) == count, titles
|
||||
_open_drawer(page)
|
||||
assert page.evaluate(DRAWER_LABELS) == titles
|
||||
assert page.evaluate(DRAWER_DISABLED) == [False] * count # 둘째 칸 안 꺼짐
|
||||
if who == "시스템 관리자":
|
||||
page.click(".app-drawer__steps .ui-workflow-layout__step:has-text('보안')")
|
||||
assert not page.evaluate(DRAWER_OPEN)
|
||||
top = (
|
||||
"[...document.querySelector('.b01-dashboard__grid').children]"
|
||||
".find((s) => s.querySelector('.ui-card__title').textContent.trim() === '보안')"
|
||||
".getBoundingClientRect().top"
|
||||
)
|
||||
try: # 부드러운 굴림이 멎을 때까지
|
||||
page.wait_for_function(f"() => Math.abs({top} - 52) <= 2", timeout=5000)
|
||||
except Exception:
|
||||
pass
|
||||
assert abs(page.evaluate(top) - 52) <= 2, page.evaluate(top)
|
||||
browser.close()
|
||||
@@ -117,3 +117,8 @@
|
||||
html.is-bm-route .ui-general-layout__header {
|
||||
padding-left: max(0px, calc(var(--app-burger-inset) + var(--spacing-8) - (100vw - 100%) / 2));
|
||||
}
|
||||
|
||||
/* ☰ 바로가기(334) — 맨 위로 굴린 구역 제목이 ☰ 자리에 깔리지 않게 비킴 */
|
||||
html.is-bm-route .ui-general-block__section {
|
||||
scroll-margin-top: var(--app-burger-inset);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export function createStepBar(
|
||||
button.classList.toggle("is-enabled", isEnabled);
|
||||
button.disabled = !isEnabled;
|
||||
|
||||
if (index === 1) {
|
||||
if (options?.routes?.[index] === ROUTES.B04_PREPROCESS) {
|
||||
void getDashboardRole()
|
||||
.then((role) => {
|
||||
if (role !== "SYSTEM_ADMIN") {
|
||||
|
||||
Reference in New Issue
Block a user