feat(spa): polish fixed-value form with \$name InputGroup + per-row identifier validation

Redesigns each value row as a dedicated card-like row with a \`\$name\`
InputGroup (pi pi-dollar addon + sanitised InputText) and an auto-resize
Textarea, mirroring the design-handoff prototype's FixedValuesEditor.

Adds inline per-row error chips for empty / invalid / duplicate
identifiers, blocks save with a warn toast when any row is invalid, and
swaps the "Add value" affordance for a dashed full-width button. Loader
strips a stray leading \`\$\` from older payload shapes so the addon is
the sole source of the sigil.

Tests cover: heading, existing-id load, empty-name guard, invalid-row
guard, payload shape on save, duplicate-name flagging, and \`\$\`-prefix
strip on load.
This commit is contained in:
DumiFlex
2026-04-27 07:00:28 +03:00
parent 80cc323f31
commit 928e33cc5f
3 changed files with 366 additions and 32 deletions
@@ -0,0 +1,189 @@
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
vi.mock("../api/client", () => ({
api: {
modules: {
create: vi.fn(),
get: vi.fn(),
update: vi.fn(),
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
},
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
},
}));
import { api } from "../api/client";
import FixedValueForm from "../views/FixedValueForm.vue";
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
const apiCat = api.categories as unknown as Record<string, ReturnType<typeof vi.fn>>;
beforeEach(() => {
setActivePinia(createPinia());
Object.values(apiMod).forEach((fn) => fn.mockReset());
apiMod.list.mockResolvedValue({ items: [], total: 0 });
apiCat.list.mockResolvedValue({ items: [] });
});
afterEach(() => {
vi.clearAllMocks();
});
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/fixed-values", component: { template: "<div/>" } },
],
});
}
function findByText(wrap: ReturnType<typeof mount>, text: string) {
return wrap.findAll("button").find((b) => b.text().includes(text));
}
describe("FixedValueForm.vue", () => {
it("renders 'New fixed values' heading when no id", async () => {
const wrap = mount(FixedValueForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("New fixed values");
});
it("loads existing module when id prop given", async () => {
apiMod.get.mockResolvedValue({
id: "fv_a", name: "Subject Profile", description: "desc",
category_id: null, tags: [], type: "fixed_values",
payload: { values: [{ id: "v1", name: "first_name", value: "Mira" }] },
version: 1, created_at: "", updated_at: "", is_favorite: false,
});
const wrap = mount(FixedValueForm, {
props: { id: "fv_a" },
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("Edit fixed values");
expect(apiMod.get).toHaveBeenCalledWith("fv_a");
const varInput = wrap.find('[data-test="fv-row-0-name"]')
.element as HTMLInputElement;
expect(varInput.value).toBe("first_name");
});
it("save without name shows warn toast and does not call api", async () => {
const wrap = mount(FixedValueForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).not.toHaveBeenCalled();
});
it("save with invalid var name in row shows toast and skips api", async () => {
const wrap = mount(FixedValueForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
// Fill module name so the first guard passes.
const nameInput = wrap.find("#fv-name");
await nameInput.setValue("Studio Defaults");
// Add a row with a name starting with a digit (invalid identifier).
const addBtn = wrap.find('[data-test="fv-add"]');
await addBtn.trigger("click");
await flushPromises();
const varInput = wrap.find('[data-test="fv-row-0-name"]');
// Setting "9bad" is allowed by the input sanitiser (digits allowed) but
// fails VALID_IDENTIFIER_RE because identifiers can't start with a digit.
await varInput.setValue("9bad");
await flushPromises();
// Inline error chip surfaces for the row.
expect(wrap.find('[data-test="fv-row-0-err"]').exists()).toBe(true);
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).not.toHaveBeenCalled();
});
it("save sends payload with values shape", async () => {
apiMod.create.mockResolvedValue({
id: "fv_a", type: "fixed_values", name: "Studio Defaults",
description: "", category_id: null, tags: [], is_favorite: false,
payload: { values: [] }, version: 1, created_at: "", updated_at: "",
});
const wrap = mount(FixedValueForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#fv-name");
await nameInput.setValue("Studio Defaults");
const addBtn = wrap.find('[data-test="fv-add"]');
await addBtn.trigger("click");
await flushPromises();
const varInput = wrap.find('[data-test="fv-row-0-name"]');
await varInput.setValue("focal_length");
await flushPromises();
const valInput = wrap.find('[data-test="fv-row-0-value"]');
await valInput.setValue("85mm");
await flushPromises();
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).toHaveBeenCalledWith(
expect.objectContaining({ type: "fixed_values", name: "Studio Defaults" }),
);
const call = apiMod.create.mock.calls[0]?.[0] as {
payload: { values: { name: string; value: string }[] };
};
expect(call.payload.values).toHaveLength(1);
expect(call.payload.values[0].name).toBe("focal_length");
expect(call.payload.values[0].value).toBe("85mm");
});
it("flags duplicate var names as invalid rows", async () => {
const wrap = mount(FixedValueForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#fv-name");
await nameInput.setValue("Pack");
const addBtn = wrap.find('[data-test="fv-add"]');
await addBtn.trigger("click");
await addBtn.trigger("click");
await flushPromises();
const a = wrap.find('[data-test="fv-row-0-name"]');
const b = wrap.find('[data-test="fv-row-1-name"]');
await a.setValue("dupe");
await b.setValue("dupe");
await flushPromises();
expect(wrap.find('[data-test="fv-row-0-err"]').text()).toContain("Duplicate");
expect(wrap.find('[data-test="fv-row-1-err"]').text()).toContain("Duplicate");
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).not.toHaveBeenCalled();
});
it("strips a leading $ from existing payload var names on load", async () => {
apiMod.get.mockResolvedValue({
id: "fv_a", name: "Pack", description: "",
category_id: null, tags: [], type: "fixed_values",
payload: { values: [{ id: "v1", name: "$age", value: "29" }] },
version: 1, created_at: "", updated_at: "", is_favorite: false,
});
const wrap = mount(FixedValueForm, {
props: { id: "fv_a" },
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const varInput = wrap.find('[data-test="fv-row-0-name"]')
.element as HTMLInputElement;
expect(varInput.value).toBe("age");
});
});
+175 -30
View File
@@ -11,6 +11,7 @@ import AutoComplete from "primevue/autocomplete";
import { useToast } from "primevue/usetoast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { VALID_IDENTIFIER_RE } from "../utils/slug";
const props = defineProps<{ id?: string }>();
const router = useRouter();
@@ -28,6 +29,7 @@ const values = ref<NamedValue[]>([]);
const saving = ref(false);
const isEdit = computed(() => !!props.id);
// ----- Tag autocomplete (mirrors WildcardForm) -----
const tagSuggestions = ref<string[]>([]);
function searchTags(event: { query: string }) {
const q = event.query.toLowerCase();
@@ -49,7 +51,14 @@ onMounted(async () => {
description.value = row.description;
categoryId.value = row.category_id;
tags.value = row.tags;
values.value = (row.payload as { values?: NamedValue[] }).values ?? [];
const rows = (row.payload as { values?: NamedValue[] }).values ?? [];
// Strip any leading "$" that might have been persisted by older shapes —
// the variable name is rendered as `$name` via the InputGroupAddon.
values.value = rows.map((v) => ({
id: v.id,
name: (v.name ?? "").replace(/^\$+/, ""),
value: v.value ?? "",
}));
} catch {
toast.add({ severity: "error", summary: "Module not found", life: 3000 });
router.replace("/fixed-values");
@@ -63,11 +72,57 @@ function addValue() {
}
function removeValue(idx: number) { values.value.splice(idx, 1); }
/**
* Sanitise a var-name on input — drop everything outside the identifier
* charset so users can paste sloppy text and not end up with bindings the
* engine tokenizer rejects. Mirrors the prototype's
* `e.target.value.replace(/[^a-zA-Z0-9_]/g, "")`.
*/
function onVarInput(idx: number, raw: string | null | undefined) {
const cleaned = (raw ?? "").replace(/[^a-zA-Z0-9_]/g, "");
values.value[idx].name = cleaned;
}
/**
* Per-row validity used to drive the inline error chip and the save guard.
* - empty → "Required"
* - bad ident → "Invalid identifier"
* - duplicate → "Duplicate name"
* - otherwise → ""
*/
const rowErrors = computed<string[]>(() => {
const errs: string[] = [];
const counts = new Map<string, number>();
for (const v of values.value) {
const n = v.name.trim();
counts.set(n, (counts.get(n) ?? 0) + 1);
}
for (const v of values.value) {
const n = v.name.trim();
if (!n) { errs.push("Required"); continue; }
if (!VALID_IDENTIFIER_RE.test(n)) { errs.push("Invalid identifier"); continue; }
if ((counts.get(n) ?? 0) > 1) { errs.push("Duplicate name"); continue; }
errs.push("");
}
return errs;
});
const hasRowErrors = computed(() => rowErrors.value.some((e) => e !== ""));
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
return;
}
if (hasRowErrors.value) {
toast.add({
severity: "warn",
summary: "Fix invalid value rows",
detail: "Each row needs a unique, valid `$name` identifier.",
life: 3000,
});
return;
}
saving.value = true;
try {
const payload = { values: values.value };
@@ -140,36 +195,72 @@ async function save() {
<section class="form-section">
<div class="flex items-center justify-between mb-2">
<h2 class="form-section__label m-0">Values</h2>
<Button label="Add value" icon="pi pi-plus" size="small" severity="primary" @click="addValue" />
<h2 class="form-section__label m-0">Values ({{ values.length }})</h2>
</div>
<table class="w-full text-sm border border-wp-border rounded">
<thead>
<tr class="bg-wp-bg2">
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase">Variable</th>
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase">Value</th>
<th class="w-16"></th>
</tr>
</thead>
<tbody>
<tr v-for="(v, idx) in values" :key="v.id" class="border-t border-wp-border">
<td class="px-3 py-2 w-48">
<InputGroup>
<InputGroupAddon><i class="pi pi-dollar" /></InputGroupAddon>
<InputText v-model="v.name" placeholder="varname" />
</InputGroup>
</td>
<td class="px-3 py-2"><InputText v-model="v.value" class="w-full" /></td>
<td class="px-3 py-2 text-right">
<Button icon="pi pi-trash" text rounded size="small" severity="danger"
aria-label="Remove value" @click="removeValue(idx)" />
</td>
</tr>
<tr v-if="!values.length">
<td colspan="3" class="text-center text-wp-text2 py-4">No values yet.</td>
</tr>
</tbody>
</table>
<p class="text-xs text-wp-text2 mb-2">
Each row binds a literal string to <code>$name</code>. Names must match
<code>[a-zA-Z_][a-zA-Z0-9_]*</code> and be unique within this module.
</p>
<div v-if="values.length" class="fv-rows">
<div
v-for="(v, idx) in values"
:key="v.id"
class="fv-row"
:data-test="`fv-row-${idx}`"
:data-invalid="rowErrors[idx] ? 'true' : 'false'"
>
<div class="fv-row__var">
<InputGroup>
<InputGroupAddon><i class="pi pi-dollar" /></InputGroupAddon>
<InputText
:model-value="v.name"
:data-test="`fv-row-${idx}-name`"
:class="{ 'p-invalid': !!rowErrors[idx] }"
placeholder="varname"
aria-label="Variable name"
@update:model-value="(val) => onVarInput(idx, val)"
/>
</InputGroup>
<p
v-if="rowErrors[idx]"
class="fv-row__err"
:data-test="`fv-row-${idx}-err`"
>
{{ rowErrors[idx] }}
</p>
</div>
<div class="fv-row__value">
<Textarea
v-model="v.value"
:rows="2"
auto-resize
class="w-full"
placeholder="value"
aria-label="Variable value"
:data-test="`fv-row-${idx}-value`"
/>
</div>
<div class="fv-row__remove">
<Button
icon="pi pi-trash"
text rounded size="small" severity="danger"
aria-label="Remove value"
@click="removeValue(idx)"
/>
</div>
</div>
</div>
<button
type="button"
class="fv-add"
data-test="fv-add"
@click="addValue"
>
<i class="pi pi-plus" />
<span>Add value</span>
</button>
</section>
</div>
@@ -197,4 +288,58 @@ async function save() {
letter-spacing: 0.5px; color: var(--wp-text2);
margin: 0 0 8px 0;
}
/* Per-row layout: $name | value textarea | trash. */
.fv-rows {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.fv-row {
display: grid;
grid-template-columns: 220px 1fr 36px;
gap: 8px;
align-items: start;
padding: 8px;
border: 1px solid var(--wp-border);
border-radius: 6px;
background: var(--wp-bg2);
}
.fv-row[data-invalid="true"] {
border-color: var(--wp-danger, #dc2626);
}
.fv-row__var :deep(.p-inputtext) { width: 100%; }
.fv-row__err {
font-size: 11px;
color: var(--wp-danger, #dc2626);
margin: 4px 0 0;
}
.fv-row__remove {
display: flex;
justify-content: flex-end;
padding-top: 2px;
}
/* Dashed "Add value" button matching prototype affordance. */
.fv-add {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 10px 12px;
border: 1px dashed var(--wp-border);
border-radius: 6px;
background: transparent;
color: var(--wp-text2);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.fv-add:hover {
background: var(--wp-bg2);
border-color: var(--wp-text2);
color: var(--wp-text);
}
</style>
+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-xOK4I4Wx.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-CMgfxADo.css">
<script type="module" crossorigin src="/wp/assets/manager-BhIVKRQ2.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-DnSaKANs.css">
</head>
<body>
<div id="app"></div>