feat(spa): import/export with per-item selection + conflict resolution
Two-pane Export tab with collapsible groups, search, presets (Full/Wildcards/Favorites), and live bundle summary. Import tab with drag-drop, conflict badges (new/exists/modified), per-item selection, and rename/overwrite/skip resolution. Adds bundleSelection.ts pure helpers (filtering, presets, hashing, classification) backed by 21 unit tests, plus 9 ImportExport.vue mount tests covering the export group select-all flow and the import bundle parse + filter + send pipeline.
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import PrimeVue from "primevue/config";
|
||||
import ToastService from "primevue/toastservice";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
modules: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
categories: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
exportBundle: vi.fn(),
|
||||
importBundle: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import ImportExport from "../views/ImportExport.vue";
|
||||
import type { CategoryRow, ModuleRow } from "../api/types";
|
||||
|
||||
const apiAny = api as unknown as {
|
||||
modules: { list: ReturnType<typeof vi.fn> };
|
||||
categories: { list: ReturnType<typeof vi.fn> };
|
||||
importBundle: ReturnType<typeof vi.fn>;
|
||||
exportBundle: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function mkModule(over: Partial<ModuleRow>): ModuleRow {
|
||||
return {
|
||||
id: "wc_aaaa1111",
|
||||
type: "wildcard",
|
||||
name: "Sample",
|
||||
description: "",
|
||||
category_id: null,
|
||||
tags: [],
|
||||
is_favorite: false,
|
||||
payload: { options: [] },
|
||||
version: 1,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mkCategory(over: Partial<CategoryRow>): CategoryRow {
|
||||
return { id: "cat_a", name: "Style", color: "#a78bfa", icon: null, sort_order: 0, ...over };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
apiAny.modules.list.mockReset();
|
||||
apiAny.categories.list.mockReset();
|
||||
apiAny.importBundle.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function mountView() {
|
||||
return mount(ImportExport, {
|
||||
global: { plugins: [PrimeVue, ToastService] },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ImportExport.vue — Export tab", () => {
|
||||
it("renders both tabs and starts on Export", async () => {
|
||||
apiAny.modules.list.mockResolvedValue({ items: [], total: 0 });
|
||||
apiAny.categories.list.mockResolvedValue({ items: [] });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="io-tab-export"]').exists()).toBe(true);
|
||||
expect(wrap.find('[data-test="io-tab-import"]').exists()).toBe(true);
|
||||
expect(wrap.find('[data-test="io-export-pane"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("disables 'Download bundle' when nothing is selected", async () => {
|
||||
apiAny.modules.list.mockResolvedValue({ items: [], total: 0 });
|
||||
apiAny.categories.list.mockResolvedValue({ items: [] });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
const btn = wrap.find('[data-test="io-export-download"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("seeds the default selection to 'Full library' once data loads", async () => {
|
||||
apiAny.modules.list.mockResolvedValue({
|
||||
items: [
|
||||
mkModule({ id: "wc_a", type: "wildcard", name: "Pose" }),
|
||||
mkModule({ id: "fv_a", type: "fixed_values", name: "Defaults" }),
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
apiAny.categories.list.mockResolvedValue({
|
||||
items: [mkCategory({ id: "cat_a" })],
|
||||
});
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// Download button enabled because everything is checked by default.
|
||||
const btn = wrap.find('[data-test="io-export-download"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("'None' button clears all selections and disables download", async () => {
|
||||
apiAny.modules.list.mockResolvedValue({
|
||||
items: [mkModule({ id: "wc_a", type: "wildcard" })],
|
||||
total: 1,
|
||||
});
|
||||
apiAny.categories.list.mockResolvedValue({ items: [] });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrap.find('[data-test="io-export-select-none"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
const btn = wrap.find('[data-test="io-export-download"]');
|
||||
expect(btn.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("'Wildcards only' preset selects only wildcard rows", async () => {
|
||||
apiAny.modules.list.mockResolvedValue({
|
||||
items: [
|
||||
mkModule({ id: "wc_a", type: "wildcard" }),
|
||||
mkModule({ id: "fv_a", type: "fixed_values" }),
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
apiAny.categories.list.mockResolvedValue({ items: [] });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrap.find('[data-test="io-preset-wildcards"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
const summary = wrap.find('[data-test="io-export-summary"]').text();
|
||||
// 1 wildcard, 0 fixed values, 1 total
|
||||
expect(summary).toMatch(/Wildcards\s*1/);
|
||||
expect(summary).toMatch(/Fixed values\s*0/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ImportExport.vue — Import tab", () => {
|
||||
/**
|
||||
* jsdom doesn't expose `DragEvent` and `HTMLInputElement.files` is
|
||||
* read-only — both are facts of life for unit tests. We invoke the file
|
||||
* input's `change` handler with a manually-attached `files` array, which
|
||||
* routes through the same `handleFile()` path the dropzone uses.
|
||||
*/
|
||||
async function feedBundle(wrap: ReturnType<typeof mountView>, bundle: unknown) {
|
||||
const file = new File([JSON.stringify(bundle)], "bundle.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const input = wrap.find<HTMLInputElement>('[data-test="io-file-input"]');
|
||||
Object.defineProperty(input.element, "files", {
|
||||
value: [file], configurable: true,
|
||||
});
|
||||
await input.trigger("change");
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
it("switches to Import tab and shows the dropzone", async () => {
|
||||
apiAny.modules.list.mockResolvedValue({ items: [], total: 0 });
|
||||
apiAny.categories.list.mockResolvedValue({ items: [] });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrap.find('[data-test="io-tab-import"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(wrap.find('[data-test="io-import-pane"]').exists()).toBe(true);
|
||||
expect(wrap.find('[data-test="io-dropzone"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders conflict badges for parsed bundle rows", async () => {
|
||||
// Local library has wc_a; incoming bundle re-imports wc_a (modified) plus a new one.
|
||||
apiAny.modules.list.mockResolvedValue({
|
||||
items: [mkModule({ id: "wc_a", payload: { options: [{ id: "o1", value: "x", weight: 1 }] } })],
|
||||
total: 1,
|
||||
});
|
||||
apiAny.categories.list.mockResolvedValue({ items: [] });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrap.find('[data-test="io-tab-import"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
await feedBundle(wrap, {
|
||||
version: 1,
|
||||
modules: [
|
||||
mkModule({ id: "wc_a", payload: { options: [{ id: "o1", value: "y", weight: 1 }] } }),
|
||||
mkModule({ id: "wc_b", name: "Brand New" }),
|
||||
],
|
||||
categories: [],
|
||||
});
|
||||
|
||||
expect(wrap.find('[data-test="io-import-badge-wc_a"]').exists()).toBe(true);
|
||||
expect(wrap.find('[data-test="io-import-badge-wc_b"]').exists()).toBe(true);
|
||||
expect(wrap.find('[data-test="io-import-badge-wc_a"]').text()).toBe("modified");
|
||||
expect(wrap.find('[data-test="io-import-badge-wc_b"]').text()).toBe("new");
|
||||
});
|
||||
|
||||
it("ticking a group's select-all box ticks all leaf checkboxes", async () => {
|
||||
apiAny.modules.list.mockResolvedValue({
|
||||
items: [
|
||||
mkModule({ id: "wc_a", type: "wildcard", name: "A" }),
|
||||
mkModule({ id: "wc_b", type: "wildcard", name: "B" }),
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
apiAny.categories.list.mockResolvedValue({ items: [] });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// Clear, then select all wildcards via the group checkbox.
|
||||
await wrap.find('[data-test="io-export-select-none"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
// Find the group's checkbox (PrimeVue Checkbox uses an inner <input>).
|
||||
const groupCheckEl = wrap.get('[data-test="io-export-group-check-wildcard"]');
|
||||
const input = groupCheckEl.find("input");
|
||||
await input.setValue(true);
|
||||
await flushPromises();
|
||||
|
||||
// Both rows should now be selected → download should be enabled.
|
||||
const btn = wrap.find('[data-test="io-export-download"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
const summary = wrap.find('[data-test="io-export-summary"]').text();
|
||||
expect(summary).toMatch(/Wildcards\s*2/);
|
||||
});
|
||||
|
||||
it("import button sends only the checked items to api.importBundle", async () => {
|
||||
apiAny.modules.list.mockResolvedValue({ items: [], total: 0 });
|
||||
apiAny.categories.list.mockResolvedValue({ items: [] });
|
||||
apiAny.importBundle.mockResolvedValue({
|
||||
modules_imported: 1, categories_imported: 0, skipped: [],
|
||||
});
|
||||
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
await wrap.find('[data-test="io-tab-import"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
await feedBundle(wrap, {
|
||||
version: 1,
|
||||
modules: [
|
||||
mkModule({ id: "wc_keep", name: "Keep me" }),
|
||||
mkModule({ id: "wc_drop", name: "Drop me" }),
|
||||
],
|
||||
categories: [],
|
||||
});
|
||||
|
||||
// Untick wc_drop by clicking its row (toggle off — was selected by default).
|
||||
const dropRow = wrap.find('[data-test="io-import-row-wc_drop"]');
|
||||
expect(dropRow.exists()).toBe(true);
|
||||
await dropRow.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
// Hit the import button.
|
||||
const submit = wrap.find('[data-test="io-import-submit"]');
|
||||
expect(submit.exists()).toBe(true);
|
||||
await submit.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(apiAny.importBundle).toHaveBeenCalledTimes(1);
|
||||
const sent = apiAny.importBundle.mock.calls[0]![0];
|
||||
expect(sent.version).toBe(1);
|
||||
expect(sent.modules.map((m: ModuleRow) => m.id)).toEqual(["wc_keep"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFilteredBundle,
|
||||
bundleSizeBytes,
|
||||
canonicalJson,
|
||||
classifyCategory,
|
||||
classifyModule,
|
||||
formatBytes,
|
||||
GROUPS,
|
||||
hashPayload,
|
||||
presetFavoritesOnly,
|
||||
presetFull,
|
||||
presetWildcardsOnly,
|
||||
selectionCounts,
|
||||
selectionKey,
|
||||
} from "../utils/bundleSelection";
|
||||
import type { CategoryRow, ModuleRow } from "../api/types";
|
||||
|
||||
function mkModule(over: Partial<ModuleRow>): ModuleRow {
|
||||
return {
|
||||
id: "wc_aaaa1111",
|
||||
type: "wildcard",
|
||||
name: "Sample",
|
||||
description: "",
|
||||
category_id: null,
|
||||
tags: [],
|
||||
is_favorite: false,
|
||||
payload: {},
|
||||
version: 1,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mkCategory(over: Partial<CategoryRow>): CategoryRow {
|
||||
return {
|
||||
id: "cat_a",
|
||||
name: "Style",
|
||||
color: "#a78bfa",
|
||||
icon: null,
|
||||
sort_order: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bundleSelection — selection key", () => {
|
||||
it("formats `<group>:<id>`", () => {
|
||||
expect(selectionKey("wildcard", "wc_x")).toBe("wildcard:wc_x");
|
||||
expect(selectionKey("category", "cat_a")).toBe("category:cat_a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleSelection — GROUPS", () => {
|
||||
it("includes a category pseudo-group with type=null", () => {
|
||||
const cat = GROUPS.find((g) => g.key === "category");
|
||||
expect(cat).toBeDefined();
|
||||
expect(cat?.type).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleSelection — buildFilteredBundle", () => {
|
||||
const modules = [
|
||||
mkModule({ id: "wc_a", type: "wildcard", category_id: "cat_a" }),
|
||||
mkModule({ id: "fv_b", type: "fixed_values", category_id: "cat_b" }),
|
||||
mkModule({ id: "cb_c", type: "combine", category_id: null }),
|
||||
];
|
||||
const categories = [
|
||||
mkCategory({ id: "cat_a", name: "Style" }),
|
||||
mkCategory({ id: "cat_b", name: "Lighting" }),
|
||||
mkCategory({ id: "cat_z", name: "Unused" }),
|
||||
];
|
||||
|
||||
it("filters modules by selection set", () => {
|
||||
const sel = new Set<string>([
|
||||
selectionKey("wildcard", "wc_a"),
|
||||
selectionKey("combine", "cb_c"),
|
||||
]);
|
||||
const bundle = buildFilteredBundle({ modules, categories, selected: sel });
|
||||
expect(bundle.modules.map((m) => m.id).sort()).toEqual(["cb_c", "wc_a"]);
|
||||
});
|
||||
|
||||
it("auto-includes referenced categories even when not explicitly checked", () => {
|
||||
const sel = new Set<string>([selectionKey("wildcard", "wc_a")]);
|
||||
const bundle = buildFilteredBundle({ modules, categories, selected: sel });
|
||||
expect(bundle.categories.map((c) => c.id)).toEqual(["cat_a"]);
|
||||
});
|
||||
|
||||
it("respects explicit category checks even with no module references", () => {
|
||||
const sel = new Set<string>([selectionKey("category", "cat_z")]);
|
||||
const bundle = buildFilteredBundle({ modules, categories, selected: sel });
|
||||
expect(bundle.categories.map((c) => c.id)).toEqual(["cat_z"]);
|
||||
expect(bundle.modules).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("merges referenced + explicitly checked categories without duplicates", () => {
|
||||
const sel = new Set<string>([
|
||||
selectionKey("wildcard", "wc_a"), // refs cat_a
|
||||
selectionKey("category", "cat_a"), // explicit
|
||||
selectionKey("category", "cat_z"), // explicit
|
||||
]);
|
||||
const bundle = buildFilteredBundle({ modules, categories, selected: sel });
|
||||
expect(bundle.categories.map((c) => c.id).sort()).toEqual(["cat_a", "cat_z"]);
|
||||
});
|
||||
|
||||
it("sets bundle version=1 and includes exported_at timestamp", () => {
|
||||
const bundle = buildFilteredBundle({
|
||||
modules: [], categories: [], selected: new Set(),
|
||||
});
|
||||
expect(bundle.version).toBe(1);
|
||||
expect(typeof bundle.exported_at).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleSelection — bundleSizeBytes", () => {
|
||||
it("matches JSON.stringify(...).length", () => {
|
||||
const bundle = buildFilteredBundle({
|
||||
modules: [mkModule({ id: "wc_a" })],
|
||||
categories: [mkCategory({ id: "cat_a" })],
|
||||
selected: new Set([
|
||||
selectionKey("wildcard", "wc_a"),
|
||||
selectionKey("category", "cat_a"),
|
||||
]),
|
||||
});
|
||||
expect(bundleSizeBytes(bundle)).toBe(JSON.stringify(bundle).length);
|
||||
});
|
||||
|
||||
it("formatBytes produces human-readable strings", () => {
|
||||
expect(formatBytes(123)).toBe("123 B");
|
||||
expect(formatBytes(2048)).toBe("2.0 KB");
|
||||
expect(formatBytes(1024 * 1024 * 3)).toBe("3.00 MB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleSelection — presets", () => {
|
||||
const modules = [
|
||||
mkModule({ id: "wc_a", type: "wildcard", is_favorite: true }),
|
||||
mkModule({ id: "wc_b", type: "wildcard", is_favorite: false }),
|
||||
mkModule({ id: "fv_a", type: "fixed_values", is_favorite: true }),
|
||||
mkModule({ id: "cb_a", type: "combine", is_favorite: false }),
|
||||
];
|
||||
const categories = [mkCategory({ id: "cat_a" })];
|
||||
|
||||
it("Full library — selects every module + every category", () => {
|
||||
const sel = presetFull(modules, categories);
|
||||
expect(sel.size).toBe(modules.length + categories.length);
|
||||
expect(sel.has("wildcard:wc_a")).toBe(true);
|
||||
expect(sel.has("category:cat_a")).toBe(true);
|
||||
});
|
||||
|
||||
it("Wildcards only — selects exactly wildcards (no fixed_values/combines)", () => {
|
||||
const sel = presetWildcardsOnly(modules);
|
||||
expect(Array.from(sel).sort()).toEqual(["wildcard:wc_a", "wildcard:wc_b"]);
|
||||
});
|
||||
|
||||
it("Favorites only — selects every favorited module across kinds", () => {
|
||||
const sel = presetFavoritesOnly(modules);
|
||||
expect(Array.from(sel).sort()).toEqual([
|
||||
"fixed_values:fv_a", "wildcard:wc_a",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleSelection — selectionCounts", () => {
|
||||
it("counts per group + total", () => {
|
||||
const sel = new Set([
|
||||
"wildcard:wc_a", "wildcard:wc_b",
|
||||
"fixed_values:fv_a",
|
||||
"category:cat_a",
|
||||
]);
|
||||
const c = selectionCounts(sel);
|
||||
expect(c.wildcard).toBe(2);
|
||||
expect(c.fixed_values).toBe(1);
|
||||
expect(c.combine).toBe(0);
|
||||
expect(c.category).toBe(1);
|
||||
expect(c.total).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleSelection — canonicalJson + hashPayload", () => {
|
||||
it("canonicalJson is order-insensitive", () => {
|
||||
const a = canonicalJson({ b: 1, a: 2 });
|
||||
const b = canonicalJson({ a: 2, b: 1 });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("hashPayload differs when payloads differ", () => {
|
||||
expect(hashPayload({ a: 1 })).not.toBe(hashPayload({ a: 2 }));
|
||||
});
|
||||
|
||||
it("hashPayload is stable across key reorderings", () => {
|
||||
expect(hashPayload({ a: 1, b: 2 })).toBe(hashPayload({ b: 2, a: 1 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleSelection — classifyModule", () => {
|
||||
it("returns `new` when id is unknown locally", () => {
|
||||
const local = new Map<string, ModuleRow>();
|
||||
const incoming = mkModule({ id: "wc_new", payload: { x: 1 } });
|
||||
expect(classifyModule(incoming, local).kind).toBe("new");
|
||||
});
|
||||
|
||||
it("returns `exists` when payload is identical", () => {
|
||||
const existing = mkModule({ id: "wc_a", payload: { foo: 1 } });
|
||||
const local = new Map<string, ModuleRow>([[existing.id, existing]]);
|
||||
const incoming = mkModule({ id: "wc_a", payload: { foo: 1 } });
|
||||
const r = classifyModule(incoming, local);
|
||||
expect(r.kind).toBe("exists");
|
||||
expect(r.existingId).toBe("wc_a");
|
||||
});
|
||||
|
||||
it("returns `modified` when id matches but payload diverges", () => {
|
||||
const existing = mkModule({ id: "wc_a", payload: { foo: 1 } });
|
||||
const local = new Map<string, ModuleRow>([[existing.id, existing]]);
|
||||
const incoming = mkModule({ id: "wc_a", payload: { foo: 2 } });
|
||||
const r = classifyModule(incoming, local);
|
||||
expect(r.kind).toBe("modified");
|
||||
expect(r.existingId).toBe("wc_a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleSelection — classifyCategory", () => {
|
||||
it("returns `new` when id is unknown locally", () => {
|
||||
const r = classifyCategory(mkCategory({ id: "cat_z" }), new Map());
|
||||
expect(r.kind).toBe("new");
|
||||
});
|
||||
|
||||
it("returns `modified` when name or color differs", () => {
|
||||
const existing = mkCategory({ id: "cat_a", color: "#aaaaaa" });
|
||||
const local = new Map([[existing.id, existing]]);
|
||||
const r = classifyCategory(mkCategory({ id: "cat_a", color: "#bbbbbb" }), local);
|
||||
expect(r.kind).toBe("modified");
|
||||
});
|
||||
});
|
||||
@@ -118,7 +118,13 @@ export const api = {
|
||||
exportBundle() {
|
||||
return request<ImportBundle>("/wp/api/export", { method: "GET" });
|
||||
},
|
||||
importBundle(bundle: ImportBundle) {
|
||||
/**
|
||||
* POST a bundle to /wp/api/import. The backend processes only what's in
|
||||
* the payload, so a "partial" bundle (subset of modules / categories) is a
|
||||
* valid input — the import-export UI relies on this to ship per-item
|
||||
* selections without inventing a new endpoint.
|
||||
*/
|
||||
importBundle(bundle: ImportBundle | Partial<ImportBundle> & { version: 1 }) {
|
||||
return request<ImportResult>("/wp/api/import", {
|
||||
method: "POST", body: JSON.stringify(bundle),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Pure helpers for the Import / Export selection UI.
|
||||
*
|
||||
* All functions here are deterministic and free of DOM / network dependencies
|
||||
* so they can be unit-tested under vanilla Vitest. The view layer composes
|
||||
* them on top of reactive selection state.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CategoryRow,
|
||||
ImportBundle,
|
||||
ModuleRow,
|
||||
ModuleType,
|
||||
} from "../api/types";
|
||||
|
||||
/** Selection group keys ↔ module types (categories are a pseudo-group). */
|
||||
export type GroupKey =
|
||||
| "wildcard"
|
||||
| "fixed_values"
|
||||
| "combine"
|
||||
| "derivation"
|
||||
| "constraint"
|
||||
| "pipeline"
|
||||
| "category";
|
||||
|
||||
export interface GroupMeta {
|
||||
key: GroupKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
/** Module type this group represents — `null` for the categories group. */
|
||||
type: ModuleType | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display order mirrors the prototype's MODULE_GROUPS in
|
||||
* docs/design-handoff/wildcardpipeline/project/screens/utilities.jsx.
|
||||
*/
|
||||
export const GROUPS: GroupMeta[] = [
|
||||
{ key: "wildcard", label: "Wildcards", icon: "pi pi-th-large", color: "var(--wp-kind-wildcard)", type: "wildcard" },
|
||||
{ key: "fixed_values", label: "Fixed Values", icon: "pi pi-tag", color: "var(--wp-kind-fixed)", type: "fixed_values" },
|
||||
{ key: "combine", label: "Combines", icon: "pi pi-share-alt", color: "var(--wp-kind-combine)", type: "combine" },
|
||||
{ key: "derivation", label: "Derivations", icon: "pi pi-code", color: "var(--wp-kind-derivation)", type: "derivation" },
|
||||
{ key: "constraint", label: "Constraints", icon: "pi pi-sitemap", color: "var(--wp-kind-constraint)", type: "constraint" },
|
||||
{ key: "pipeline", label: "Pipelines", icon: "pi pi-list", color: "var(--wp-kind-pipeline)", type: "pipeline" },
|
||||
{ key: "category", label: "Categories", icon: "pi pi-bookmark", color: "var(--wp-kind-category, #60a5fa)", type: null },
|
||||
];
|
||||
|
||||
/** Stable selection key — `<group>:<id>`. */
|
||||
export function selectionKey(group: GroupKey, id: string): string {
|
||||
return `${group}:${id}`;
|
||||
}
|
||||
|
||||
/** Map a module's type to its selection group. */
|
||||
export function groupForModule(type: ModuleType): GroupKey {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable canonical JSON for a payload — sorts keys recursively. Used to
|
||||
* compute a quick payload hash so import can flag rows where the id matches
|
||||
* but the payload diverged (the "modified" badge).
|
||||
*/
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(sortKeys(value));
|
||||
}
|
||||
|
||||
function sortKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortKeys);
|
||||
if (value && typeof value === "object") {
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
sorted[k] = sortKeys((value as Record<string, unknown>)[k]);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap, deterministic 32-bit FNV-1a hash of an arbitrary string. Returned
|
||||
* as 8 hex chars. Used purely for change-detection — never security.
|
||||
*/
|
||||
export function quickHash(input: string): string {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
h ^= input.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/** Hash of a module's payload — stable across reorders. */
|
||||
export function hashPayload(payload: Record<string, unknown> | undefined): string {
|
||||
return quickHash(canonicalJson(payload ?? {}));
|
||||
}
|
||||
|
||||
export interface FilteredBundleInput {
|
||||
modules: ModuleRow[];
|
||||
categories: CategoryRow[];
|
||||
/** Selection key set — see `selectionKey()`. */
|
||||
selected: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a partial ImportBundle from the source library + a selection set.
|
||||
*
|
||||
* Modules are filtered by their per-row checkbox. Categories are kept if:
|
||||
* - they are explicitly checked in the categories group, OR
|
||||
* - they are referenced by any selected module (`category_id` match).
|
||||
*
|
||||
* The second rule guarantees the imported bundle is self-consistent: every
|
||||
* module that mentions a category brings that category along, even when the
|
||||
* user only ticked module rows.
|
||||
*/
|
||||
export function buildFilteredBundle(input: FilteredBundleInput): ImportBundle {
|
||||
const { modules, categories, selected } = input;
|
||||
|
||||
const pickedModules = modules.filter((m) =>
|
||||
selected.has(selectionKey(groupForModule(m.type), m.id)),
|
||||
);
|
||||
|
||||
const referenced = new Set<string>();
|
||||
for (const m of pickedModules) {
|
||||
if (m.category_id) referenced.add(m.category_id);
|
||||
}
|
||||
|
||||
const pickedCategories = categories.filter(
|
||||
(c) => selected.has(selectionKey("category", c.id)) || referenced.has(c.id),
|
||||
);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
modules: pickedModules,
|
||||
categories: pickedCategories,
|
||||
};
|
||||
}
|
||||
|
||||
/** Approximate bundle size in bytes — `JSON.stringify(bundle).length`. */
|
||||
export function bundleSizeBytes(bundle: ImportBundle): number {
|
||||
return JSON.stringify(bundle).length;
|
||||
}
|
||||
|
||||
/** Human-friendly bytes — "1.2 KB", "340 B", "2.4 MB". */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
return `${(kb / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
/** Per-group counts of selected items, plus total. */
|
||||
export function selectionCounts(
|
||||
selected: Set<string>,
|
||||
): Record<GroupKey, number> & { total: number } {
|
||||
const counts: Record<GroupKey, number> & { total: number } = {
|
||||
wildcard: 0, fixed_values: 0, combine: 0,
|
||||
derivation: 0, constraint: 0, pipeline: 0,
|
||||
category: 0, total: 0,
|
||||
};
|
||||
for (const key of selected) {
|
||||
const idx = key.indexOf(":");
|
||||
if (idx <= 0) continue;
|
||||
const g = key.slice(0, idx) as GroupKey;
|
||||
if (g in counts) {
|
||||
counts[g] += 1;
|
||||
counts.total += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// ---------- Presets ----------
|
||||
|
||||
/**
|
||||
* "Full library" — every module + every category.
|
||||
*/
|
||||
export function presetFull(
|
||||
modules: ModuleRow[], categories: CategoryRow[],
|
||||
): Set<string> {
|
||||
const s = new Set<string>();
|
||||
for (const m of modules) s.add(selectionKey(groupForModule(m.type), m.id));
|
||||
for (const c of categories) s.add(selectionKey("category", c.id));
|
||||
return s;
|
||||
}
|
||||
|
||||
/** "Wildcards only" — every wildcard module, nothing else. */
|
||||
export function presetWildcardsOnly(modules: ModuleRow[]): Set<string> {
|
||||
const s = new Set<string>();
|
||||
for (const m of modules) {
|
||||
if (m.type === "wildcard") s.add(selectionKey("wildcard", m.id));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** "Favorites only" — every favorited module across all kinds. */
|
||||
export function presetFavoritesOnly(modules: ModuleRow[]): Set<string> {
|
||||
const s = new Set<string>();
|
||||
for (const m of modules) {
|
||||
if (m.is_favorite) s.add(selectionKey(groupForModule(m.type), m.id));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- Import-side conflict detection ----------
|
||||
|
||||
export type ConflictKind = "new" | "exists" | "modified";
|
||||
|
||||
export interface ConflictResult {
|
||||
kind: ConflictKind;
|
||||
/** Existing local module id, when the incoming row clashes with one. */
|
||||
existingId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an incoming module row against the current local library.
|
||||
*
|
||||
* - `new` — id not present locally
|
||||
* - `exists` — id matches and payload looks identical
|
||||
* - `modified` — id matches but payload diverged (different shape)
|
||||
*/
|
||||
export function classifyModule(
|
||||
incoming: ModuleRow,
|
||||
localById: Map<string, ModuleRow>,
|
||||
): ConflictResult {
|
||||
const local = localById.get(incoming.id);
|
||||
if (!local) return { kind: "new" };
|
||||
const a = hashPayload(local.payload);
|
||||
const b = hashPayload(incoming.payload);
|
||||
if (a === b) return { kind: "exists", existingId: local.id };
|
||||
return { kind: "modified", existingId: local.id };
|
||||
}
|
||||
|
||||
export function classifyCategory(
|
||||
incoming: CategoryRow,
|
||||
localById: Map<string, CategoryRow>,
|
||||
): ConflictResult {
|
||||
const local = localById.get(incoming.id);
|
||||
if (!local) return { kind: "new" };
|
||||
if (local.name === incoming.name && local.color === incoming.color) {
|
||||
return { kind: "exists", existingId: local.id };
|
||||
}
|
||||
return { kind: "modified", existingId: local.id };
|
||||
}
|
||||
+1168
-41
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/wp/images/site.webmanifest" />
|
||||
<script type="module" crossorigin src="/wp/assets/manager-BLJAO7d9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/wp/assets/manager-B7C8brQ3.css">
|
||||
<script type="module" crossorigin src="/wp/assets/manager-Z-SdbdKR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/wp/assets/manager-CMgfxADo.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user