diff --git a/apps/dashboard/src/app/(dashboard)/settings/_components/AccessEditorModal.test.tsx b/apps/dashboard/src/app/(dashboard)/settings/_components/AccessEditorModal.test.tsx new file mode 100644 index 000000000..c4ce4dfbf --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/_components/AccessEditorModal.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/components/i18n-provider"; +import { permissionsApi } from "@/lib/api"; +import { AccessEditorModal } from "./AccessEditorModal"; + +const showToast = vi.hoisted(() => vi.fn()); +vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ showToast }) })); + +describe("AccessEditorModal", () => { + let root: Root | undefined; + let host: HTMLDivElement | undefined; + + afterEach(async () => { + if (root) await act(async () => root?.unmount()); + host?.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("loads the resource catalog once after its labels update", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const neverSettles = new Promise(() => {}); + const listResources = vi + .spyOn(permissionsApi, "listResources") + .mockResolvedValueOnce({ data: [{ id: "project-1", label: "Project One" }] } as never) + .mockImplementation(() => neverSettles); + + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root?.render( + + {}} + onClose={() => {}} + /> + , + ); + }); + + expect(listResources).toHaveBeenCalledTimes(1); + expect(host.textContent).toContain("Project One"); + }); +}); diff --git a/apps/dashboard/src/app/(dashboard)/settings/_components/AccessEditorModal.tsx b/apps/dashboard/src/app/(dashboard)/settings/_components/AccessEditorModal.tsx index 17776a9a5..54cd17de0 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/_components/AccessEditorModal.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/_components/AccessEditorModal.tsx @@ -25,7 +25,7 @@ import { Icon as UiIcon } from "@repo/ui/icons"; * edit surface can prefill from the server and never be silently defaulted. */ -import { useMemo, useReducer, useState } from "react"; +import { useCallback, useMemo, useReducer, useState } from "react"; import { AccessControlEditor } from "@/components/permissions/AccessControlEditor"; import { reduceSelection, @@ -79,13 +79,14 @@ export function AccessEditorModal({ (grants): AccessSelection => ({ grants, readOnly: false, template: "custom" }), ); - const onCatalogLoaded = (type: ResourceType, entries: CatalogEntry[]) => { + // ResourcePicker reloads when this callback changes; label updates must not restart its fetch. + const onCatalogLoaded = useCallback((type: ResourceType, entries: CatalogEntry[]) => { setLabels((prev) => { const next = new Map(prev); for (const e of entries) next.set(`${type} ${e.id}`, e.label); return next; }); - }; + }, []); const handleSave = async () => { setSaving(true);