diff --git a/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts b/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts index cfef3323..4526f7e3 100644 --- a/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts @@ -67,6 +67,12 @@ test("R6 console semantics keep real icons, native entity lists, and dynamic acc const accessSource = read("src/components/access/AccessMatrixPanel.vue"); assert.match(accessSource, /--permission-tool-count/u); assert.doesNotMatch(accessSource, /repeat\(6,/u); + assert.match(accessSource, /if \(!value\) access\.clearSelection\(\)/u); + assert.match(accessSource, /function closeInspector\(\): void \{ access\.clearSelection\(\);/u); + const accessStoreSource = read("src/stores/access.ts"); + assert.match(accessStoreSource, /function clearSelection\(\): void/u); + assert.match(accessStoreSource, /selectedUserId\.value = null/u); + assert.match(accessStoreSource, /detail\.value = null/u); }); test("R6 Sub2API source comparison is recorded in parent/child issue scope", () => { diff --git a/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue b/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue index 58fc4002..1382c342 100644 --- a/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue +++ b/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue @@ -48,13 +48,14 @@ onMounted(async () => { watch(() => route.params.userId, (userId) => { const value = typeof userId === "string" ? userId : ""; if (value && value !== access.selectedUserId) void access.selectUser(value); + if (!value) access.clearSelection(); }); function selectRow(row: AccessUserRow): void { if (route.params.userId !== row.id) void router.push({ name: "AccessUserDetail", params: { userId: row.id }, query: route.query }); void access.selectUser(row.id); } -function closeInspector(): void { void router.push({ name: "Access", query: route.query }); } +function closeInspector(): void { access.clearSelection(); void router.push({ name: "Access", query: route.query }); } function onRoleChange(event: Event): void { const role = (event.target as HTMLSelectElement).value as AccessUserRole; void access.updateSelectedUser({ role }); } function onStatusChange(event: Event): void { const status = (event.target as HTMLSelectElement).value as AccessUserStatus; void access.updateSelectedUser({ status }); } function onToolChange(toolId: string, event: Event): void { void access.setTool(toolId, (event.target as HTMLInputElement).checked); } diff --git a/web/hwlab-cloud-web/src/stores/access.test.ts b/web/hwlab-cloud-web/src/stores/access.test.ts new file mode 100644 index 00000000..911ad4fb --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/access.test.ts @@ -0,0 +1,37 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, expect, test, vi } from "vitest"; + +const { userRequest } = vi.hoisted(() => ({ userRequest: vi.fn() })); + +vi.mock("@/api", () => ({ + accessAPI: { + summary: vi.fn(), + users: vi.fn(), + user: userRequest, + updateUser: vi.fn(), + setTool: vi.fn() + } +})); + +import { useAccessStore } from "./access"; + +beforeEach(() => { + setActivePinia(createPinia()); + userRequest.mockReset(); +}); + +test("clearSelection ignores a stale detail response", async () => { + let resolveRequest: (value: { ok: true; status: number; data: { user?: { id: string } } }) => void = () => undefined; + userRequest.mockReturnValue(new Promise((resolve) => { resolveRequest = resolve; })); + const store = useAccessStore(); + + const pending = store.selectUser("user-a"); + store.clearSelection(); + resolveRequest({ ok: true, status: 200, data: { user: { id: "user-a" } } }); + await pending; + + expect(store.selectedUserId).toBeNull(); + expect(store.detail).toBeNull(); + expect(store.selectedUser).toBeNull(); + expect(store.detailLoading).toBe(false); +}); diff --git a/web/hwlab-cloud-web/src/stores/access.ts b/web/hwlab-cloud-web/src/stores/access.ts index 261e26cd..97c1228f 100644 --- a/web/hwlab-cloud-web/src/stores/access.ts +++ b/web/hwlab-cloud-web/src/stores/access.ts @@ -37,6 +37,7 @@ export const useAccessStore = defineStore("access", () => { detailLoading.value = true; mutationError.value = null; const response = await accessAPI.user(userId); + if (selectedUserId.value !== userId) return; if (response.ok) { detail.value = response.data; } else { @@ -45,6 +46,14 @@ export const useAccessStore = defineStore("access", () => { detailLoading.value = false; } + function clearSelection(): void { + selectedUserId.value = null; + detail.value = null; + detailLoading.value = false; + savingKey.value = null; + mutationError.value = null; + } + async function updateSelectedUser(payload: { role?: AccessUserRole; status?: AccessUserStatus }): Promise { if (!selectedUserId.value) return; savingKey.value = `user:${selectedUserId.value}`; @@ -75,5 +84,5 @@ export const useAccessStore = defineStore("access", () => { savingKey.value = null; } - return { summary, users, detail, selectedUserId, summaryView, userRows, detailView, toolRows, selectedUser, loading, detailLoading, savingKey, error, mutationError, refresh, selectUser, updateSelectedUser, setTool }; + return { summary, users, detail, selectedUserId, summaryView, userRows, detailView, toolRows, selectedUser, loading, detailLoading, savingKey, error, mutationError, refresh, selectUser, clearSelection, updateSelectedUser, setTool }; });