feat(spa): pinia stores for modules + categories + ui
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
Reference in New Issue
Block a user