feat(web): хранилище настроек доски с прежними ключами, импорт и экспорт JSON (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b421687188
commit
5f012db829
34
web/src/board/exportJson.ts
Normal file
34
web/src/board/exportJson.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// полный снимок доски: все задачи и связи, обе раскладки относительно дорожек (layout — обычная, layoutByDate — «по датам»; h только если задана вручную),
|
||||||
|
// порядок и свёртка дорожек, фильтры. Текущую раскладку компонент сохраняет в хранилище (saveLayout) перед вызовом
|
||||||
|
import { JIRA_URL } from "./constants.ts";
|
||||||
|
import { colWidths, foldedSet, laneOrder, savedLayout } from "./storage.ts";
|
||||||
|
import type { BoardModel, Filters, Visible } from "./types.ts";
|
||||||
|
|
||||||
|
type Saved = Record<string, { x: number; ry: number; w: number; h?: number }>;
|
||||||
|
|
||||||
|
export function exportJson(model: BoardModel, vis: Visible, f: Filters, now: string): unknown {
|
||||||
|
const savedPos = (savedLayout(false) || {}) as Saved, savedDate = (savedLayout(true) || {}) as Saved;
|
||||||
|
const shown = new Set(vis.nodes.map((n) => n.key));
|
||||||
|
const fromSaved = (saved: Saved, k: string) => {
|
||||||
|
const r = saved[k]; if (!r) return null;
|
||||||
|
const o: { x: number; ry: number; w: number; h?: number } = { x: r.x, ry: r.ry, w: r.w };
|
||||||
|
if (typeof r.h === "number") o.h = r.h;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
const nodes = model.nodes.filter((n) => !n.stub).map((n) => ({
|
||||||
|
key: n.key, title: n.title, status: n.status, priority: n.prio, kind: n.kind, due: n.due || null, assignee: n.who || null, sprint: n.sprint || null, epic: n.epic,
|
||||||
|
url: `${JIRA_URL}/browse/${n.key}`, visible: shown.has(n.key), layout: fromSaved(savedPos, n.key), layoutByDate: fromSaved(savedDate, n.key),
|
||||||
|
}));
|
||||||
|
const order = laneOrder();
|
||||||
|
return {
|
||||||
|
format: "arm-ka-links/1", exportedAt: now, source: model.fileName,
|
||||||
|
filters: { sprints: [...f.sprints], assignees: [...f.who], neighbors: f.neighbors, onlyLinked: f.onlyLinked, onlyHand: f.onlyHand, criticalPath: f.crit, byDate: f.byDate,
|
||||||
|
hiddenKinds: [...f.hideKind], hiddenEdges: [...f.hideEdge] },
|
||||||
|
laneOrder: order.length ? order : vis.lanes.map((l) => l.name), collapsedLanes: [...foldedSet()], dateColumns: colWidths(),
|
||||||
|
nodes, links: model.links.map((l) => ({ from: l.src, to: l.dst, type: l.type })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// имя файла выгрузки: links-<имя исходного файла без .csv>-<дата>.json
|
||||||
|
export function exportFileName(model: BoardModel, now: string): string {
|
||||||
|
return `links-${(model.fileName || "board").replace(/[^\wА-Яа-яЁё.-]+/g, "_").replace(/\.csv$/i, "")}-${now.slice(0, 10)}.json`;
|
||||||
|
}
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import { CW, KEY_RE, NO_EPIC } from "../constants.ts";
|
import { CW, KEY_RE, NO_EPIC } from "../constants.ts";
|
||||||
import type { BoardNode, Dicts, JsonImport, Link, SavedBox } from "../types.ts";
|
import type { BoardNode, Dicts, JsonImport, Link, SavedBox } from "../types.ts";
|
||||||
import { addLink, addStubs } from "./links.ts";
|
import { addLink, addStubs } from "./links.ts";
|
||||||
|
import { strList } from "../storage.ts";
|
||||||
import { sortableDate } from "./util.ts";
|
import { sortableDate } from "./util.ts";
|
||||||
|
|
||||||
const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : [];
|
|
||||||
|
|
||||||
type Raw = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any -- внешний JSON произвольной формы
|
type Raw = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any -- внешний JSON произвольной формы
|
||||||
|
|
||||||
// JSON, выгруженный старой страницей (см. exportJson): задачи, связи, раскладка, порядок и свёртка дорожек, фильтры
|
// JSON, выгруженный старой страницей (см. exportJson): задачи, связи, раскладка, порядок и свёртка дорожек, фильтры
|
||||||
|
|||||||
80
web/src/board/storage.test.ts
Normal file
80
web/src/board/storage.test.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { BoardSnapshot } from "../api/types.ts";
|
||||||
|
import { canon } from "../test/canon.ts";
|
||||||
|
import { computeVisible } from "./analysis/visible.ts";
|
||||||
|
import { dictsFromSettings } from "./dicts.ts";
|
||||||
|
import { exportJson } from "./exportJson.ts";
|
||||||
|
import settings from "./__fixtures__/settings.json";
|
||||||
|
import storageCases from "./__fixtures__/storage.cases.json";
|
||||||
|
import { computeLayout, measuredHeight } from "./layout/layout.ts";
|
||||||
|
import { fromHub } from "./model/fromHub.ts";
|
||||||
|
import { fromJson } from "./model/fromJson.ts";
|
||||||
|
import { applyJsonSettings, migrateMode, migrateStore, readPrefs, saveLayout, savedLayout, setColWidth, writeFlag } from "./storage.ts";
|
||||||
|
import { noFilters } from "./types.ts";
|
||||||
|
|
||||||
|
const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8");
|
||||||
|
const { dicts } = dictsFromSettings(settings);
|
||||||
|
const NOW = "2026-10-14T12:00:00.000Z";
|
||||||
|
const dump = () => Object.fromEntries(Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)]));
|
||||||
|
|
||||||
|
describe("хранилище = links.html@3931a88", () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
const expected = JSON.parse(fx("storage.expected.json"));
|
||||||
|
for (const c of storageCases as unknown as { name: string; op: string; before: Record<string, string> }[]) {
|
||||||
|
it(`миграция: ${c.name}`, () => {
|
||||||
|
Object.entries(c.before).forEach(([k, v]) => localStorage.setItem(k, v));
|
||||||
|
if (c.op === "startup") migrateMode();
|
||||||
|
migrateStore();
|
||||||
|
expect(dump()).toEqual(expected[c.name]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("импорт JSON старой страницы: хранилище и фильтры", () => {
|
||||||
|
const g = JSON.parse(fx("old-board.expected.json"));
|
||||||
|
const patch = applyJsonSettings(fromJson(fx("old-board.json"), "old-board.json", dicts));
|
||||||
|
expect(dump()).toEqual(g.storage);
|
||||||
|
const f = { ...noFilters(), ...patch };
|
||||||
|
expect(canon({ neighbors: f.neighbors, onlyLinked: f.onlyLinked, onlyHand: f.onlyHand, crit: f.crit,
|
||||||
|
byDate: f.byDate, hideKind: f.hideKind, hideEdge: f.hideEdge })).toEqual(g.S);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("экспорт JSON после раскладки по умолчанию", () => {
|
||||||
|
const g = JSON.parse(fx("hub-basic.expected.json"));
|
||||||
|
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
|
||||||
|
const vis = computeVisible(model, noFilters(), [], "2026-10-14");
|
||||||
|
const lay = computeLayout(vis, { byDate: false, colWidths: {}, saved: null, today: "2026-10-14",
|
||||||
|
measure: () => ({ h: measuredHeight(117), other: 79 }) });
|
||||||
|
saveLayout(lay.rel, lay.colBounds, false);
|
||||||
|
expect(canon(exportJson(model, vis, noFilters(), NOW))).toEqual(g.export);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("флаги и ширины колонок читаются обратно", () => {
|
||||||
|
writeFlag("neighbors", true);
|
||||||
|
writeFlag("snap", false);
|
||||||
|
expect(readPrefs().filters.neighbors).toBe(true);
|
||||||
|
expect(readPrefs().snap).toBe(false);
|
||||||
|
setColWidth("2026-10-20", 400);
|
||||||
|
expect(JSON.parse(localStorage.getItem("graph-colw:board")!)).toEqual({ "2026-10-20": 400 });
|
||||||
|
setColWidth("2026-10-20", null);
|
||||||
|
expect(JSON.parse(localStorage.getItem("graph-colw:board")!)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("закрытое хранилище: чтение даёт умолчания, запись молча пропускается", () => {
|
||||||
|
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||||||
|
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||||||
|
vi.spyOn(Storage.prototype, "key").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||||||
|
expect(() => { migrateMode(); migrateStore(); writeFlag("crit", true); }).not.toThrow();
|
||||||
|
expect(readPrefs().snap).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("раскладка неверной формы отбрасывается при чтении", () => {
|
||||||
|
localStorage.setItem("graph-pos:board", JSON.stringify({ "AURORA-1": null, "AURORA-2": { x: "1" }, "AURORA-3": { x: 1, ry: 2, w: 240, h: 90 } }));
|
||||||
|
expect(savedLayout(false)).toEqual({ "AURORA-3": { x: 1, ry: 2, w: 240, h: 90 } });
|
||||||
|
localStorage.setItem("graph-pos:board", "[1]");
|
||||||
|
expect(savedLayout(false)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
142
web/src/board/storage.ts
Normal file
142
web/src/board/storage.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
// хранилище настроек доски: ключи и формат — как у links.html@3931a88 (прежние настройки пользователя продолжают работать)
|
||||||
|
// localStorage: общие настройки «graph-<имя>», настройки доски «graph-<имя>:board» (раскладка, дорожки, фильтры). Ключ не зависит от имени
|
||||||
|
// файла: карточки адресуются ключами Jira, поэтому расстановка переживает замену данных. Любое обращение защищено от недоступного хранилища
|
||||||
|
import { COLW, GRID } from "./constants.ts";
|
||||||
|
import type { RelBox, Layout } from "./layout/layout.ts";
|
||||||
|
import type { Filters, JsonImport } from "./types.ts";
|
||||||
|
import { noFilters } from "./types.ts";
|
||||||
|
|
||||||
|
export const store = {
|
||||||
|
get(k: string, d: string | null): string | null { try { const v = localStorage.getItem(k); return v === null ? d : v; } catch { return d; } },
|
||||||
|
set(k: string, v: string): void { try { localStorage.setItem(k, v); } catch { /* хранилище недоступно */ } },
|
||||||
|
del(k: string): void { try { localStorage.removeItem(k); } catch { /* хранилище недоступно */ } },
|
||||||
|
json<T>(k: string, d: T): T { try { const v = JSON.parse(store.get(k, "null") as string); return v === null ? d : v as T; } catch { return d; } },
|
||||||
|
};
|
||||||
|
export const pref = (name: string): string => "graph-" + name;
|
||||||
|
const BOARD = "board", BOARD_KEYS = ["pos", "lanes", "fold", "sprint", "who"];
|
||||||
|
export const fileKey = (name: string): string => "graph-" + name + ":" + BOARD;
|
||||||
|
export const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : [];
|
||||||
|
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
||||||
|
const num = (v: unknown): boolean => typeof v === "number" && Number.isFinite(v);
|
||||||
|
|
||||||
|
// до 11.10.2026 настройки лежали под именем файла («graph-pos:<файл>») и терялись при каждой новой выгрузке; при первом запуске новой версии
|
||||||
|
// переносим на общий ключ набор того файла, где расставлено больше всего карточек
|
||||||
|
export function migrateStore(): void {
|
||||||
|
if (store.get(fileKey("pos"), null) !== null) return;
|
||||||
|
const names: string[] = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const k = localStorage.key(i);
|
||||||
|
if (k && k.startsWith("graph-pos:") && !k.endsWith(":" + BOARD)) names.push(k.slice("graph-pos:".length));
|
||||||
|
}
|
||||||
|
} catch { return; }
|
||||||
|
if (!names.length) return;
|
||||||
|
const size = (n: string): number => Object.keys(store.json<object>("graph-pos:" + n, {}) || {}).length;
|
||||||
|
names.sort((a, b) => size(b) - size(a));
|
||||||
|
BOARD_KEYS.forEach((k) => { const v = store.get("graph-" + k + ":" + names[0], null); if (v !== null) store.set(fileKey(k), v); });
|
||||||
|
}
|
||||||
|
// до 11.10.2026 был режим показа: «linked» стал флагом «соседи», «all» — пустым выбором спринта
|
||||||
|
export function migrateMode(): void {
|
||||||
|
const old = store.get(pref("mode"), null);
|
||||||
|
if (old === null) return;
|
||||||
|
if (old === "linked") store.set(pref("neighbors"), "1");
|
||||||
|
if (old === "all") store.set(fileKey("sprint"), "[]");
|
||||||
|
store.del(pref("mode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const laneOrder = (): string[] => strList(store.json(fileKey("lanes"), []));
|
||||||
|
export const foldedSet = (): Set<string> => new Set(strList(store.json(fileKey("fold"), [])));
|
||||||
|
export function colWidths(): Record<string, number> {
|
||||||
|
const v = store.json<unknown>(fileKey("colw"), {});
|
||||||
|
return isObj(v) ? v as Record<string, number> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutKey = (byDate: boolean): string => fileKey(byDate ? "posdate" : "pos"); // у обычной раскладки и раскладки «по датам» свои хранилища
|
||||||
|
// раскладка из хранилища; записи неверной формы (не объект, нет чисел x/ry/w) отбрасываются
|
||||||
|
export function savedLayout(byDate: boolean): unknown {
|
||||||
|
const v = store.json<unknown>(layoutKey(byDate), null);
|
||||||
|
if (!isObj(v)) return null;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
Object.entries(v).forEach(([k, b]) => { if (isObj(b) && num(b.x) && num(b.ry) && num(b.w) && (b.h === undefined || num(b.h))) out[k] = b; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// сохраняем поверх уже записанного: карточки, скрытые текущим фильтром, не теряют свои места
|
||||||
|
// в режиме «по датам» x хранится относительно левой границы колонки даты: при сдвиге границ карточки едут вместе с колонкой
|
||||||
|
export function saveLayout(rel: Record<string, RelBox>, colBounds: Layout["colBounds"], byDate: boolean): void {
|
||||||
|
const raw = store.json<unknown>(layoutKey(byDate), {});
|
||||||
|
const out: Record<string, unknown> = isObj(raw) ? raw : {};
|
||||||
|
Object.entries(rel).forEach(([k, r]) => {
|
||||||
|
const x = r.x - (colBounds[k] ? colBounds[k].x0 : 0);
|
||||||
|
out[k] = r.auto ? { x, ry: r.ry, w: r.w } : { x, ry: r.ry, w: r.w, h: r.h };
|
||||||
|
});
|
||||||
|
store.set(layoutKey(byDate), JSON.stringify(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardPrefs { filters: Filters; snap: boolean; table: boolean; problems: boolean }
|
||||||
|
// sprints/who пустые: их выбирает initialSprints/initialWho (нужна загруженная модель)
|
||||||
|
export function readPrefs(): BoardPrefs {
|
||||||
|
const on = (name: string): boolean => store.get(pref(name), "0") === "1";
|
||||||
|
return {
|
||||||
|
filters: { ...noFilters(), neighbors: on("neighbors"), onlyLinked: on("onlylinked"), onlyHand: on("onlyhand"), crit: on("crit"), byDate: on("bydate"),
|
||||||
|
hideKind: new Set(strList(store.json(pref("hidekind"), []))), hideEdge: new Set(strList(store.json(pref("hideedge"), []))) },
|
||||||
|
snap: store.get(pref("snap"), "1") !== "0", table: on("tbl"), problems: on("prob"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type FlagName = "neighbors" | "onlyLinked" | "onlyHand" | "crit" | "byDate";
|
||||||
|
const FLAG_KEY: Record<FlagName | "snap" | "table" | "problems", string> = {
|
||||||
|
neighbors: "neighbors", onlyLinked: "onlylinked", onlyHand: "onlyhand", crit: "crit", byDate: "bydate", snap: "snap", table: "tbl", problems: "prob",
|
||||||
|
};
|
||||||
|
export function writeFlag(name: FlagName | "snap" | "table" | "problems", on: boolean): void { store.set(pref(FLAG_KEY[name]), on ? "1" : "0"); }
|
||||||
|
export function writeHidden(hideKind: Set<string>, hideEdge: Set<string>): void {
|
||||||
|
store.set(pref("hidekind"), JSON.stringify([...hideKind]));
|
||||||
|
store.set(pref("hideedge"), JSON.stringify([...hideEdge]));
|
||||||
|
}
|
||||||
|
export const rememberedSprints = (): unknown => store.json<unknown>(fileKey("sprint"), null);
|
||||||
|
export const rememberedWho = (): unknown => store.json<unknown>(fileKey("who"), []);
|
||||||
|
export function writeSprints(s: Set<string>): void { store.set(fileKey("sprint"), JSON.stringify([...s])); }
|
||||||
|
export function writeWho(s: Set<string>): void { store.set(fileKey("who"), JSON.stringify([...s])); }
|
||||||
|
|
||||||
|
export function toggleLane(name: string): void {
|
||||||
|
const s = foldedSet();
|
||||||
|
if (s.has(name)) s.delete(name); else s.add(name);
|
||||||
|
store.set(fileKey("fold"), JSON.stringify([...s]));
|
||||||
|
}
|
||||||
|
// current — имена дорожек в текущем порядке; true — порядок изменён и записан
|
||||||
|
export function moveLane(current: string[], name: string, to: number): boolean {
|
||||||
|
const names = [...current], from = names.indexOf(name);
|
||||||
|
if (from < 0 || to < 0 || to >= names.length || from === to) return false;
|
||||||
|
names.splice(to, 0, names.splice(from, 1)[0]);
|
||||||
|
store.set(fileKey("lanes"), JSON.stringify(names.concat(laneOrder().filter((n) => !names.includes(n)))));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// ширины колонок дат, заданные вручную: {дата: px}; меньше COLW не бывает; null или w ≤ COLW — автоматическая ширина
|
||||||
|
export function setColWidth(date: string, w: number | null): void {
|
||||||
|
const cw = colWidths();
|
||||||
|
if (w === null || w <= COLW) delete cw[date]; else cw[date] = Math.round(w / GRID) * GRID;
|
||||||
|
store.set(fileKey("colw"), JSON.stringify(cw));
|
||||||
|
}
|
||||||
|
export function resetLayout(byDate: boolean): void {
|
||||||
|
[byDate ? "posdate" : "pos", "lanes", "fold"].concat(byDate ? ["colw"] : []).forEach((k) => store.del(fileKey(k)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// раскладка и фильтры из JSON кладутся в хранилище доски, так они применятся и к следующей загрузке данных;
|
||||||
|
// возвращает фильтры, которые импорт изменил (спринты и исполнителей выбирают initialSprints/initialWho из хранилища)
|
||||||
|
export function applyJsonSettings(r: JsonImport): Partial<Filters> {
|
||||||
|
if (Object.keys(r.layout).length) store.set(fileKey("pos"), JSON.stringify(r.layout));
|
||||||
|
if (Object.keys(r.layoutByDate).length) store.set(fileKey("posdate"), JSON.stringify(r.layoutByDate));
|
||||||
|
if (r.laneOrder.length) store.set(fileKey("lanes"), JSON.stringify(r.laneOrder));
|
||||||
|
if (r.collapsedLanes) store.set(fileKey("fold"), JSON.stringify(r.collapsedLanes));
|
||||||
|
if (r.dateColumns) store.set(fileKey("colw"), JSON.stringify(r.dateColumns));
|
||||||
|
const f = r.filters, out: Partial<Filters> = {};
|
||||||
|
if (Array.isArray(f.sprints)) store.set(fileKey("sprint"), JSON.stringify(strList(f.sprints)));
|
||||||
|
if (Array.isArray(f.assignees)) store.set(fileKey("who"), JSON.stringify(strList(f.assignees)));
|
||||||
|
if (f.mode === "all") store.set(fileKey("sprint"), "[]"); // «все задачи файла» = пустой выбор спринта (режим «linked» fromJson уже превратил в neighbors)
|
||||||
|
const flag = (src: string, name: FlagName): void => {
|
||||||
|
const v = f[src];
|
||||||
|
if (typeof v === "boolean") { out[name] = v; writeFlag(name, v); }
|
||||||
|
};
|
||||||
|
flag("neighbors", "neighbors"); flag("onlyLinked", "onlyLinked"); flag("onlyHand", "onlyHand"); flag("criticalPath", "crit"); flag("byDate", "byDate");
|
||||||
|
if (Array.isArray(f.hiddenKinds)) { out.hideKind = new Set(strList(f.hiddenKinds)); store.set(pref("hidekind"), JSON.stringify([...out.hideKind])); }
|
||||||
|
if (Array.isArray(f.hiddenEdges)) { out.hideEdge = new Set(strList(f.hiddenEdges)); store.set(pref("hideedge"), JSON.stringify([...out.hideEdge])); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user