Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { showLoading } from "@ui/ui_template_loading";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { StructureDoc, StructureNumbers } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
@@ -254,10 +255,13 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
c.host.replaceChildren();
|
||||
}
|
||||
const source = opts.source();
|
||||
// 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6) · 못 읽은 것은 아래 그대로(알림 · 「없음」)
|
||||
const stops = [previewHost, ...sheets.map((c) => c.host)].map((host) => showLoading(host));
|
||||
const [drawing, ...books] = await Promise.allSettled([
|
||||
source.read("structure", col.id),
|
||||
...sheets.map((c) => source.read(c.kind, col.id)),
|
||||
]);
|
||||
stops.forEach((stop) => stop());
|
||||
if (mine !== seq) return;
|
||||
if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc;
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
|
||||
@@ -158,7 +158,14 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary {
|
||||
if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
|
||||
editor.showList();
|
||||
},
|
||||
refresh: () => tree.refresh(),
|
||||
refresh: async () => {
|
||||
const stop = editor.loading(); // 조합 목록 칸에도 「불러오는 중」(나무 칸은 나무 부품이 · PLAN 30-6)
|
||||
try {
|
||||
await tree.refresh();
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
},
|
||||
tools: editor.newButton,
|
||||
bind: (fn) => (setDirty = fn),
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { buildPreview } from "./M02_MasterTemplete_Labor_Preview";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { matchText } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
|
||||
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
@@ -83,6 +84,8 @@ export interface LaborEditor {
|
||||
current: () => string | null;
|
||||
/** 라이브러리 찾기 칸 글 — 목록을 같은 규칙(`matchText`)으로 거름 */
|
||||
setQuery: (text: string) => void;
|
||||
/** 조합 목록을 새로 받는 동안 칸 안 「불러오는 중」(목록 화면일 때만 · 돌려받은 함수 = 끝) */
|
||||
loading: () => () => void;
|
||||
}
|
||||
|
||||
export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
|
||||
@@ -167,17 +170,26 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
|
||||
};
|
||||
|
||||
const open = async (key: string): Promise<void> => {
|
||||
const my = ++nav;
|
||||
const stop = showLoading(view); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6)
|
||||
try {
|
||||
const my = ++nav;
|
||||
const one = await fetchCombo(key);
|
||||
await logicsReady;
|
||||
if (my !== nav) return;
|
||||
stop();
|
||||
draw(one.combo, one.version);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
|
||||
if (my !== nav) return;
|
||||
stop();
|
||||
view.replaceChildren(loadingError(reason(error), () => void open(key)));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
/** 조합 목록을 새로 받는 동안 — 목록 화면일 때만 칸 안 표시(돌려받은 함수 = 끝) */
|
||||
const loading = (): (() => void) => (current ? () => {} : showLoading(view));
|
||||
|
||||
const startNew = (path: string[]): void => draw(blankCombo(path), "");
|
||||
|
||||
/* --- 상세 --- */
|
||||
@@ -390,5 +402,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
|
||||
isDirty: dirty,
|
||||
current: () => current?.combo.키 ?? null,
|
||||
setQuery,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import "./M02_MasterTemplete_TreePanel.css";
|
||||
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createTreeRow,
|
||||
@@ -393,6 +394,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
const stop = showLoading(body); // 0.3초 넘으면 나무 칸 안에 「불러오는 중」(PLAN 30-6)
|
||||
try {
|
||||
const got = await api.fetch(opt.layer, owner);
|
||||
if (mine !== seq) return;
|
||||
@@ -400,8 +402,11 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
view = null;
|
||||
body.replaceChildren(hint(L("M02_PickerLoadFailed").replace("{value}", why(error))));
|
||||
stop();
|
||||
body.replaceChildren(loadingError(why(error), () => void refresh()));
|
||||
return;
|
||||
} finally {
|
||||
stop(); // 늦게 온 옛 요청의 표시도 걷음
|
||||
}
|
||||
paint();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { setLeaveGuard } from "../A00_Common/router";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
readFilled,
|
||||
@@ -463,6 +464,7 @@ export function createMain(
|
||||
return empty("");
|
||||
}
|
||||
title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a
|
||||
const stop = showLoading(host); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6)
|
||||
try {
|
||||
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
|
||||
if (mine !== seq) return;
|
||||
@@ -481,12 +483,15 @@ export function createMain(
|
||||
const field = NUMBER_FIELD[next.kind];
|
||||
const number = field && (got.문서 as Record<string, unknown> | null)?.[field];
|
||||
if (number) title.textContent = `${String(number)} ${next.label ?? next.name}`;
|
||||
stop();
|
||||
await mount(shown, next);
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
empty("");
|
||||
stop();
|
||||
const why = error instanceof Error ? error.message : "";
|
||||
showToast(L("M02_LoadFailed").replace("{value}", why), "error");
|
||||
host.replaceChildren(loadingError(why, () => void open(next))); // 그 자리에 까닭 + 다시 불러오기
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""컨테이너 안 「불러오는 중」(PLAN 30-6 · `ui_template_loading.ts`) — 0.3초 · 실패 글 · M02 자리.
|
||||
|
||||
① `delayedShow` 를 Node 로 가짜 시계로 돌림 — 0.3초 전에 끝나면 안 보임(깜빡임 없음) · 넘으면 보이고
|
||||
끝나면 걷힘 · 끝낸 뒤 시계가 와도 안 보임.
|
||||
② 실패 자리 = 까닭 글(「불러오지 못함 — 까닭」) + [다시 불러오기] · 화면 전체 잠금 안 씀.
|
||||
③ M02 나무 · 상세(표 · 도면) · 조합 목록 · 조합 상세 · 항목 카드가 이 부품을 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
LOADING = ROOT / "ui_template" / "ui_template_loading.ts"
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_0점3초_넘을_때만_보임():
|
||||
found = re.search(r"^export function delayedShow[\s\S]*?^}", _text(LOADING), re.M)
|
||||
assert found, "delayedShow 를 못 찾음"
|
||||
script = (
|
||||
"const LOADING_DELAY_MS = 300;\n"
|
||||
+ found.group(0).replace("export ", "")
|
||||
+ """
|
||||
let now = 0; const jobs = [];
|
||||
const timer = {
|
||||
set: (fn, ms) => { jobs.push({ at: now + ms, fn, live: true }); return jobs.length - 1; },
|
||||
clear: (id) => { if (jobs[id]) jobs[id].live = false; },
|
||||
};
|
||||
const tick = (ms) => {
|
||||
now += ms;
|
||||
for (const j of jobs) if (j.live && j.at <= now) { j.live = false; j.fn(); }
|
||||
};
|
||||
const log = [];
|
||||
const on = (n) => () => log.push(n + ":show"); const off = (n) => () => log.push(n + ":hide");
|
||||
const fast = delayedShow(on("fast"), off("fast"), undefined, timer);
|
||||
tick(200); fast(); tick(500);
|
||||
const slow = delayedShow(on("slow"), off("slow"), undefined, timer);
|
||||
tick(299); log.push("299"); tick(1); slow(); tick(500);
|
||||
console.log(JSON.stringify(log));
|
||||
"""
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == ["299", "slow:show", "slow:hide"]
|
||||
|
||||
|
||||
def test_실패_글과_다시_불러오기_M02_자리():
|
||||
src = _text(LOADING)
|
||||
assert 'reason ? `${t("Common_Load_Failed")} — ${reason}` : t("Common_Load_Failed")' in src
|
||||
assert 'createButton({ label: t("Common_Reload")' in src and 'role: "alert"' in src
|
||||
assert "showLoadingOverlay()" not in src # 칸 안만(화면 전체 잠금 안 부름)
|
||||
# 브라우저 setTimeout 을 객체 메서드로 부르면 Illegal invocation — 감싼 기본값(ORCA 흠)
|
||||
assert "set: (fn, ms) => setTimeout(fn, ms)," in src
|
||||
assert "clear: (id) => clearTimeout(id)," in src
|
||||
locale = _text(ROOT / "ui_template" / "ui_template_locale_common.ts")
|
||||
assert 'Common_Load_Failed: ["불러오지 못함"' in locale
|
||||
assert 'Common_Reload: ["다시 불러오기"' in locale
|
||||
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert "const stop = showLoading(body);" in tree
|
||||
assert "loadingError(why(error), () => void refresh())" in tree
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert "const stop = showLoading(host);" in main
|
||||
assert "loadingError(why, () => void open(next))" in main
|
||||
edit = _text(M02 / "M02_MasterTemplete_Labor_Edit.ts")
|
||||
assert "loadingError(reason(error), () => void open(key))" in edit
|
||||
assert "(current ? () => {} : showLoading(view))" in edit
|
||||
assert "const stop = editor.loading();" in _text(M02 / "M02_MasterTemplete_Labor.ts")
|
||||
assert "showLoading(host)" in _text(M02 / "M02_MasterTemplete_ItemCards.ts")
|
||||
@@ -0,0 +1,36 @@
|
||||
/* 컨테이너 안 불러오는 중 · 실패 자리(ui_template_loading.ts) — 회전 표시는 공용 .ui-spinner 와 같은 색 · 작게 */
|
||||
.ui-loading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.ui-loading__spin {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-mist-violet);
|
||||
border-top-color: var(--color-royal-amethyst);
|
||||
border-radius: 50%;
|
||||
animation: ui-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.ui-loading--error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@keyframes ui-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ui-loading__spin {
|
||||
animation-duration: 2.4s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/* =============================================================================
|
||||
* ui_template_loading.ts
|
||||
* 컨테이너 안 「불러오는 중」 — 화면 전체 잠금(`showLoadingOverlay`)과 달리 그 칸 안에만 작은 회전 표시 +
|
||||
* 글 · 0.3초 넘게 걸릴 때만 보임(빠른 응답은 깜빡임 없음) · 실패하면 그 자리에 까닭 + [다시 불러오기].
|
||||
*
|
||||
* const stop = showLoading(host);
|
||||
* try { const got = await load(); stop(); draw(got); }
|
||||
* catch (e) { stop(); host.replaceChildren(loadingError(why(e), () => void again())); }
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_loading.css";
|
||||
import { createButton, el } from "./ui_template_elements";
|
||||
import { t } from "./ui_template_locale";
|
||||
|
||||
export const LOADING_DELAY_MS = 300;
|
||||
|
||||
/** `delay` 뒤에 `show` — 그 전에 돌려받은 함수를 부르면 안 보임 · 보인 뒤면 `hide` */
|
||||
export function delayedShow(
|
||||
show: () => void,
|
||||
hide: () => void,
|
||||
delay: number = LOADING_DELAY_MS,
|
||||
// 브라우저 setTimeout 은 window 에서 불러야 함(객체 메서드로 부르면 Illegal invocation) — 감싸서 넘김
|
||||
timer: {
|
||||
set: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
||||
clear: (id: ReturnType<typeof setTimeout>) => void;
|
||||
} = {
|
||||
set: (fn, ms) => setTimeout(fn, ms),
|
||||
clear: (id) => clearTimeout(id),
|
||||
},
|
||||
): () => void {
|
||||
let shown = false;
|
||||
const id = timer.set(() => {
|
||||
shown = true;
|
||||
show();
|
||||
}, delay);
|
||||
return () => {
|
||||
timer.clear(id);
|
||||
if (shown) hide();
|
||||
};
|
||||
}
|
||||
|
||||
/** 칸 안 불러오는 중 — 돌려받은 함수 = 끝(보였으면 표시를 뗌 · 내용은 부르는 쪽이 그림) */
|
||||
export function showLoading(host: HTMLElement, text = t("Common_Status_Loading")): () => void {
|
||||
const box = el("div", {
|
||||
className: "ui-loading",
|
||||
attrs: { role: "status", "aria-live": "polite" },
|
||||
children: [
|
||||
el("span", { className: "ui-loading__spin", attrs: { "aria-hidden": "true" } }),
|
||||
el("span", { text }),
|
||||
],
|
||||
});
|
||||
return delayedShow(
|
||||
() => host.replaceChildren(box),
|
||||
() => box.remove(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 실패 글 — 까닭 그대로 · 없으면 「불러오지 못함」 */
|
||||
export const loadingFailText = (reason: string): string =>
|
||||
reason ? `${t("Common_Load_Failed")} — ${reason}` : t("Common_Load_Failed");
|
||||
|
||||
/** 실패 자리 — 까닭 글 + [다시 불러오기] */
|
||||
export function loadingError(reason: string, retry: () => void): HTMLElement {
|
||||
const again = createButton({ label: t("Common_Reload"), variant: "ghost", size: "sm" });
|
||||
again.addEventListener("click", retry);
|
||||
return el("div", {
|
||||
className: "ui-loading ui-loading--error",
|
||||
attrs: { role: "alert" },
|
||||
children: [el("span", { text: loadingFailText(reason) }), again],
|
||||
});
|
||||
}
|
||||
@@ -39,6 +39,8 @@ export const ui_locales_common = {
|
||||
* 공통 — 상태 / 메시지
|
||||
* ------------------------------------------------------------------------ */
|
||||
Common_Status_Loading: ["불러오는 중...", "Loading..."],
|
||||
Common_Load_Failed: ["불러오지 못함", "Failed to load"],
|
||||
Common_Reload: ["다시 불러오기", "Reload"],
|
||||
Common_Status_Saving: ["저장 중...", "Saving..."],
|
||||
Common_Status_Processing: ["처리 중...", "Processing..."],
|
||||
Common_Status_Success: ["완료되었습니다", "Completed"],
|
||||
|
||||
Reference in New Issue
Block a user