feat(spa): per-module version history with last-3 snapshots and restore panel

Each editor now captures the pre-save state into a `history` sidecar inside
`payload` (max 3 entries, oldest dropped) on every save of an existing
module. A "History (N)" button appears in the editor footer when entries
exist; clicking it opens a slide-out panel with timestamped snapshots, a
JSON preview, and a per-snapshot restore that hydrates the form from the
captured state.

The sidecar lives inside the payload itself so it round-trips through the
existing API without backend schema changes — engine handlers all read via
`payload.get(...)` and ignore the extra `history` key.
This commit is contained in:
DumiFlex
2026-04-27 07:31:53 +03:00
parent f1add58631
commit 678a0eb403
13 changed files with 1146 additions and 51 deletions
@@ -0,0 +1,85 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import PrimeVue from "primevue/config";
import HistoryPanel from "../components/HistoryPanel.vue";
import type { ModuleHistoryEntry } from "../api/types";
function makeEntry(overrides: Partial<ModuleHistoryEntry> = {}): ModuleHistoryEntry {
return {
saved_at: "2025-04-26T12:00:00Z",
name: "snapshot",
description: "",
category_id: null,
tags: [],
payload: { foo: 1 },
...overrides,
};
}
describe("HistoryPanel.vue", () => {
it("does not render when closed", () => {
const wrap = mount(HistoryPanel, {
props: { open: false, entries: [] },
global: { plugins: [PrimeVue] },
attachTo: document.body,
});
// Teleport puts the contents on body when open=true; closed = nothing.
expect(document.body.querySelector('[data-test="history-panel"]')).toBeNull();
wrap.unmount();
});
it("renders empty state when no entries", async () => {
const wrap = mount(HistoryPanel, {
props: { open: true, entries: [] },
global: { plugins: [PrimeVue] },
attachTo: document.body,
});
const empty = document.body.querySelector('[data-test="history-empty"]');
expect(empty).not.toBeNull();
expect(empty?.textContent ?? "").toMatch(/No previous versions/);
wrap.unmount();
});
it("renders entries newest-first and emits restore on click", async () => {
const entries: ModuleHistoryEntry[] = [
makeEntry({ name: "older", saved_at: "2025-04-20T00:00:00Z" }),
makeEntry({ name: "newer", saved_at: "2025-04-26T12:00:00Z" }),
];
const wrap = mount(HistoryPanel, {
props: { open: true, entries },
global: { plugins: [PrimeVue] },
attachTo: document.body,
});
const list = document.body.querySelector('[data-test="history-list"]');
expect(list).not.toBeNull();
const items = document.body.querySelectorAll('[data-test^="history-entry-"]');
expect(items).toHaveLength(2);
// First displayed item is newest.
expect(items[0].textContent ?? "").toContain("newer");
expect(items[1].textContent ?? "").toContain("older");
const restoreBtn = document.body.querySelector(
'[data-test="history-restore-0"]',
) as HTMLElement | null;
expect(restoreBtn).not.toBeNull();
restoreBtn?.click();
const restoreEvents = wrap.emitted("restore");
expect(restoreEvents).toBeTruthy();
expect((restoreEvents?.[0]?.[0] as ModuleHistoryEntry).name).toBe("newer");
wrap.unmount();
});
it("emits update:open=false when close button clicked", async () => {
const wrap = mount(HistoryPanel, {
props: { open: true, entries: [makeEntry()] },
global: { plugins: [PrimeVue] },
attachTo: document.body,
});
const closeBtn = document.body.querySelector(
'[data-test="history-close"]',
) as HTMLElement | null;
closeBtn?.click();
expect(wrap.emitted("update:open")?.[0]).toEqual([false]);
wrap.unmount();
});
});
+100
View File
@@ -187,4 +187,104 @@ describe("WildcardForm.vue", () => {
.element as HTMLInputElement;
expect(varInput.value).toBe("my_hair");
});
it("shows History (N) when payload has history entries and restores on click", async () => {
apiMod.get.mockResolvedValue({
id: "wc_a", name: "alpha", description: "", category_id: null,
tags: [], type: "wildcard",
payload: {
options: [{ id: "o1", value: "blue", weight: 1 }],
sub_categories: [],
var_binding: "alpha",
history: [
{
saved_at: "2025-04-26T00:00:00Z",
name: "older-name",
description: "older",
category_id: null,
tags: [],
payload: {
options: [{ id: "o0", value: "red", weight: 1 }],
sub_categories: [],
var_binding: "older_name",
},
},
{
saved_at: "2025-04-26T12:00:00Z",
name: "previous-name",
description: "prev",
category_id: null,
tags: [],
payload: {
options: [{ id: "o0a", value: "green", weight: 1 }],
sub_categories: [],
var_binding: "previous_name",
},
},
],
},
version: 1, created_at: "", updated_at: "", is_favorite: false,
});
const wrap = mount(WildcardForm, {
props: { id: "wc_a" },
attachTo: document.body,
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
// Button shows count.
const historyBtn = wrap.find('[data-test="history-btn"]');
expect(historyBtn.exists()).toBe(true);
expect(historyBtn.text()).toContain("History (2)");
// Open panel.
await historyBtn.trigger("click");
await flushPromises();
expect(document.body.querySelector('[data-test="history-panel"]')).not.toBeNull();
// Newest entry is index 0.
const restoreBtn = document.body.querySelector(
'[data-test="history-restore-0"]',
) as HTMLElement | null;
expect(restoreBtn).not.toBeNull();
restoreBtn?.click();
await flushPromises();
// Name input now reflects the restored snapshot's name.
const nameInput = wrap.find("#wc-name").element as HTMLInputElement;
expect(nameInput.value).toBe("previous-name");
wrap.unmount();
});
it("save on existing module appends to history sidecar", async () => {
apiMod.get.mockResolvedValue({
id: "wc_a", name: "alpha", description: "", category_id: null,
tags: [], type: "wildcard",
payload: { options: [], sub_categories: [], var_binding: "alpha" },
version: 1, created_at: "", updated_at: "", is_favorite: false,
});
apiMod.update.mockImplementation((_id: string, body: { payload: Record<string, unknown> }) => Promise.resolve({
id: "wc_a", type: "wildcard", name: "alpha2",
description: "", category_id: null, tags: [], is_favorite: false,
payload: body.payload, version: 2, created_at: "", updated_at: "",
}));
const wrap = mount(WildcardForm, {
props: { id: "wc_a" },
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#wc-name");
await nameInput.setValue("alpha2");
await flushPromises();
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
// 1st call = initial get on mount; 2nd call = pre-save snapshot fetch.
expect(apiMod.get).toHaveBeenCalledTimes(2);
const upd = apiMod.update.mock.calls[0]?.[1] as { payload: { history?: unknown[] } };
expect(Array.isArray(upd.payload.history)).toBe(true);
expect(upd.payload.history?.length).toBe(1);
wrap.unmount();
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import {
HISTORY_MAX,
appendSnapshot,
readHistory,
stripHistory,
} from "../utils/history";
import type { ModuleHistoryEntry } from "../api/types";
const baseRecord = (overrides: Partial<{ name: string; description: string; tags: string[] }> = {}) => ({
name: "alpha",
description: "",
category_id: null,
tags: [],
...overrides,
});
describe("readHistory", () => {
it("returns [] when payload is missing or wrong shape", () => {
expect(readHistory(undefined)).toEqual([]);
expect(readHistory(null)).toEqual([]);
expect(readHistory("nope")).toEqual([]);
expect(readHistory({})).toEqual([]);
});
it("returns [] when history key is not an array", () => {
expect(readHistory({ history: "string" })).toEqual([]);
expect(readHistory({ history: 42 })).toEqual([]);
});
it("filters out malformed entries", () => {
const valid: ModuleHistoryEntry = {
saved_at: "2025-01-01T00:00:00Z",
name: "ok",
payload: {},
};
const out = readHistory({
history: [valid, null, { saved_at: 1, name: "bad" }, { name: "no-ts", payload: {} }],
});
expect(out).toEqual([valid]);
});
it("returns the entries when shape is valid", () => {
const entries: ModuleHistoryEntry[] = [
{ saved_at: "2025-01-01T00:00:00Z", name: "v1", payload: { foo: 1 } },
{ saved_at: "2025-01-02T00:00:00Z", name: "v2", payload: { foo: 2 } },
];
expect(readHistory({ history: entries })).toEqual(entries);
});
});
describe("stripHistory", () => {
it("removes the history key without mutating the input", () => {
const original = { foo: 1, bar: "x", history: [{ a: 1 }] };
const out = stripHistory(original);
expect(out).toEqual({ foo: 1, bar: "x" });
// Input is untouched.
expect("history" in original).toBe(true);
});
it("returns an empty object for nullish/non-object input", () => {
expect(stripHistory(undefined as unknown as Record<string, unknown>)).toEqual({});
expect(stripHistory(null as unknown as Record<string, unknown>)).toEqual({});
});
it("preserves untouched payloads", () => {
expect(stripHistory({ a: 1, b: [1, 2] })).toEqual({ a: 1, b: [1, 2] });
});
});
describe("appendSnapshot", () => {
it("adds a new entry when no prior history exists", () => {
const next = appendSnapshot(baseRecord({ name: "v1" }), { foo: 1 });
expect(next).toHaveLength(1);
expect(next[0].name).toBe("v1");
expect(next[0].payload).toEqual({ foo: 1 });
expect(next[0].saved_at).toMatch(/^\d{4}-/);
});
it("preserves prior entries", () => {
const earlier: ModuleHistoryEntry = {
saved_at: "2024-01-01T00:00:00Z",
name: "older",
payload: { foo: 0 },
};
const oldPayload: Record<string, unknown> = { foo: 1, history: [earlier] };
const next = appendSnapshot(baseRecord({ name: "v2" }), oldPayload);
expect(next).toHaveLength(2);
expect(next[0]).toBe(earlier);
expect(next[1].name).toBe("v2");
});
it("trims to HISTORY_MAX (oldest dropped) after 4 saves", () => {
expect(HISTORY_MAX).toBe(3);
// Save 1: payload starts with no history.
let payload: Record<string, unknown> = { v: 1 };
let history = appendSnapshot(baseRecord({ name: "v1" }), payload);
payload = { v: 2, history };
// Save 2.
history = appendSnapshot(baseRecord({ name: "v2" }), payload);
payload = { v: 3, history };
// Save 3.
history = appendSnapshot(baseRecord({ name: "v3" }), payload);
payload = { v: 4, history };
// Save 4 — pre-save record was "v4" with v=4, going to "v5".
history = appendSnapshot(baseRecord({ name: "v4" }), payload);
expect(history).toHaveLength(HISTORY_MAX);
expect(history.map((h) => h.name)).toEqual(["v2", "v3", "v4"]);
});
it("strips history from the snapshot's stored payload (no nesting)", () => {
const earlier: ModuleHistoryEntry = {
saved_at: "2024-01-01T00:00:00Z",
name: "older",
payload: { foo: 0 },
};
const oldPayload: Record<string, unknown> = { foo: 1, history: [earlier] };
const recordPayload: Record<string, unknown> = { foo: 1, history: [earlier] };
const next = appendSnapshot(
{ ...baseRecord({ name: "v2" }), payload: recordPayload },
oldPayload,
);
const snap = next[next.length - 1];
expect("history" in snap.payload).toBe(false);
expect(snap.payload).toEqual({ foo: 1 });
});
it("clones tags so later mutation doesn't reach the snapshot", () => {
const tags = ["a", "b"];
const next = appendSnapshot(
baseRecord({ tags }),
{},
);
tags.push("c");
expect(next[0].tags).toEqual(["a", "b"]);
});
});
+17
View File
@@ -119,6 +119,23 @@ export interface ModuleRow {
updated_at: string;
}
/**
* Per-module version-history entry. Stored as a sidecar inside `payload.history`
* (max 3 entries). Soft contract — we never add `history` to the typed payload
* shapes themselves; helpers in `utils/history.ts` access it via runtime checks.
*
* `payload` here is the snapshotted module payload **with the `history` key
* stripped** so saved snapshots never recurse.
*/
export interface ModuleHistoryEntry {
saved_at: string;
name: string;
description?: string;
category_id?: string | null;
tags?: string[];
payload: Record<string, unknown>;
}
export interface ModuleListResponse {
items: ModuleRow[];
total: number;
+276
View File
@@ -0,0 +1,276 @@
<script setup lang="ts">
/**
* HistoryPanel
*
* Slide-out drawer that lists the last 3 saved snapshots of a module.
* Each entry shows a relative timestamp, the snapshotted name, a JSON
* preview of the snapshot's payload, and a "Restore" button.
*
* Click outside / Esc closes (handled by the local overlay click + window
* keydown listener).
*
* The panel is layout-agnostic — it doesn't know what kind of module owns
* the history; the parent editor wires `entries` and decides what to do
* with the `restore` event.
*/
import { computed, onMounted, onBeforeUnmount, watch } from "vue";
import Button from "primevue/button";
import RelativeDate from "./RelativeDate.vue";
import type { ModuleHistoryEntry } from "../api/types";
const props = defineProps<{
open: boolean;
entries: ModuleHistoryEntry[];
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
(e: "restore", entry: ModuleHistoryEntry): void;
}>();
// Newest first — the sidecar stores oldest-first; reverse for display.
const ordered = computed<ModuleHistoryEntry[]>(() =>
[...props.entries].reverse(),
);
function close(): void {
emit("update:open", false);
}
function restore(entry: ModuleHistoryEntry): void {
emit("restore", entry);
}
function previewJson(entry: ModuleHistoryEntry): string {
return JSON.stringify(
{
name: entry.name,
description: entry.description ?? "",
category_id: entry.category_id ?? null,
tags: entry.tags ?? [],
payload: entry.payload,
},
null,
2,
);
}
function onKey(e: KeyboardEvent): void {
if (e.key === "Escape" && props.open) close();
}
onMounted(() => {
window.addEventListener("keydown", onKey);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKey);
});
// Keep body from scrolling under the open panel — small UX nicety.
watch(
() => props.open,
(next) => {
if (typeof document === "undefined") return;
document.body.style.overflow = next ? "hidden" : "";
},
);
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="wp-history-overlay"
data-test="history-overlay"
@mousedown.self="close"
>
<aside
class="wp-history-panel"
role="dialog"
aria-label="Version history"
data-test="history-panel"
>
<header class="wp-history-panel__header">
<div>
<h3 class="wp-history-panel__title">Version history</h3>
<p class="wp-history-panel__sub">
Last {{ entries.length }} save{{ entries.length === 1 ? "" : "s" }} ·
oldest is overwritten on save
</p>
</div>
<Button
icon="pi pi-times"
severity="secondary"
text
rounded
aria-label="Close history"
data-test="history-close"
@click="close"
/>
</header>
<div v-if="!ordered.length" class="wp-history-empty" data-test="history-empty">
<i class="pi pi-history" />
<p>No previous versions yet.</p>
<p class="wp-history-empty__hint">
Save a change to start tracking history.
</p>
</div>
<div v-else class="wp-history-panel__body">
<ol class="wp-history-list" data-test="history-list">
<li
v-for="(entry, idx) in ordered"
:key="`${entry.saved_at}-${idx}`"
class="wp-history-item"
:data-test="`history-entry-${idx}`"
>
<div class="wp-history-item__head">
<div>
<div class="wp-history-item__name">
{{ entry.name || "(untitled)" }}
</div>
<div class="wp-history-item__meta">
<RelativeDate :value="entry.saved_at" />
<span class="wp-history-item__sep">·</span>
<span :title="entry.saved_at">{{ entry.saved_at }}</span>
</div>
</div>
<Button
label="Restore this version"
icon="pi pi-refresh"
severity="primary"
size="small"
:data-test="`history-restore-${idx}`"
@click="restore(entry)"
/>
</div>
<pre class="wp-history-item__json">{{ previewJson(entry) }}</pre>
</li>
</ol>
</div>
</aside>
</div>
</Teleport>
</template>
<style scoped>
.wp-history-overlay {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0, 0, 0, 0.45);
display: flex;
justify-content: flex-end;
}
.wp-history-panel {
width: 480px;
max-width: 100vw;
background: var(--wp-bg);
border-left: 1px solid var(--wp-border);
display: flex;
flex-direction: column;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.35);
}
.wp-history-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-bg2);
}
.wp-history-panel__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--wp-text);
}
.wp-history-panel__sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--wp-text2);
}
.wp-history-panel__body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.wp-history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.wp-history-item {
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
background: var(--wp-bg2);
padding: 10px 12px;
}
.wp-history-item__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.wp-history-item__name {
font-size: 13px;
font-weight: 600;
color: var(--wp-text);
word-break: break-word;
}
.wp-history-item__meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--wp-text2);
margin-top: 2px;
}
.wp-history-item__sep {
color: var(--wp-text3, #6e6e7c);
}
.wp-history-item__json {
margin: 0;
max-height: 240px;
overflow: auto;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 11.5px;
line-height: 1.4;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: 4px;
padding: 8px 10px;
color: var(--wp-text);
white-space: pre;
}
.wp-history-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 32px;
color: var(--wp-text2);
}
.wp-history-empty i {
font-size: 28px;
color: var(--wp-text3, #6e6e7c);
margin-bottom: 12px;
}
.wp-history-empty p {
margin: 0;
font-size: 13px;
}
.wp-history-empty__hint {
margin-top: 4px !important;
font-size: 12px;
color: var(--wp-text3, #6e6e7c);
}
</style>
+89
View File
@@ -0,0 +1,89 @@
/**
* Per-module version-history helpers.
*
* History is stored as a sidecar inside `payload.history`: an array of the
* last `HISTORY_MAX` saved snapshots, oldest first. The sidecar lives inside
* the payload itself (not on `ModuleRow`) so it round-trips cleanly through
* the existing API without backend schema changes — the engine handlers all
* use `payload.get(...)` so the extra key is ignored.
*
* On save, the editor:
* 1. Reads existing history from the *current* payload via `readHistory`.
* 2. Builds a snapshot of the *pre-save* record (name/desc/cat/tags/payload),
* passing the pre-save payload through `stripHistory` so snapshots never
* recurse.
* 3. Appends the snapshot via `appendSnapshot`, trimming to `HISTORY_MAX`.
* 4. Writes the new payload as `{ ...newPayload, history: nextHistory }`.
*/
import type { ModuleHistoryEntry } from "../api/types";
export const HISTORY_MAX = 3;
const HISTORY_KEY = "history";
export interface SnapshotInput {
name: string;
description?: string;
category_id?: string | null;
tags?: string[];
/** Pre-save payload — stored value comes from `oldPayload` argument; this
* field is accepted for API symmetry but is otherwise ignored. */
payload?: Record<string, unknown>;
}
/** Read the history sidecar from a module's payload. Returns `[]` if missing. */
export function readHistory(payload: unknown): ModuleHistoryEntry[] {
if (!payload || typeof payload !== "object") return [];
const raw = (payload as Record<string, unknown>)[HISTORY_KEY];
if (!Array.isArray(raw)) return [];
// Filter out malformed entries so a corrupt save can't poison the panel.
return raw.filter((e): e is ModuleHistoryEntry => {
if (!e || typeof e !== "object") return false;
const r = e as Record<string, unknown>;
return typeof r.saved_at === "string" && typeof r.name === "string";
});
}
/**
* Append a snapshot of the pre-save record, trimming to `HISTORY_MAX`.
*
* The new payload's `history` array is the OLD record's history plus the
* given snapshot, so saving N times preserves the last `HISTORY_MAX`
* versions (oldest dropped first).
*
* The snapshot's stored payload is run through `stripHistory` to avoid
* recursive nesting of history-in-history.
*/
export function appendSnapshot(
oldRecord: SnapshotInput,
oldPayload: Record<string, unknown>,
): ModuleHistoryEntry[] {
const prev = readHistory(oldPayload);
// The snapshot's stored payload comes from `oldPayload` (the pre-save
// payload), with `history` stripped so saved snapshots never recurse.
// `oldRecord.payload` is accepted for API compatibility but the canonical
// source of truth is `oldPayload` — the spec is `payload: stripHistory(oldPayload)`.
const snapshot: ModuleHistoryEntry = {
saved_at: new Date().toISOString(),
name: oldRecord.name,
description: oldRecord.description,
category_id: oldRecord.category_id ?? null,
tags: oldRecord.tags ? [...oldRecord.tags] : [],
payload: stripHistory(oldPayload),
};
const next = [...prev, snapshot];
// Keep last HISTORY_MAX (oldest dropped).
return next.length > HISTORY_MAX ? next.slice(next.length - HISTORY_MAX) : next;
}
/** Return a copy of `payload` with the `history` key removed. Pure. */
export function stripHistory(
payload: Record<string, unknown>,
): Record<string, unknown> {
if (!payload || typeof payload !== "object") return {};
// Object spread with destructuring drops `history` cleanly without mutating
// the input.
const { [HISTORY_KEY]: _omit, ...rest } = payload;
void _omit;
return rest;
}
+76 -10
View File
@@ -21,12 +21,15 @@ import AutoComplete from "primevue/autocomplete";
import { useToast } from "primevue/usetoast";
import RichTextInput from "../components/RichTextInput.vue";
import RichTextPreview from "../components/RichTextPreview.vue";
import HistoryPanel from "../components/HistoryPanel.vue";
import { tokenizeRich } from "../utils/richTokenize";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
import { appendSnapshot, readHistory } from "../utils/history";
import type {
CombinePayload,
ModuleHistoryEntry,
ModuleRow,
WildcardPayload,
WildcardOption,
@@ -50,6 +53,8 @@ const outputVarTouched = ref(false);
const outputVarError = ref("");
const saving = ref(false);
const isEdit = computed(() => !!props.id);
const historyEntries = ref<ModuleHistoryEntry[]>([]);
const historyOpen = ref(false);
const PLACEHOLDER = "$first_name, a $age-year-old with $hair_color hair";
@@ -255,6 +260,7 @@ onMounted(async () => {
} else {
outputVar.value = toIdentifier(row.name);
}
historyEntries.value = readHistory(row.payload);
} catch {
toast.add({ severity: "error", summary: "Combine not found", life: 3000 });
router.replace("/combines");
@@ -265,6 +271,30 @@ onMounted(async () => {
onBeforeUnmount(stopRerollTimer);
function applyRestore(entry: ModuleHistoryEntry): void {
name.value = entry.name;
description.value = entry.description ?? "";
categoryId.value = entry.category_id ?? null;
tags.value = entry.tags ? [...entry.tags] : [];
const p = (entry.payload ?? {}) as Partial<CombinePayload>;
template.value = p.template ?? "";
const o = (p.output_var ?? "").replace(/^\$+/, "");
if (o.trim()) {
outputVar.value = o;
outputVarTouched.value = true;
} else {
outputVar.value = toIdentifier(entry.name);
outputVarTouched.value = false;
}
historyOpen.value = false;
toast.add({
severity: "info",
summary: "Version restored",
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
life: 4000,
});
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
@@ -284,17 +314,36 @@ async function save() {
output_var: finalOutput,
input_vars: [...detected.value],
};
const body = {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: payload as unknown as Record<string, unknown>,
};
const newPayload = payload as unknown as Record<string, unknown>;
if (isEdit.value && props.id) {
await moduleStore.update(props.id, body);
const prev = await moduleStore.get(props.id);
const nextHistory = appendSnapshot(
{
name: prev.name,
description: prev.description,
category_id: prev.category_id,
tags: prev.tags,
payload: prev.payload as Record<string, unknown>,
},
prev.payload as Record<string, unknown>,
);
await moduleStore.update(props.id, {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: { ...newPayload, history: nextHistory },
});
historyEntries.value = nextHistory;
} else {
await moduleStore.create({ type: "combine", ...body });
await moduleStore.create({
type: "combine",
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: newPayload,
});
}
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
router.push("/combines");
@@ -465,9 +514,25 @@ async function save() {
</div>
<div class="form-page__footer">
<Button
v-if="historyEntries.length"
:label="`History (${historyEntries.length})`"
icon="pi pi-history"
severity="secondary"
outlined
data-test="history-btn"
@click="historyOpen = true"
/>
<div class="form-page__footer-spacer" />
<Button label="Cancel" severity="secondary" outlined @click="router.push('/combines')" />
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
</div>
<HistoryPanel
:open="historyOpen"
:entries="historyEntries"
@update:open="(v) => (historyOpen = v)"
@restore="applyRestore"
/>
</div>
</template>
@@ -480,8 +545,9 @@ async function save() {
background: var(--wp-bg);
border-top: 1px solid var(--wp-border);
padding: 12px 24px;
display: flex; gap: 8px; justify-content: flex-end;
display: flex; gap: 8px; align-items: center;
}
.form-page__footer-spacer { flex: 1; }
.form-section { margin-bottom: 24px; }
.form-section__label {
font-size: 11px; text-transform: uppercase;
+72 -11
View File
@@ -12,12 +12,15 @@ import { useToast } from "primevue/usetoast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import ConstraintMatrixGrid from "../components/ConstraintMatrix.vue";
import HistoryPanel from "../components/HistoryPanel.vue";
import { appendSnapshot, readHistory } from "../utils/history";
import type {
ConstraintCell,
ConstraintException,
ConstraintMatrix,
ConstraintMode,
ConstraintPayload,
ModuleHistoryEntry,
ModuleRow,
} from "../api/types";
@@ -43,6 +46,8 @@ const matrix = ref<ConstraintMatrix>({});
const exceptions = ref<ConstraintException[]>([]);
const saving = ref(false);
const isEdit = computed(() => !!props.id);
const historyEntries = ref<ModuleHistoryEntry[]>([]);
const historyOpen = ref(false);
const MODE_DEFAULT_FACTOR: Record<ConstraintMode, number> = {
allow: 1,
@@ -158,6 +163,7 @@ onMounted(async () => {
targetWildcardId.value = p.target_wildcard_id ?? null;
matrix.value = normalizeMatrix(p.matrix);
exceptions.value = normalizeExceptions(p.exceptions);
historyEntries.value = readHistory(row.payload);
} catch {
toast.add({ severity: "error", summary: "Constraint not found", life: 3000 });
router.replace("/constraints");
@@ -182,6 +188,25 @@ function setExceptionMode(idx: number, mode: ConstraintMode) {
ex.factor = MODE_DEFAULT_FACTOR[mode] ?? 1;
}
function applyRestore(entry: ModuleHistoryEntry): void {
name.value = entry.name;
description.value = entry.description ?? "";
categoryId.value = entry.category_id ?? null;
tags.value = entry.tags ? [...entry.tags] : [];
const p = (entry.payload ?? {}) as Partial<ConstraintPayload>;
sourceWildcardId.value = p.source_wildcard_id ?? null;
targetWildcardId.value = p.target_wildcard_id ?? null;
matrix.value = normalizeMatrix(p.matrix);
exceptions.value = normalizeExceptions(p.exceptions);
historyOpen.value = false;
toast.add({
severity: "info",
summary: "Version restored",
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
life: 4000,
});
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
@@ -203,17 +228,36 @@ async function save() {
matrix: matrix.value,
exceptions: exceptions.value,
};
const body = {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: payload as unknown as Record<string, unknown>,
};
const newPayload = payload as unknown as Record<string, unknown>;
if (isEdit.value && props.id) {
await moduleStore.update(props.id, body);
const prev = await moduleStore.get(props.id);
const nextHistory = appendSnapshot(
{
name: prev.name,
description: prev.description,
category_id: prev.category_id,
tags: prev.tags,
payload: prev.payload as Record<string, unknown>,
},
prev.payload as Record<string, unknown>,
);
await moduleStore.update(props.id, {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: { ...newPayload, history: nextHistory },
});
historyEntries.value = nextHistory;
} else {
await moduleStore.create({ type: "constraint", ...body });
await moduleStore.create({
type: "constraint",
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: newPayload,
});
}
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
router.push("/constraints");
@@ -224,7 +268,7 @@ async function save() {
}
}
defineExpose({ sourceWildcardId, targetWildcardId, matrix, exceptions });
defineExpose({ sourceWildcardId, targetWildcardId, matrix, exceptions, historyEntries, historyOpen, applyRestore });
</script>
<template>
@@ -457,6 +501,16 @@ defineExpose({ sourceWildcardId, targetWildcardId, matrix, exceptions });
<!-- E) Footer -->
<div class="form-page__footer">
<Button
v-if="historyEntries.length"
:label="`History (${historyEntries.length})`"
icon="pi pi-history"
severity="secondary"
outlined
data-test="history-btn"
@click="historyOpen = true"
/>
<div class="form-page__footer-spacer" />
<Button label="Cancel" severity="secondary" outlined @click="router.push('/constraints')" />
<Button
label="Save"
@@ -467,6 +521,12 @@ defineExpose({ sourceWildcardId, targetWildcardId, matrix, exceptions });
@click="save"
/>
</div>
<HistoryPanel
:open="historyOpen"
:entries="historyEntries"
@update:open="(v) => (historyOpen = v)"
@restore="applyRestore"
/>
</div>
</template>
@@ -479,8 +539,9 @@ defineExpose({ sourceWildcardId, targetWildcardId, matrix, exceptions });
background: var(--wp-bg);
border-top: 1px solid var(--wp-border);
padding: 12px 24px;
display: flex; gap: 8px; justify-content: flex-end;
display: flex; gap: 8px; align-items: center;
}
.form-page__footer-spacer { flex: 1; }
.form-section { margin-bottom: 24px; }
.form-section__label {
font-size: 11px; text-transform: uppercase;
+70 -11
View File
@@ -11,12 +11,15 @@ import { useToast } from "primevue/usetoast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import DerivationRuleCard from "../components/DerivationRuleCard.vue";
import HistoryPanel from "../components/HistoryPanel.vue";
import { appendSnapshot, readHistory } from "../utils/history";
import type {
DerivationAction,
DerivationBranch,
DerivationElse,
DerivationPayload,
DerivationRule,
ModuleHistoryEntry,
} from "../api/types";
const props = defineProps<{ id?: string }>();
@@ -32,6 +35,8 @@ const tags = ref<string[]>([]);
const rules = ref<DerivationRule[]>([]);
const saving = ref(false);
const isEdit = computed(() => !!props.id);
const historyEntries = ref<ModuleHistoryEntry[]>([]);
const historyOpen = ref(false);
// Variable suggestions are populated by the parent surface in the future. For
// now this is intentionally an empty list — the rule card falls back to a free
@@ -138,6 +143,7 @@ onMounted(async () => {
const p = row.payload as Partial<DerivationPayload>;
const incoming = Array.isArray(p.rules) ? p.rules : [];
rules.value = incoming.length ? incoming.map(migrateRule) : [];
historyEntries.value = readHistory(row.payload);
} catch {
toast.add({ severity: "error", summary: "Derivation not found", life: 3000 });
router.replace("/derivations");
@@ -157,6 +163,23 @@ function updateRule(idx: number, value: DerivationRule) {
rules.value = rules.value.map((r, i) => (i === idx ? value : r));
}
function applyRestore(entry: ModuleHistoryEntry): void {
name.value = entry.name;
description.value = entry.description ?? "";
categoryId.value = entry.category_id ?? null;
tags.value = entry.tags ? [...entry.tags] : [];
const p = (entry.payload ?? {}) as Partial<DerivationPayload>;
const incoming = Array.isArray(p.rules) ? p.rules : [];
rules.value = incoming.length ? incoming.map(migrateRule) : [];
historyOpen.value = false;
toast.add({
severity: "info",
summary: "Version restored",
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
life: 4000,
});
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
@@ -165,17 +188,36 @@ async function save() {
saving.value = true;
try {
const payload: DerivationPayload = { rules: rules.value };
const body = {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: payload as unknown as Record<string, unknown>,
};
const newPayload = payload as unknown as Record<string, unknown>;
if (isEdit.value && props.id) {
await moduleStore.update(props.id, body);
const prev = await moduleStore.get(props.id);
const nextHistory = appendSnapshot(
{
name: prev.name,
description: prev.description,
category_id: prev.category_id,
tags: prev.tags,
payload: prev.payload as Record<string, unknown>,
},
prev.payload as Record<string, unknown>,
);
await moduleStore.update(props.id, {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: { ...newPayload, history: nextHistory },
});
historyEntries.value = nextHistory;
} else {
await moduleStore.create({ type: "derivation", ...body });
await moduleStore.create({
type: "derivation",
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: newPayload,
});
}
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
router.push("/derivations");
@@ -186,7 +228,7 @@ async function save() {
}
}
defineExpose({ rules, addRule, removeRule });
defineExpose({ rules, addRule, removeRule, historyEntries, historyOpen, applyRestore });
</script>
<template>
@@ -277,9 +319,25 @@ defineExpose({ rules, addRule, removeRule });
</div>
<div class="form-page__footer">
<Button
v-if="historyEntries.length"
:label="`History (${historyEntries.length})`"
icon="pi pi-history"
severity="secondary"
outlined
data-test="history-btn"
@click="historyOpen = true"
/>
<div class="form-page__footer-spacer" />
<Button label="Cancel" severity="secondary" outlined @click="router.push('/derivations')" />
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
</div>
<HistoryPanel
:open="historyOpen"
:entries="historyEntries"
@update:open="(v) => (historyOpen = v)"
@restore="applyRestore"
/>
</div>
</template>
@@ -292,8 +350,9 @@ defineExpose({ rules, addRule, removeRule });
background: var(--wp-bg);
border-top: 1px solid var(--wp-border);
padding: 12px 24px;
display: flex; gap: 8px; justify-content: flex-end;
display: flex; gap: 8px; align-items: center;
}
.form-page__footer-spacer { flex: 1; }
.form-section { margin-bottom: 24px; }
.form-section__label {
font-size: 11px; text-transform: uppercase;
+59 -3
View File
@@ -9,9 +9,12 @@ import Textarea from "primevue/textarea";
import Select from "primevue/select";
import AutoComplete from "primevue/autocomplete";
import { useToast } from "primevue/usetoast";
import HistoryPanel from "../components/HistoryPanel.vue";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { VALID_IDENTIFIER_RE } from "../utils/slug";
import { appendSnapshot, readHistory } from "../utils/history";
import type { ModuleHistoryEntry } from "../api/types";
const props = defineProps<{ id?: string }>();
const router = useRouter();
@@ -28,6 +31,8 @@ const tags = ref<string[]>([]);
const values = ref<NamedValue[]>([]);
const saving = ref(false);
const isEdit = computed(() => !!props.id);
const historyEntries = ref<ModuleHistoryEntry[]>([]);
const historyOpen = ref(false);
// ----- Tag autocomplete (mirrors WildcardForm) -----
const tagSuggestions = ref<string[]>([]);
@@ -59,6 +64,7 @@ onMounted(async () => {
name: (v.name ?? "").replace(/^\$+/, ""),
value: v.value ?? "",
}));
historyEntries.value = readHistory(row.payload);
} catch {
toast.add({ severity: "error", summary: "Module not found", life: 3000 });
router.replace("/fixed-values");
@@ -109,6 +115,26 @@ const rowErrors = computed<string[]>(() => {
const hasRowErrors = computed(() => rowErrors.value.some((e) => e !== ""));
function applyRestore(entry: ModuleHistoryEntry): void {
name.value = entry.name;
description.value = entry.description ?? "";
categoryId.value = entry.category_id ?? null;
tags.value = entry.tags ? [...entry.tags] : [];
const rows = ((entry.payload ?? {}) as { values?: NamedValue[] }).values ?? [];
values.value = rows.map((v) => ({
id: v.id,
name: (v.name ?? "").replace(/^\$+/, ""),
value: v.value ?? "",
}));
historyOpen.value = false;
toast.add({
severity: "info",
summary: "Version restored",
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
life: 4000,
});
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
@@ -125,12 +151,25 @@ async function save() {
}
saving.value = true;
try {
const payload = { values: values.value };
const payload = { values: values.value } as Record<string, unknown>;
if (isEdit.value && props.id) {
const prev = await moduleStore.get(props.id);
const nextHistory = appendSnapshot(
{
name: prev.name,
description: prev.description,
category_id: prev.category_id,
tags: prev.tags,
payload: prev.payload as Record<string, unknown>,
},
prev.payload as Record<string, unknown>,
);
await moduleStore.update(props.id, {
name: name.value, description: description.value,
category_id: categoryId.value, tags: tags.value, payload,
category_id: categoryId.value, tags: tags.value,
payload: { ...payload, history: nextHistory },
});
historyEntries.value = nextHistory;
} else {
await moduleStore.create({
type: "fixed_values",
@@ -265,9 +304,25 @@ async function save() {
</div>
<div class="form-page__footer">
<Button
v-if="historyEntries.length"
:label="`History (${historyEntries.length})`"
icon="pi pi-history"
severity="secondary"
outlined
data-test="history-btn"
@click="historyOpen = true"
/>
<div class="form-page__footer-spacer" />
<Button label="Cancel" severity="secondary" outlined @click="router.push('/fixed-values')" />
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
</div>
<HistoryPanel
:open="historyOpen"
:entries="historyEntries"
@update:open="(v) => (historyOpen = v)"
@restore="applyRestore"
/>
</div>
</template>
@@ -280,8 +335,9 @@ async function save() {
background: var(--wp-bg);
border-top: 1px solid var(--wp-border);
padding: 12px 24px;
display: flex; gap: 8px; justify-content: flex-end;
display: flex; gap: 8px; align-items: center;
}
.form-page__footer-spacer { flex: 1; }
.form-section { margin-bottom: 24px; }
.form-section__label {
font-size: 11px; text-transform: uppercase;
+73 -10
View File
@@ -13,10 +13,13 @@ import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import PipelineSteps from "../components/PipelineSteps.vue";
import PipelineStepPicker from "../components/PipelineStepPicker.vue";
import HistoryPanel from "../components/HistoryPanel.vue";
import { appendSnapshot, readHistory } from "../utils/history";
import type {
CombinePayload,
DerivationPayload,
ConstraintPayload,
ModuleHistoryEntry,
ModuleRow,
ModuleType,
PipelinePayload,
@@ -36,6 +39,8 @@ const tags = ref<string[]>([]);
const steps = ref<PipelineStep[]>([]);
const saving = ref(false);
const pickerOpen = ref(false);
const historyEntries = ref<ModuleHistoryEntry[]>([]);
const historyOpen = ref(false);
// All modules (cross-kind) used by step rows + picker.
const allModules = ref<ModuleRow[]>([]);
@@ -99,6 +104,7 @@ onMounted(async () => {
enabled: s.enabled !== false,
...(s.instance ? { instance: s.instance } : {}),
}));
historyEntries.value = readHistory(row.payload);
} catch {
toast.add({ severity: "error", summary: "Pipeline not found", life: 3000 });
router.replace("/pipelines");
@@ -202,6 +208,27 @@ function previewKindIcon(k: ModuleType | null): string {
return k ? KIND_ICON[k] : "pi pi-circle";
}
function applyRestore(entry: ModuleHistoryEntry): void {
name.value = entry.name;
description.value = entry.description ?? "";
categoryId.value = entry.category_id ?? null;
tags.value = entry.tags ? [...entry.tags] : [];
const p = (entry.payload ?? {}) as Partial<PipelinePayload>;
steps.value = (p.steps ?? []).map((s) => ({
id: s.id || `step_${Math.random().toString(16).slice(2, 8)}`,
module_id: s.module_id,
enabled: s.enabled !== false,
...(s.instance ? { instance: s.instance } : {}),
}));
historyOpen.value = false;
toast.add({
severity: "info",
summary: "Version restored",
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
life: 4000,
});
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
@@ -217,17 +244,36 @@ async function save() {
...(s.instance ? { instance: s.instance } : {}),
})),
};
const body = {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: payload as unknown as Record<string, unknown>,
};
const newPayload = payload as unknown as Record<string, unknown>;
if (isEdit.value && props.id) {
await moduleStore.update(props.id, body);
const prev = await moduleStore.get(props.id);
const nextHistory = appendSnapshot(
{
name: prev.name,
description: prev.description,
category_id: prev.category_id,
tags: prev.tags,
payload: prev.payload as Record<string, unknown>,
},
prev.payload as Record<string, unknown>,
);
await moduleStore.update(props.id, {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: { ...newPayload, history: nextHistory },
});
historyEntries.value = nextHistory;
} else {
await moduleStore.create({ type: "pipeline", ...body });
await moduleStore.create({
type: "pipeline",
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: newPayload,
});
}
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
router.push("/pipelines");
@@ -347,9 +393,25 @@ async function save() {
<!-- E) Footer -->
<div class="form-page__footer">
<Button
v-if="historyEntries.length"
:label="`History (${historyEntries.length})`"
icon="pi pi-history"
severity="secondary"
outlined
data-test="history-btn"
@click="historyOpen = true"
/>
<div class="form-page__footer-spacer" />
<Button label="Cancel" severity="secondary" outlined @click="router.push('/pipelines')" />
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
</div>
<HistoryPanel
:open="historyOpen"
:entries="historyEntries"
@update:open="(v) => (historyOpen = v)"
@restore="applyRestore"
/>
</div>
</template>
@@ -362,8 +424,9 @@ async function save() {
background: var(--wp-bg);
border-top: 1px solid var(--wp-border);
padding: 12px 24px;
display: flex; gap: 8px; justify-content: flex-end;
display: flex; gap: 8px; align-items: center;
}
.form-page__footer-spacer { flex: 1; }
.form-section { margin-bottom: 24px; }
.form-section__label {
font-size: 11px; text-transform: uppercase;
+85 -4
View File
@@ -11,10 +11,17 @@ import Select from "primevue/select";
import AutoComplete from "primevue/autocomplete";
import { useToast } from "primevue/usetoast";
import RichTextInput from "../components/RichTextInput.vue";
import HistoryPanel from "../components/HistoryPanel.vue";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
import type { CombinePayload, WildcardOption, WildcardPayload } from "../api/types";
import { appendSnapshot, readHistory } from "../utils/history";
import type {
CombinePayload,
ModuleHistoryEntry,
WildcardOption,
WildcardPayload,
} from "../api/types";
const props = defineProps<{ id?: string }>();
const router = useRouter();
@@ -38,6 +45,11 @@ const varBindingError = ref("");
const saving = ref(false);
const isEdit = computed(() => !!props.id);
// Per-module version history (max 3). Hydrated from `payload.history` on
// load and updated in-place whenever the user saves an edit.
const historyEntries = ref<ModuleHistoryEntry[]>([]);
const historyOpen = ref(false);
const subCategoryOptions = computed(() => [
{ label: "(none)", value: null as string | null },
...subCategories.value.map((s) => ({ label: s, value: s })),
@@ -148,6 +160,7 @@ onMounted(async () => {
} else {
varBinding.value = toIdentifier(row.name);
}
historyEntries.value = readHistory(row.payload);
} catch {
toast.add({ severity: "error", summary: "Wildcard not found", life: 3000 });
router.replace("/wildcards");
@@ -161,6 +174,41 @@ function addOption() {
}
function removeOption(idx: number) { options.value.splice(idx, 1); }
/** Live editor state minus the `history` sidecar — used to capture pre-save snapshots. */
function currentPayload(): Record<string, unknown> {
const finalBinding = (varBinding.value.trim()) || toIdentifier(name.value);
const payload: WildcardPayload = {
options: options.value,
sub_categories: subCategories.value,
var_binding: finalBinding,
};
return payload as unknown as Record<string, unknown>;
}
function applyRestore(entry: ModuleHistoryEntry): void {
name.value = entry.name;
description.value = entry.description ?? "";
categoryId.value = entry.category_id ?? null;
tags.value = entry.tags ? [...entry.tags] : [];
const p = (entry.payload ?? {}) as Partial<WildcardPayload>;
options.value = (p.options ?? []).map((o) => ({ ...o }));
subCategories.value = [...(p.sub_categories ?? [])];
if (p.var_binding && p.var_binding.trim()) {
varBinding.value = p.var_binding;
varBindingTouched.value = true;
} else {
varBinding.value = toIdentifier(entry.name);
varBindingTouched.value = false;
}
historyOpen.value = false;
toast.add({
severity: "info",
summary: "Version restored",
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
life: 4000,
});
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
@@ -180,18 +228,32 @@ async function save() {
sub_categories: subCategories.value,
var_binding: finalBinding,
};
const newPayload = payload as unknown as Record<string, unknown>;
if (isEdit.value && props.id) {
// Capture pre-save state into history sidecar before the new write.
const prev = await moduleStore.get(props.id);
const nextHistory = appendSnapshot(
{
name: prev.name,
description: prev.description,
category_id: prev.category_id,
tags: prev.tags,
payload: prev.payload as Record<string, unknown>,
},
prev.payload as Record<string, unknown>,
);
await moduleStore.update(props.id, {
name: name.value, description: description.value,
category_id: categoryId.value, tags: tags.value,
payload: payload as unknown as Record<string, unknown>,
payload: { ...newPayload, history: nextHistory },
});
historyEntries.value = nextHistory;
} else {
await moduleStore.create({
type: "wildcard",
name: name.value, description: description.value,
category_id: categoryId.value, tags: tags.value,
payload: payload as unknown as Record<string, unknown>,
payload: newPayload,
});
}
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
@@ -202,6 +264,8 @@ async function save() {
saving.value = false;
}
}
defineExpose({ historyEntries, historyOpen, applyRestore, currentPayload });
</script>
<template>
@@ -356,9 +420,25 @@ async function save() {
</div>
<div class="form-page__footer">
<Button
v-if="historyEntries.length"
:label="`History (${historyEntries.length})`"
icon="pi pi-history"
severity="secondary"
outlined
data-test="history-btn"
@click="historyOpen = true"
/>
<div class="form-page__footer-spacer" />
<Button label="Cancel" severity="secondary" outlined @click="router.push('/wildcards')" />
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
</div>
<HistoryPanel
:open="historyOpen"
:entries="historyEntries"
@update:open="(v) => (historyOpen = v)"
@restore="applyRestore"
/>
</div>
</template>
@@ -371,8 +451,9 @@ async function save() {
background: var(--wp-bg);
border-top: 1px solid var(--wp-border);
padding: 12px 24px;
display: flex; gap: 8px; justify-content: flex-end;
display: flex; gap: 8px; align-items: center;
}
.form-page__footer-spacer { flex: 1; }
.form-section { margin-bottom: 24px; }
.form-section__label {
font-size: 11px; text-transform: uppercase;
+2 -2
View File
@@ -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-BhIVKRQ2.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-DnSaKANs.css">
<script type="module" crossorigin src="/wp/assets/manager-CcM7A8Hk.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-DUGKIqpP.css">
</head>
<body>
<div id="app"></div>