diff --git a/.changeset/wise-mosaic-sections.md b/.changeset/wise-mosaic-sections.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wise-mosaic-sections.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/delete-organization.stories.tsx b/packages/swingset/src/stories/delete-organization.stories.tsx index d7b1e435222..4d0c14a9832 100644 --- a/packages/swingset/src/stories/delete-organization.stories.tsx +++ b/packages/swingset/src/stories/delete-organization.stories.tsx @@ -1,5 +1,7 @@ /** @jsxImportSource @emotion/react */ -import { DeleteOrganization } from '@clerk/ui/mosaic/sections/delete-organization'; +import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; +import { deleteOrgMachine } from '@clerk/ui/mosaic/sections/delete-organization-machine'; +import { DeleteOrganizationView } from '@clerk/ui/mosaic/sections/delete-organization-view'; import type { StoryMeta } from '@/lib/types'; @@ -11,5 +13,18 @@ export const meta: StoryMeta = { }; export function Default() { - return ; + const [snapshot, send, actor] = useMachine(deleteOrgMachine, { + context: { + organizationName: 'Acme Inc', + destroyOrganization: () => new Promise(resolve => setTimeout(resolve, 800)), + }, + }); + + return ( + + ); } diff --git a/packages/swingset/src/stories/leave-organization.stories.tsx b/packages/swingset/src/stories/leave-organization.stories.tsx index 3ec1c554292..03e9600d16a 100644 --- a/packages/swingset/src/stories/leave-organization.stories.tsx +++ b/packages/swingset/src/stories/leave-organization.stories.tsx @@ -1,5 +1,7 @@ /** @jsxImportSource @emotion/react */ -import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization'; +import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; +import { leaveOrgMachine } from '@clerk/ui/mosaic/sections/leave-organization-machine'; +import { LeaveOrganizationView } from '@clerk/ui/mosaic/sections/leave-organization-view'; import type { StoryMeta } from '@/lib/types'; @@ -11,5 +13,18 @@ export const meta: StoryMeta = { }; export function Default() { - return ; + const [snapshot, send, actor] = useMachine(leaveOrgMachine, { + context: { + organizationName: 'Acme Inc', + leaveOrganization: () => new Promise(resolve => setTimeout(resolve, 800)), + }, + }); + + return ( + + ); } diff --git a/packages/swingset/src/stories/organization-profile-general.stories.tsx b/packages/swingset/src/stories/organization-profile-general.stories.tsx index b969053afa7..3682934c571 100644 --- a/packages/swingset/src/stories/organization-profile-general.stories.tsx +++ b/packages/swingset/src/stories/organization-profile-general.stories.tsx @@ -1,8 +1,11 @@ /** @jsxImportSource @emotion/react */ -import { OrganizationProfileGeneral } from '@clerk/ui/mosaic/panels/organization-profile-general'; +import { OrganizationProfileGeneralView } from '@clerk/ui/mosaic/panels/organization-profile-general-view'; import type { StoryMeta } from '@/lib/types'; +import { Default as DeleteOrganizationDemo } from './delete-organization.stories'; +import { Default as LeaveOrganizationDemo } from './leave-organization.stories'; + export const meta: StoryMeta = { group: 'Panels', title: 'OrganizationProfileGeneral', @@ -11,5 +14,10 @@ export const meta: StoryMeta = { }; export function Default() { - return ; + return ( + } + deleteOrganization={} + /> + ); } diff --git a/packages/swingset/src/stories/organization-profile.stories.tsx b/packages/swingset/src/stories/organization-profile.stories.tsx index e609e3854f8..d6bc644fd36 100644 --- a/packages/swingset/src/stories/organization-profile.stories.tsx +++ b/packages/swingset/src/stories/organization-profile.stories.tsx @@ -1,8 +1,10 @@ /** @jsxImportSource @emotion/react */ -import { OrganizationProfile } from '@clerk/ui/mosaic/aio/organization-profile'; +import { OrganizationProfileView } from '@clerk/ui/mosaic/aio/organization-profile-view'; import type { StoryMeta } from '@/lib/types'; +import { Default as OrganizationProfileGeneralDemo } from './organization-profile-general.stories'; + export const meta: StoryMeta = { group: 'AIO', title: 'OrganizationProfile', @@ -11,5 +13,5 @@ export const meta: StoryMeta = { }; export function Default() { - return ; + return } />; } diff --git a/packages/ui/src/mosaic/aio/organization-profile-view.tsx b/packages/ui/src/mosaic/aio/organization-profile-view.tsx new file mode 100644 index 00000000000..83f7a80e558 --- /dev/null +++ b/packages/ui/src/mosaic/aio/organization-profile-view.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; + +import { Box } from '../components/box'; +import { Tabs } from '../components/tabs'; + +interface OrganizationProfileViewProps { + /** The General tab's panel content. */ + general: ReactNode; +} + +export function OrganizationProfileView({ general }: OrganizationProfileViewProps) { + return ( + +

} + sx={t => ({ + ...t.text('lg'), + fontWeight: t.font.semibold, + marginBlockEnd: t.spacing(8), + })} + > + Organization Profile + + + + General + Members + + {general} + +

} + sx={t => ({ + ...t.text('base'), + fontWeight: t.font.medium, + textAlign: 'center', + })} + > + Members content + + + + + ); +} diff --git a/packages/ui/src/mosaic/aio/organization-profile.tsx b/packages/ui/src/mosaic/aio/organization-profile.tsx index 743da8386b5..01f59620361 100644 --- a/packages/ui/src/mosaic/aio/organization-profile.tsx +++ b/packages/ui/src/mosaic/aio/organization-profile.tsx @@ -1,45 +1,6 @@ -import { Box } from '../components/box'; -import { Tabs } from '../components/tabs'; import { OrganizationProfileGeneral } from '../panels/organization-profile-general'; +import { OrganizationProfileView } from './organization-profile-view'; export function OrganizationProfile() { - return ( - ({ - width: '100%', - })} - > -

} - sx={t => ({ - ...t.text('lg'), - fontWeight: t.font.semibold, - marginBlockEnd: t.spacing(8), - })} - > - Organization Profile - - - - General - Members - - - - - -

} - sx={t => ({ - ...t.text('base'), - fontWeight: t.font.medium, - textAlign: 'center', - })} - > - Members content - - - - - ); + return } />; } diff --git a/packages/ui/src/mosaic/mock/organization-store.ts b/packages/ui/src/mosaic/mock/organization-store.ts deleted file mode 100644 index 3a1fb367992..00000000000 --- a/packages/ui/src/mosaic/mock/organization-store.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Mock timing constants for the org-profile prototype. - * - * Simulated async only — no real SDK. The loading state itself lives in local - * component state in `useOrganization()`. - */ - -/** Time until the simulated org load resolves. */ -export const LOAD_DELAY_MS = 600; -/** Artificial latency for `destroy()` mutations. */ -export const MUTATION_DELAY_MS = 2000; - -export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); diff --git a/packages/ui/src/mosaic/mock/use-organization.tsx b/packages/ui/src/mosaic/mock/use-organization.tsx deleted file mode 100644 index 37da1a4daf3..00000000000 --- a/packages/ui/src/mosaic/mock/use-organization.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { delay, LOAD_DELAY_MS, MUTATION_DELAY_MS } from './organization-store'; - -/** - * Mock `useOrganization` for prototyping Mosaic organization-profile components. - * - * Mirrors the shape of the real hook in - * `packages/shared/src/react/hooks/useOrganization.tsx`: a - * `{ isLoaded, organization, membership }` discriminated union where - * `organization.destroy()` deletes the org and `membership.destroy()` leaves it. - * - * Loading state is held in local component state and flips to "loaded" after a - * simulated delay. Simulated async only — no real SDK. - */ - -export interface MockOrganization { - id: string; - name: string; - slug: string | null; - membersCount: number; - /** Delete the entire organization (admin-only in the real API). */ - destroy: () => Promise; -} - -export interface MockMembership { - id: string; - /** e.g. 'org:admin' | 'org:member' */ - role: string; - /** Leave the organization (removes the current member). */ - destroy: () => Promise; -} - -// Mirrors the real discriminated union: while loading, every field is `undefined`. -export type UseOrganizationReturn = - | { isLoaded: false; organization: undefined; membership: undefined } - | { isLoaded: true; organization: MockOrganization | null; membership: MockMembership | null }; - -export function useOrganization(): UseOrganizationReturn { - // Starts `false` so SSR markup matches the first client render, then flips after the delay. - const [isLoaded, setIsLoaded] = useState(false); - - useEffect(() => { - const timer = setTimeout(() => setIsLoaded(true), LOAD_DELAY_MS); - return () => clearTimeout(timer); - }, []); - - if (!isLoaded) { - return { isLoaded: false, organization: undefined, membership: undefined }; - } - - return { - isLoaded: true, - organization: { - id: 'org_mock', - name: "Alex's Organization", - slug: 'alex-org', - membersCount: 4, - destroy: () => delay(MUTATION_DELAY_MS), - }, - membership: { - id: 'mem_mock', - role: 'org:admin', - destroy: () => delay(MUTATION_DELAY_MS), - }, - }; -} diff --git a/packages/ui/src/mosaic/panels/organization-profile-general-view.tsx b/packages/ui/src/mosaic/panels/organization-profile-general-view.tsx new file mode 100644 index 00000000000..64e4793d9c1 --- /dev/null +++ b/packages/ui/src/mosaic/panels/organization-profile-general-view.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; + +import { Box } from '../components/box'; +import { alpha } from '../utils'; + +interface OrganizationProfileGeneralViewProps { + /** The leave-organization section, rendered above the divider. */ + leaveOrganization: ReactNode; + /** The delete-organization section, rendered below the divider. */ + deleteOrganization: ReactNode; +} + +export function OrganizationProfileGeneralView({ + leaveOrganization, + deleteOrganization, +}: OrganizationProfileGeneralViewProps) { + return ( + + {leaveOrganization} + ({ + height: '1px', + background: `light-dark(${alpha('#000', 10)},${alpha('#fff', 10)})`, + marginBlock: t.spacing(4), + })} + /> + {deleteOrganization} + + ); +} diff --git a/packages/ui/src/mosaic/panels/organization-profile-general.tsx b/packages/ui/src/mosaic/panels/organization-profile-general.tsx index fc346ff9f75..7aca4421094 100644 --- a/packages/ui/src/mosaic/panels/organization-profile-general.tsx +++ b/packages/ui/src/mosaic/panels/organization-profile-general.tsx @@ -1,25 +1,12 @@ -import { Box } from '../components/box'; import { DeleteOrganization } from '../sections/delete-organization'; import { LeaveOrganization } from '../sections/leave-organization'; -import { alpha } from '../utils'; +import { OrganizationProfileGeneralView } from './organization-profile-general-view'; export function OrganizationProfileGeneral() { return ( - ({ - width: '100%', - containerType: 'inline-size', - })} - > - - ({ - height: '1px', - background: `light-dark(${alpha('#000', 10)},${alpha('#fff', 10)})`, - marginBlock: t.spacing(4), - })} - /> - - + } + deleteOrganization={} + /> ); } diff --git a/packages/ui/src/mosaic/sections/__tests__/delete-organization-controller.test.tsx b/packages/ui/src/mosaic/sections/__tests__/delete-organization-controller.test.tsx new file mode 100644 index 00000000000..f0a7c3d1067 --- /dev/null +++ b/packages/ui/src/mosaic/sections/__tests__/delete-organization-controller.test.tsx @@ -0,0 +1,91 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../machines/__tests__/test-utils'; +import { useDeleteOrganizationController } from '../delete-organization-controller'; + +const ORG_NAME = 'Acme Inc'; + +let destroy: ReturnType; +let revalidate: ReturnType; +let organization: { id: string; name: string; destroy: () => Promise } | null; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useOrganization: () => ({ isLoaded: true, organization, membership: null }), + useOrganizationList: () => ({ userMemberships: { revalidate } }), + }; +}); + +beforeEach(() => { + destroy = vi.fn(); + revalidate = vi.fn().mockResolvedValue(undefined); + organization = { id: 'org_1', name: ORG_NAME, destroy }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function Harness() { + const controller = useDeleteOrganizationController(); + if (controller.status !== 'ready') { + return loading; + } + return ( +
+ {controller.snapshot.value} + {controller.snapshot.context.error ?? ''} + + + +
+ ); +} + +function openAndConfirm() { + fireEvent.click(screen.getByText('Open')); + fireEvent.click(screen.getByText('Type')); + fireEvent.click(screen.getByText('Confirm')); +} + +describe('useDeleteOrganizationController', () => { + it('drives CONFIRM → deleting → resolve → deleted', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + expect(screen.getByTestId('state')).toHaveTextContent('idle'); + + openAndConfirm(); + expect(screen.getByTestId('state')).toHaveTextContent('deleting'); + + await act(async () => { + gate.resolve(); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('deleted'); + expect(destroy).toHaveBeenCalledTimes(1); + expect(revalidate).toHaveBeenCalledTimes(1); + }); + + it('returns to confirming with an error message when deleting rejects', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + openAndConfirm(); + expect(screen.getByTestId('state')).toHaveTextContent('deleting'); + + await act(async () => { + gate.reject(new Error('nope')); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('confirming'); + expect(screen.getByTestId('error')).toHaveTextContent('nope'); + expect(revalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/sections/__tests__/leave-organization-controller.test.tsx b/packages/ui/src/mosaic/sections/__tests__/leave-organization-controller.test.tsx new file mode 100644 index 00000000000..316edc44ae7 --- /dev/null +++ b/packages/ui/src/mosaic/sections/__tests__/leave-organization-controller.test.tsx @@ -0,0 +1,93 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../machines/__tests__/test-utils'; +import { useLeaveOrganizationController } from '../leave-organization-controller'; + +const ORG_NAME = 'Acme Inc'; + +let destroy: ReturnType; +let revalidate: ReturnType; +let organization: { id: string; name: string } | null; +let membership: { id: string; destroy: () => Promise } | null; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useOrganization: () => ({ isLoaded: true, organization, membership }), + useOrganizationList: () => ({ userMemberships: { revalidate } }), + }; +}); + +beforeEach(() => { + destroy = vi.fn(); + revalidate = vi.fn().mockResolvedValue(undefined); + organization = { id: 'org_1', name: ORG_NAME }; + membership = { id: 'mem_1', destroy }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function Harness() { + const controller = useLeaveOrganizationController(); + if (controller.status !== 'ready') { + return loading; + } + return ( +
+ {controller.snapshot.value} + {controller.snapshot.context.error ?? ''} + + + +
+ ); +} + +function openAndConfirm() { + fireEvent.click(screen.getByText('Open')); + fireEvent.click(screen.getByText('Type')); + fireEvent.click(screen.getByText('Confirm')); +} + +describe('useLeaveOrganizationController', () => { + it('drives CONFIRM → leaving → resolve → left', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + expect(screen.getByTestId('state')).toHaveTextContent('idle'); + + openAndConfirm(); + expect(screen.getByTestId('state')).toHaveTextContent('leaving'); + + await act(async () => { + gate.resolve(); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('left'); + expect(destroy).toHaveBeenCalledTimes(1); + expect(revalidate).toHaveBeenCalledTimes(1); + }); + + it('returns to confirming with an error message when leaving rejects', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + openAndConfirm(); + expect(screen.getByTestId('state')).toHaveTextContent('leaving'); + + await act(async () => { + gate.reject(new Error('nope')); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('confirming'); + expect(screen.getByTestId('error')).toHaveTextContent('nope'); + expect(revalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/sections/delete-organization-controller.tsx b/packages/ui/src/mosaic/sections/delete-organization-controller.tsx index d46bd67fe89..3beab341616 100644 --- a/packages/ui/src/mosaic/sections/delete-organization-controller.tsx +++ b/packages/ui/src/mosaic/sections/delete-organization-controller.tsx @@ -1,13 +1,23 @@ +import { useOrganization, useOrganizationList } from '@clerk/shared/react'; + import { useMachine } from '../machine/useMachine'; -import { useOrganization } from '../mock/use-organization'; import { deleteOrgMachine } from './delete-organization-machine'; export function useDeleteOrganizationController() { const { isLoaded, organization } = useOrganization(); + const { userMemberships } = useOrganizationList({ userMemberships: true }); + const [snapshot, send, actor] = useMachine(deleteOrgMachine, { context: { organizationName: organization?.name ?? '', - destroyOrganization: () => organization?.destroy() ?? Promise.resolve(), + destroyOrganization: async () => { + await organization?.destroy(); + // Refresh org lists elsewhere (e.g. the switcher). Not awaited: a stale + // list must not make a successful delete look like it failed. + void userMemberships.revalidate?.(); + // TODO(mosaic): navigate away from the deleted org's profile once the flow + // has router context, mirroring legacy navigateAfterLeaveOrganization. + }, }, }); diff --git a/packages/ui/src/mosaic/sections/leave-organization-controller.tsx b/packages/ui/src/mosaic/sections/leave-organization-controller.tsx index 7f10440c7b7..6124337b770 100644 --- a/packages/ui/src/mosaic/sections/leave-organization-controller.tsx +++ b/packages/ui/src/mosaic/sections/leave-organization-controller.tsx @@ -1,13 +1,23 @@ +import { useOrganization, useOrganizationList } from '@clerk/shared/react'; + import { useMachine } from '../machine/useMachine'; -import { useOrganization } from '../mock/use-organization'; import { leaveOrgMachine } from './leave-organization-machine'; export function useLeaveOrganizationController() { const { isLoaded, organization, membership } = useOrganization(); + const { userMemberships } = useOrganizationList({ userMemberships: true }); + const [snapshot, send, actor] = useMachine(leaveOrgMachine, { context: { organizationName: organization?.name ?? '', - leaveOrganization: () => membership?.destroy() ?? Promise.resolve(), + leaveOrganization: async () => { + await membership?.destroy(); + // Refresh org lists elsewhere (e.g. the switcher). Not awaited: a stale + // list must not make a successful leave look like it failed. + void userMemberships.revalidate?.(); + // TODO(mosaic): navigate away from the left org's profile once the flow + // has router context, mirroring legacy navigateAfterLeaveOrganization. + }, }, });