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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user