diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 00000000..883771c2 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import * as React from "react"; +import { cn } from "../../lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/src/routes/-settings.test.tsx b/src/routes/-settings.test.tsx index aad26369..8dbf5e7c 100644 --- a/src/routes/-settings.test.tsx +++ b/src/routes/-settings.test.tsx @@ -1,5 +1,5 @@ /* @vitest-environment jsdom */ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "../../convex/_generated/api"; @@ -29,8 +29,65 @@ vi.mock("@tanstack/react-router", () => ({ useSearch: () => searchMock(), })); +const signedInUser = { + _id: "user_123", + displayName: "Patrick", + name: "Patrick", + handle: "patrick", + email: "patrick@example.com", + image: null, + bio: null, +}; + +const orgMembership = { + publisher: { + _id: "publisher_openclaw", + handle: "openclaw", + displayName: "OpenClaw Team", + kind: "org", + image: null, + bio: "OpenClaw publisher", + }, + role: "owner", +}; + +const orgMembers = { + publisher: { _id: "publisher_openclaw", handle: "openclaw" }, + members: [ + { + role: "owner", + user: { + _id: "user_123", + handle: "patrick", + displayName: "Patrick", + image: null, + }, + }, + ], +}; + +function mockSignedInSettings({ + search = {}, + memberships = [orgMembership], + members = orgMembers, +}: { + search?: Record; + memberships?: Array; + members?: typeof orgMembers; +} = {}) { + searchMock.mockReturnValue(search); + useQueryMock.mockImplementation((query, args) => { + if (query === api.users.me) return signedInUser; + if (args === "skip") return undefined; + if (args && typeof args === "object" && "publisherHandle" in args) return members; + if (args && typeof args === "object") return []; + return memberships; + }); +} + describe("Settings", () => { beforeEach(() => { + window.history.replaceState(null, "", "/settings"); useQueryMock.mockReset(); useMutationMock.mockReset(); useAuthActionsMock.mockReset(); @@ -54,24 +111,7 @@ describe("Settings", () => { }); it("renders account and appearance inside signed-in account preferences", () => { - useQueryMock.mockImplementation((query, args) => { - if (query === api.users.me) { - return { - _id: "user_123", - displayName: "Patrick", - name: "Patrick", - handle: "patrick", - email: "patrick@example.com", - image: null, - bio: null, - }; - } - if (args === "skip") return undefined; - if (args && typeof args === "object" && "publisherHandle" in args) { - return undefined; - } - return []; - }); + mockSignedInSettings(); render(); @@ -87,4 +127,49 @@ describe("Settings", () => { expect(screen.queryByText(/high contrast/i)).toBeNull(); expect(screen.queryByText(/experimental features/i)).toBeNull(); }); + + it("does not load organization members on the default account view", () => { + mockSignedInSettings(); + + render(); + + expect(useQueryMock).toHaveBeenCalledWith(api.publishers.listMembers, "skip"); + expect(screen.queryByRole("heading", { name: "Members" })).toBeNull(); + }); + + it("navigates to a focused settings view from the section navigation", () => { + mockSignedInSettings(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Organizations" })); + + expect(navigateMock).toHaveBeenCalledWith({ search: { view: "organizations" } }); + }); + + it("renders organization management and loads members only on the organizations view", async () => { + mockSignedInSettings({ search: { view: "organizations" } }); + + render(); + + expect(screen.getByRole("button", { name: "Organizations" }).getAttribute("aria-current")).toBe( + "true", + ); + expect(await screen.findByText("OpenClaw Team")).toBeTruthy(); + expect(screen.getByText("@openclaw ยท owner")).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Members" })).toBeTruthy(); + expect(screen.getByText("Patrick")).toBeTruthy(); + expect(useQueryMock).toHaveBeenCalledWith(api.publishers.listMembers, { + publisherHandle: "openclaw", + }); + }); + + it("migrates legacy hash settings URLs to focused query params", () => { + window.history.replaceState(null, "", "/settings#tokens"); + mockSignedInSettings(); + + render(); + + expect(navigateMock).toHaveBeenCalledWith({ search: { view: "tokens" }, replace: true }); + }); }); diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 29c6050d..c9265887 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -49,6 +49,7 @@ import { SelectTrigger, SelectValue, } from "../components/ui/select"; +import { Separator } from "../components/ui/separator"; import { Textarea } from "../components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group"; import { useThemeMode } from "../lib/theme"; @@ -185,7 +186,9 @@ export function Settings() { const revokedTokens = (tokens ?? []).filter((token) => token.revokedAt); const orgMembers = useQuery( api.publishers.listMembers, - selectedOrg ? { publisherHandle: selectedOrg.publisher.handle } : "skip", + activeView === "organizations" && selectedOrg + ? { publisherHandle: selectedOrg.publisher.handle } + : "skip", ) as OrgMembersResult | null | undefined; useEffect(() => { @@ -325,7 +328,7 @@ export function Settings() { Account identity, publishing organizations, and API access for ClawHub.

-
+