feat(spa): pinia stores for modules + categories + ui

This commit is contained in:
DumiFlex
2026-04-26 19:46:51 +03:00
parent 7a7b632f06
commit 86c36e0f52
4 changed files with 261 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../api/client", () => ({
api: {
modules: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
duplicate: vi.fn(),
favorite: vi.fn(),
},
},
}));
import { api } from "../api/client";
import { useModuleStore } from "../stores/moduleStore";
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
beforeEach(() => {
setActivePinia(createPinia());
Object.values(apiMod).forEach((fn) => fn.mockReset());
});
afterEach(() => vi.clearAllMocks());
describe("moduleStore", () => {
it("fetchAll populates items", async () => {
apiMod.list.mockResolvedValue({
items: [{ id: "wc_x_1", name: "x", type: "wildcard", is_favorite: false }],
total: 1,
});
const s = useModuleStore();
await s.fetchAll();
expect(s.items).toHaveLength(1);
expect(s.items[0].id).toBe("wc_x_1");
});
it("fetchAll forwards filters", async () => {
apiMod.list.mockResolvedValue({ items: [], total: 0 });
const s = useModuleStore();
s.filter.type = "wildcard";
s.filter.q = "color";
await s.fetchAll();
expect(apiMod.list).toHaveBeenCalledWith({ type: "wildcard", q: "color" });
});
it("create unshifts to items", async () => {
apiMod.create.mockResolvedValue({ id: "wc_y_2", name: "y", type: "wildcard" });
const s = useModuleStore();
await s.create({ type: "wildcard", name: "y", payload: {} });
expect(s.items[0].id).toBe("wc_y_2");
});
it("update replaces item in place", async () => {
apiMod.list.mockResolvedValue({
items: [{ id: "wc_x_1", name: "x", type: "wildcard" }],
total: 1,
});
apiMod.update.mockResolvedValue({ id: "wc_x_1", name: "renamed", type: "wildcard" });
const s = useModuleStore();
await s.fetchAll();
await s.update("wc_x_1", { name: "renamed" });
expect(s.items[0].name).toBe("renamed");
});
it("remove drops from items + calls api.delete", async () => {
apiMod.list.mockResolvedValue({
items: [{ id: "wc_x_1", name: "x", type: "wildcard" }],
total: 1,
});
apiMod.delete.mockResolvedValue(undefined);
const s = useModuleStore();
await s.fetchAll();
await s.remove("wc_x_1");
expect(apiMod.delete).toHaveBeenCalledWith("wc_x_1");
expect(s.items).toHaveLength(0);
});
it("duplicate unshifts new item", async () => {
apiMod.duplicate.mockResolvedValue({ id: "wc_a_2", name: "a (copy)", type: "wildcard" });
const s = useModuleStore();
await s.duplicate("wc_a_1");
expect(s.items[0].id).toBe("wc_a_2");
});
it("toggleFavorite swaps is_favorite locally on success", async () => {
apiMod.list.mockResolvedValue({
items: [{ id: "wc_x_1", is_favorite: false, type: "wildcard" }],
total: 1,
});
apiMod.favorite.mockResolvedValue({ id: "wc_x_1", is_favorite: true, type: "wildcard" });
const s = useModuleStore();
await s.fetchAll();
await s.toggleFavorite("wc_x_1");
expect(s.items[0].is_favorite).toBe(true);
});
it("loading flag toggles around fetchAll", async () => {
let resolveFn: (v: unknown) => void = () => {};
apiMod.list.mockReturnValue(new Promise((r) => { resolveFn = r; }));
const s = useModuleStore();
const promise = s.fetchAll();
expect(s.loading).toBe(true);
resolveFn({ items: [], total: 0 });
await promise;
expect(s.loading).toBe(false);
});
it("wildcards + fixedValues computed split items by type", async () => {
apiMod.list.mockResolvedValue({
items: [
{ id: "wc_a", type: "wildcard" },
{ id: "fv_b", type: "fixed_values" },
{ id: "wc_c", type: "wildcard" },
],
total: 3,
});
const s = useModuleStore();
await s.fetchAll();
expect(s.wildcards).toHaveLength(2);
expect(s.fixedValues).toHaveLength(1);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import type { CategoryCreateInput, CategoryRow } from "../api/types";
export const useCategoryStore = defineStore("categories", () => {
const items = ref<CategoryRow[]>([]);
const loading = ref(false);
async function fetchAll() {
loading.value = true;
try {
const res = await api.categories.list();
items.value = res.items;
} finally {
loading.value = false;
}
}
async function create(body: CategoryCreateInput) {
const row = await api.categories.create(body);
items.value.push(row);
return row;
}
async function update(id: string, body: Partial<CategoryCreateInput>) {
const row = await api.categories.update(id, body);
const idx = items.value.findIndex((c) => c.id === id);
if (idx >= 0) items.value[idx] = row;
return row;
}
async function remove(id: string) {
await api.categories.delete(id);
items.value = items.value.filter((c) => c.id !== id);
}
return { items, loading, fetchAll, create, update, remove };
});
+76
View File
@@ -0,0 +1,76 @@
import { defineStore } from "pinia";
import { computed, reactive, ref } from "vue";
import { api } from "../api/client";
import type { ModuleCreateInput, ModuleRow, ModuleType, ModuleUpdateInput } from "../api/types";
interface Filter {
type?: ModuleType;
category?: string;
q?: string;
favorites?: boolean;
}
export const useModuleStore = defineStore("modules", () => {
const items = ref<ModuleRow[]>([]);
const loading = ref(false);
const filter = reactive<Filter>({});
async function fetchAll() {
loading.value = true;
try {
const params: Record<string, string | boolean | undefined> = {};
if (filter.type) params.type = filter.type;
if (filter.category) params.category = filter.category;
if (filter.q) params.q = filter.q;
if (filter.favorites) params.favorites = true;
const res = await api.modules.list(params);
items.value = res.items;
} finally {
loading.value = false;
}
}
async function get(id: string) {
return await api.modules.get(id);
}
async function create(body: ModuleCreateInput) {
const row = await api.modules.create(body);
items.value.unshift(row);
return row;
}
async function update(id: string, body: ModuleUpdateInput) {
const updated = await api.modules.update(id, body);
const idx = items.value.findIndex((i) => i.id === id);
if (idx >= 0) items.value[idx] = updated;
return updated;
}
async function remove(id: string) {
await api.modules.delete(id);
items.value = items.value.filter((i) => i.id !== id);
}
async function duplicate(id: string) {
const copy = await api.modules.duplicate(id);
items.value.unshift(copy);
return copy;
}
async function toggleFavorite(id: string) {
const updated = await api.modules.favorite(id);
const idx = items.value.findIndex((i) => i.id === id);
if (idx >= 0) items.value[idx] = updated;
return updated;
}
const wildcards = computed(() => items.value.filter((i) => i.type === "wildcard"));
const fixedValues = computed(() => items.value.filter((i) => i.type === "fixed_values"));
return {
items, loading, filter,
fetchAll, get, create, update, remove, duplicate, toggleFavorite,
wildcards, fixedValues,
};
});
+20
View File
@@ -0,0 +1,20 @@
import { defineStore } from "pinia";
import { ref } from "vue";
type Theme = "dark" | "light";
export const useUiStore = defineStore("ui", () => {
const theme = ref<Theme>("dark");
const sidebarCollapsed = ref(false);
function toggleTheme() {
theme.value = theme.value === "dark" ? "light" : "dark";
document.documentElement.classList.toggle("light-theme", theme.value === "light");
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
}
return { theme, sidebarCollapsed, toggleTheme, toggleSidebar };
});