feat(spa): wildcard + fixed-values editors

This commit is contained in:
DumiFlex
2026-04-26 19:58:29 +03:00
parent af66ca263e
commit 8abab1ee3d
3 changed files with 383 additions and 8 deletions
+108
View File
@@ -0,0 +1,108 @@
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(),
},
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
},
}));
import { api } from "../api/client";
import WildcardForm from "../views/WildcardForm.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());
apiCat.list.mockResolvedValue({ items: [] });
// jsdom lacks matchMedia, used by some PrimeVue overlays
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({
matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(),
}));
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/modules", component: { template: "<div/>" } },
],
});
}
function findByText(wrap: ReturnType<typeof mount>, text: string) {
return wrap.findAll("button").find((b) => b.text().includes(text));
}
describe("WildcardForm.vue", () => {
it("renders 'New wildcard' heading when no id", async () => {
const wrap = mount(WildcardForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("New wildcard");
});
it("loads existing module when id prop given", async () => {
apiMod.get.mockResolvedValue({
id: "wc_a", name: "alpha", description: "desc", category_id: null,
tags: [], type: "wildcard", payload: { options: [{ id: "o1", value: "red", weight: 2 }] },
version: 1, created_at: "", updated_at: "", is_favorite: false,
});
const wrap = mount(WildcardForm, {
props: { id: "wc_a" },
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("Edit wildcard");
expect(apiMod.get).toHaveBeenCalledWith("wc_a");
});
it("save creates wildcard module via store", async () => {
apiMod.create.mockResolvedValue({
id: "wc_a", type: "wildcard", name: "alpha",
description: "", category_id: null, tags: [], is_favorite: false,
payload: { options: [] }, version: 1, created_at: "", updated_at: "",
});
const wrap = mount(WildcardForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#wc-name");
await nameInput.setValue("alpha");
await nameInput.trigger("input");
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).toHaveBeenCalledWith(
expect.objectContaining({ type: "wildcard", name: "alpha" }),
);
});
it("save without name shows warn toast and does not call api", async () => {
const wrap = mount(WildcardForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).not.toHaveBeenCalled();
});
});
+137 -4
View File
@@ -1,10 +1,143 @@
<script setup lang="ts">
defineProps<{ id?: string }>();
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Textarea from "primevue/textarea";
import Select from "primevue/select";
import { useToast } from "primevue/usetoast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
const props = defineProps<{ id?: string }>();
const router = useRouter();
const moduleStore = useModuleStore();
const categoryStore = useCategoryStore();
const toast = useToast();
interface NamedValue { id: string; name: string; value: string; }
const name = ref("");
const description = ref("");
const categoryId = ref<string | null>(null);
const tags = ref<string[]>([]);
const values = ref<NamedValue[]>([]);
const saving = ref(false);
const isEdit = computed(() => !!props.id);
onMounted(async () => {
await categoryStore.fetchAll();
if (props.id) {
const row = await moduleStore.get(props.id);
name.value = row.name;
description.value = row.description;
categoryId.value = row.category_id;
tags.value = row.tags;
const payload = row.payload as { values?: NamedValue[] };
values.value = payload.values ?? [];
}
});
function addValue() {
const id = `val_${Math.random().toString(16).slice(2, 8)}`;
values.value.push({ id, name: "", value: "" });
}
function removeValue(idx: number) {
values.value.splice(idx, 1);
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
return;
}
saving.value = true;
try {
const payload = { values: values.value };
if (isEdit.value && props.id) {
await moduleStore.update(props.id, {
name: name.value, description: description.value,
category_id: categoryId.value, tags: tags.value, payload,
});
} else {
await moduleStore.create({
type: "fixed_values",
name: name.value, description: description.value,
category_id: categoryId.value, tags: tags.value, payload,
});
}
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
router.push("/modules");
} catch (e) {
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="p-6 text-wp-text">
<h1 class="text-xl font-semibold m-0">Fixed-values editor</h1>
<p class="text-sm text-wp-text2 m-0 mt-2">Stub — wired up in Task 7.</p>
<div class="p-6 text-wp-text max-w-4xl">
<h1 class="text-xl font-semibold mb-4">{{ isEdit ? "Edit fixed values" : "New fixed values" }}</h1>
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label for="fv-name" class="block text-xs text-wp-text2 mb-1">Name</label>
<InputText id="fv-name" v-model="name" class="w-full" />
</div>
<div>
<label for="fv-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
<Select
id="fv-cat"
v-model="categoryId"
:options="categoryStore.items"
option-label="name"
option-value="id"
placeholder="None"
show-clear
class="w-full"
/>
</div>
<div class="col-span-2">
<label for="fv-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
<Textarea id="fv-desc" v-model="description" rows="2" class="w-full" />
</div>
</div>
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-semibold text-wp-text2 uppercase tracking-wider">Values</h2>
<Button label="+ Add value" size="small" severity="secondary" @click="addValue" />
</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">
<InputText v-model="v.name" placeholder="$varname" class="w-full" />
</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" @click="removeValue(idx)" aria-label="Remove value" />
</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>
<div class="flex gap-2 mt-6">
<Button label="Save" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
<Button label="Cancel" severity="secondary" outlined @click="router.push('/modules')" />
</div>
</div>
</template>
+138 -4
View File
@@ -1,10 +1,144 @@
<script setup lang="ts">
defineProps<{ id?: string }>();
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Textarea from "primevue/textarea";
import InputNumber from "primevue/inputnumber";
import Select from "primevue/select";
import { useToast } from "primevue/usetoast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
const props = defineProps<{ id?: string }>();
const router = useRouter();
const moduleStore = useModuleStore();
const categoryStore = useCategoryStore();
const toast = useToast();
interface Option { id: string; value: string; weight: number; }
const name = ref("");
const description = ref("");
const categoryId = ref<string | null>(null);
const tags = ref<string[]>([]);
const options = ref<Option[]>([]);
const saving = ref(false);
const isEdit = computed(() => !!props.id);
onMounted(async () => {
await categoryStore.fetchAll();
if (props.id) {
const row = await moduleStore.get(props.id);
name.value = row.name;
description.value = row.description;
categoryId.value = row.category_id;
tags.value = row.tags;
const payload = row.payload as { options?: Option[] };
options.value = payload.options ?? [];
}
});
function addOption() {
const id = `opt_${Math.random().toString(16).slice(2, 8)}`;
options.value.push({ id, value: "", weight: 1 });
}
function removeOption(idx: number) {
options.value.splice(idx, 1);
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
return;
}
saving.value = true;
try {
const payload = { options: options.value };
if (isEdit.value && props.id) {
await moduleStore.update(props.id, {
name: name.value, description: description.value,
category_id: categoryId.value, tags: tags.value, payload,
});
} else {
await moduleStore.create({
type: "wildcard",
name: name.value, description: description.value,
category_id: categoryId.value, tags: tags.value, payload,
});
}
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
router.push("/modules");
} catch (e) {
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="p-6 text-wp-text">
<h1 class="text-xl font-semibold m-0">Wildcard editor</h1>
<p class="text-sm text-wp-text2 m-0 mt-2">Stub — wired up in Task 7.</p>
<div class="p-6 text-wp-text max-w-4xl">
<h1 class="text-xl font-semibold mb-4">{{ isEdit ? "Edit wildcard" : "New wildcard" }}</h1>
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label for="wc-name" class="block text-xs text-wp-text2 mb-1">Name</label>
<InputText id="wc-name" v-model="name" class="w-full" />
</div>
<div>
<label for="wc-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
<Select
id="wc-cat"
v-model="categoryId"
:options="categoryStore.items"
option-label="name"
option-value="id"
placeholder="None"
show-clear
class="w-full"
/>
</div>
<div class="col-span-2">
<label for="wc-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
<Textarea id="wc-desc" v-model="description" rows="2" class="w-full" />
</div>
</div>
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-semibold text-wp-text2 uppercase tracking-wider">Options</h2>
<Button label="+ Add option" size="small" severity="secondary" @click="addOption" />
</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">Weight</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="(opt, idx) in options" :key="opt.id" class="border-t border-wp-border">
<td class="px-3 py-2 w-24">
<InputNumber v-model="opt.weight" :min="0" :max="999" size="small" class="w-full" />
</td>
<td class="px-3 py-2">
<InputText v-model="opt.value" class="w-full" />
</td>
<td class="px-3 py-2 text-right">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="removeOption(idx)" aria-label="Remove option" />
</td>
</tr>
<tr v-if="!options.length">
<td colspan="3" class="text-center text-wp-text2 py-4">No options yet.</td>
</tr>
</tbody>
</table>
<div class="flex gap-2 mt-6">
<Button label="Save" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
<Button label="Cancel" severity="secondary" outlined @click="router.push('/modules')" />
</div>
</div>
</template>