feat(spa): wildcard + fixed-values editors
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user