diff --git a/.changeset/wild-members-gather.md b/.changeset/wild-members-gather.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wild-members-gather.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 9bff6e8b8b5..03dd01f2d26 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -15,10 +15,12 @@ const docModules: Record> = { }, panels: { 'organization-profile-general': dynamic(() => import('../stories/organization-profile-general.mdx')), + 'organization-profile-members': dynamic(() => import('../stories/organization-profile-members.mdx')), }, sections: { 'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')), 'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')), + 'members-list': dynamic(() => import('../stories/members-list.mdx')), }, blocks: { destructive: dynamic(() => import('../stories/destructive.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 6ae09736f4f..2629e81b7ef 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -41,6 +41,7 @@ import { Default as LeaveOrganizationDefault, meta as leaveOrganizationMeta, } from '../stories/leave-organization.stories'; +import { Default as MembersListDefault, meta as membersListMeta } from '../stories/members-list.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { Default as OrganizationProfileDefault, @@ -50,6 +51,10 @@ import { Default as OrganizationProfileGeneralDefault, meta as organizationProfileGeneralMeta, } from '../stories/organization-profile-general.stories'; +import { + Default as OrganizationProfileMembersDefault, + meta as organizationProfileMembersMeta, +} from '../stories/organization-profile-members.stories'; import { meta as otpMeta } from '../stories/otp.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; import { meta as selectMeta } from '../stories/select.stories'; @@ -74,6 +79,11 @@ const organizationProfileGeneralModule: StoryModule = { meta: organizationProfileGeneralMeta, Default: OrganizationProfileGeneralDefault, }; +const organizationProfileMembersModule: StoryModule = { + meta: organizationProfileMembersMeta, + Default: OrganizationProfileMembersDefault, +}; +const membersListModule: StoryModule = { meta: membersListMeta, Default: MembersListDefault }; const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered }; @@ -125,9 +135,11 @@ export const registry: StoryModule[] = [ organizationProfileModule, // Panels organizationProfileGeneralModule, + organizationProfileMembersModule, // Sections leaveOrganizationModule, deleteOrganizationModule, + membersListModule, // Blocks destructiveModule, // Components diff --git a/packages/swingset/src/stories/members-list.mdx b/packages/swingset/src/stories/members-list.mdx new file mode 100644 index 00000000000..82cb900ce96 --- /dev/null +++ b/packages/swingset/src/stories/members-list.mdx @@ -0,0 +1,15 @@ +import * as MembersListStories from './members-list.stories'; + +# Members List + +Lists an organization's active members with search, pagination, and per-row removal. The section owns the search (uncommitted value vs. committed query) and removal flow in its machine, derives plain rows from the Clerk memberships resource in its controller, and renders them through the Mosaic table, input, and button components — so the view stays free of Clerk resources. + + diff --git a/packages/swingset/src/stories/members-list.stories.tsx b/packages/swingset/src/stories/members-list.stories.tsx new file mode 100644 index 00000000000..a9fbeb320f2 --- /dev/null +++ b/packages/swingset/src/stories/members-list.stories.tsx @@ -0,0 +1,74 @@ +/** @jsxImportSource @emotion/react */ +import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; +import type { MemberRow } from '@clerk/ui/mosaic/sections/members-list.controller'; +import { membersListMachine } from '@clerk/ui/mosaic/sections/members-list.machine'; +import { MembersListView } from '@clerk/ui/mosaic/sections/members-list.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Sections', + title: 'MembersList', + label: 'Members List', + source: 'packages/ui/src/mosaic/sections/members-list.tsx', +}; + +const DEMO_MEMBERS: Omit[] = [ + { + id: 'mem_1', + name: 'Ada Lovelace', + identifier: 'ada@example.com', + roleLabel: 'Admin', + joinedAt: '1/12/2024', + isCurrentUser: true, + isBanned: false, + }, + { + id: 'mem_2', + name: 'Alan Turing', + identifier: 'alan@example.com', + roleLabel: 'Member', + joinedAt: '3/4/2024', + isCurrentUser: false, + isBanned: false, + }, + { + id: 'mem_3', + name: 'Grace Hopper', + identifier: 'grace@example.com', + roleLabel: 'Member', + joinedAt: '6/9/2024', + isCurrentUser: false, + isBanned: true, + }, +]; + +export function Default() { + const [snapshot, send] = useMachine(membersListMachine); + const [page, setPage] = useState(1); + + const rows: MemberRow[] = DEMO_MEMBERS.map(member => ({ + ...member, + onRemove: () => + send({ + type: 'REMOVE_MEMBER', + membershipId: member.id, + // Simulate the network round-trip so the row shows its "Removing…" state. + run: () => new Promise(resolve => setTimeout(resolve, 700)), + }), + })); + + return ( + + ); +} diff --git a/packages/swingset/src/stories/organization-profile-members.mdx b/packages/swingset/src/stories/organization-profile-members.mdx new file mode 100644 index 00000000000..2ff1c78267c --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members.mdx @@ -0,0 +1,11 @@ +import * as OrganizationProfileMembersStories from './organization-profile-members.stories'; + +# Organization Profile Members + +The Members tab panel of the Organization Profile — composes the active-members list shown under "Members". + + diff --git a/packages/swingset/src/stories/organization-profile-members.stories.tsx b/packages/swingset/src/stories/organization-profile-members.stories.tsx new file mode 100644 index 00000000000..d90dcca9b11 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members.stories.tsx @@ -0,0 +1,17 @@ +/** @jsxImportSource @emotion/react */ +import { OrganizationProfileMembersView } from '@clerk/ui/mosaic/panels/organization-profile-members-view'; + +import type { StoryMeta } from '@/lib/types'; + +import { Default as MembersListDemo } from './members-list.stories'; + +export const meta: StoryMeta = { + group: 'Panels', + title: 'OrganizationProfileMembers', + label: 'Org Profile Members', + source: 'packages/ui/src/mosaic/panels/organization-profile-members.tsx', +}; + +export function Default() { + return } />; +} diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx index bf473a8c921..103a0397eff 100644 --- a/packages/swingset/src/stories/organization-profile.mdx +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -9,6 +9,7 @@ The full Organization Profile AIO — lays out the organization panels under a t storyModule={OrganizationProfileStories} composition={[ { name: 'OrganizationProfileGeneral', href: '/panels/organization-profile-general', layer: 'Panels' }, + { name: 'OrganizationProfileMembers', href: '/panels/organization-profile-members', layer: 'Panels' }, { name: 'Tabs', href: '/components/tabs', layer: 'Components' }, ]} /> diff --git a/packages/swingset/src/stories/organization-profile.stories.tsx b/packages/swingset/src/stories/organization-profile.stories.tsx index d6bc644fd36..915b97e273b 100644 --- a/packages/swingset/src/stories/organization-profile.stories.tsx +++ b/packages/swingset/src/stories/organization-profile.stories.tsx @@ -4,6 +4,7 @@ import { OrganizationProfileView } from '@clerk/ui/mosaic/aio/organization-profi import type { StoryMeta } from '@/lib/types'; import { Default as OrganizationProfileGeneralDemo } from './organization-profile-general.stories'; +import { Default as OrganizationProfileMembersDemo } from './organization-profile-members.stories'; export const meta: StoryMeta = { group: 'AIO', @@ -13,5 +14,10 @@ export const meta: StoryMeta = { }; export function Default() { - return } />; + return ( + } + members={} + /> + ); } diff --git a/packages/ui/src/mosaic/aio/organization-profile-view.tsx b/packages/ui/src/mosaic/aio/organization-profile-view.tsx index 83f7a80e558..acee9fdec4b 100644 --- a/packages/ui/src/mosaic/aio/organization-profile-view.tsx +++ b/packages/ui/src/mosaic/aio/organization-profile-view.tsx @@ -6,9 +6,11 @@ import { Tabs } from '../components/tabs'; interface OrganizationProfileViewProps { /** The General tab's panel content. */ general: ReactNode; + /** The Members tab's panel content. */ + members: ReactNode; } -export function OrganizationProfileView({ general }: OrganizationProfileViewProps) { +export function OrganizationProfileView({ general, members }: OrganizationProfileViewProps) { return ( Members {general} - -

} - sx={t => ({ - ...t.text('base'), - fontWeight: t.font.medium, - textAlign: 'center', - })} - > - Members content - - + {members} ); diff --git a/packages/ui/src/mosaic/aio/organization-profile.tsx b/packages/ui/src/mosaic/aio/organization-profile.tsx index 01f59620361..4552ddccd3a 100644 --- a/packages/ui/src/mosaic/aio/organization-profile.tsx +++ b/packages/ui/src/mosaic/aio/organization-profile.tsx @@ -1,6 +1,12 @@ import { OrganizationProfileGeneral } from '../panels/organization-profile-general'; +import { OrganizationProfileMembers } from '../panels/organization-profile-members'; import { OrganizationProfileView } from './organization-profile-view'; export function OrganizationProfile() { - return } />; + return ( + } + members={} + /> + ); } diff --git a/packages/ui/src/mosaic/panels/organization-profile-members-view.tsx b/packages/ui/src/mosaic/panels/organization-profile-members-view.tsx new file mode 100644 index 00000000000..3c2fdb8192c --- /dev/null +++ b/packages/ui/src/mosaic/panels/organization-profile-members-view.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +import { Box } from '../components/box'; + +interface OrganizationProfileMembersViewProps { + /** The active-members list section. */ + membersList: ReactNode; +} + +export function OrganizationProfileMembersView({ membersList }: OrganizationProfileMembersViewProps) { + return ( + + {membersList} + + ); +} diff --git a/packages/ui/src/mosaic/panels/organization-profile-members.tsx b/packages/ui/src/mosaic/panels/organization-profile-members.tsx new file mode 100644 index 00000000000..241e71ca208 --- /dev/null +++ b/packages/ui/src/mosaic/panels/organization-profile-members.tsx @@ -0,0 +1,6 @@ +import { MembersList } from '../sections/members-list'; +import { OrganizationProfileMembersView } from './organization-profile-members-view'; + +export function OrganizationProfileMembers() { + return } />; +} diff --git a/packages/ui/src/mosaic/sections/__tests__/members-list.controller.test.tsx b/packages/ui/src/mosaic/sections/__tests__/members-list.controller.test.tsx new file mode 100644 index 00000000000..3819ad83147 --- /dev/null +++ b/packages/ui/src/mosaic/sections/__tests__/members-list.controller.test.tsx @@ -0,0 +1,213 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../machines/__tests__/test-utils'; +import { useMembersListController } from '../members-list.controller'; + +const READ = 'org:sys_memberships:read'; +const MANAGE = 'org:sys_memberships:manage'; + +let permissions: string[]; +let user: { id: string } | null; +let isSessionLoaded: boolean; +let isOrganizationLoaded: boolean; +let organization: { id: string } | null; +let memberships: { + data: Array>; + count: number; + page: number; + pageCount: number; + isLoading: boolean; + fetchPage: ReturnType; + revalidate: ReturnType; +} | null; +let destroy: ReturnType; +let revalidate: ReturnType; +let fetchPage: ReturnType; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useOrganization: () => ({ isLoaded: isOrganizationLoaded, organization, memberships }), + useSession: () => ({ + isLoaded: isSessionLoaded, + session: isSessionLoaded + ? { + id: 'sess_1', + checkAuthorization: ({ permission }: { permission: string }) => permissions.includes(permission), + } + : undefined, + }), + useUser: () => ({ user }), + }; +}); + +beforeEach(() => { + permissions = [READ, MANAGE]; + user = { id: 'user_self' }; + isSessionLoaded = true; + isOrganizationLoaded = true; + organization = { id: 'org_1' }; + destroy = vi.fn().mockResolvedValue(undefined); + revalidate = vi.fn().mockResolvedValue(undefined); + fetchPage = vi.fn(); + memberships = { + data: [ + { + id: 'mem_1', + publicUserData: { userId: 'user_self', firstName: 'Alice', lastName: 'Doe', identifier: 'alice@example.com' }, + role: 'org:admin', + roleName: 'Admin', + createdAt: new Date('2024-01-01T00:00:00Z'), + destroy, + }, + { + id: 'mem_2', + publicUserData: { + userId: 'user_2', + firstName: 'Bob', + lastName: null, + identifier: 'bob@example.com', + banned: true, + }, + role: 'org:member', + roleName: 'Member', + createdAt: new Date('2024-02-01T00:00:00Z'), + destroy, + }, + ], + count: 2, + page: 1, + pageCount: 1, + isLoading: false, + fetchPage, + revalidate, + }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function Harness() { + const controller = useMembersListController(); + if (controller.status !== 'ready') { + return {controller.status}; + } + return ( +
+ {controller.snapshot.value} + {controller.snapshot.context.error ?? ''} + {controller.snapshot.context.query} + {String(controller.canManage)} + + {controller.rows.map(r => `${r.name}|${r.roleLabel}|${r.isCurrentUser}|${r.isBanned}`).join(';')} + + + + + {controller.rows.map(r => ( + + ))} +
+ ); +} + +describe('useMembersListController', () => { + it('is loading until the session is loaded', () => { + isSessionLoaded = false; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('loading'); + }); + + it('is loading until the organization is loaded', () => { + isOrganizationLoaded = false; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('loading'); + }); + + it('is hidden when the user cannot read memberships', () => { + permissions = []; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('hidden'); + }); + + it('is hidden when there is no active organization', () => { + organization = null; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('hidden'); + }); + + it('derives plain rows from the memberships resource', () => { + render(); + + expect(screen.getByTestId('state')).toHaveTextContent('ready'); + expect(screen.getByTestId('rows')).toHaveTextContent('Alice Doe|Admin|true|false;Bob|Member|false|true'); + }); + + it('reports canManage from the manage permission', () => { + permissions = [READ]; + render(); + + expect(screen.getByTestId('state')).toHaveTextContent('ready'); + expect(screen.getByTestId('canManage')).toHaveTextContent('false'); + }); + + it('commits the search query only on submit', () => { + render(); + + fireEvent.click(screen.getByText('ChangeSearch')); + expect(screen.getByTestId('query')).toHaveTextContent(''); + + fireEvent.click(screen.getByText('SubmitSearch')); + expect(screen.getByTestId('query')).toHaveTextContent('z'); + }); + + it('drives removal through destroy + revalidate', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + + fireEvent.click(screen.getByText('Remove mem_2')); + expect(screen.getByTestId('state')).toHaveTextContent('removing'); + expect(destroy).toHaveBeenCalledTimes(1); + + await act(async () => { + gate.resolve(); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('ready'); + expect(revalidate).toHaveBeenCalledTimes(1); + }); + + it('surfaces a removal error on the snapshot', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + fireEvent.click(screen.getByText('Remove mem_2')); + + await act(async () => { + gate.reject(new Error('cannot remove last admin')); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('ready'); + expect(screen.getByTestId('error')).toHaveTextContent('cannot remove last admin'); + expect(revalidate).not.toHaveBeenCalled(); + }); + + it('fetches a new page via the memberships resource', () => { + render(); + + fireEvent.click(screen.getByText('Page2')); + + expect(fetchPage).toHaveBeenCalledWith(2); + }); +}); diff --git a/packages/ui/src/mosaic/sections/__tests__/members-list.machine.test.ts b/packages/ui/src/mosaic/sections/__tests__/members-list.machine.test.ts new file mode 100644 index 00000000000..04442ad723d --- /dev/null +++ b/packages/ui/src/mosaic/sections/__tests__/members-list.machine.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { membersListMachine } from '../members-list.machine'; + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +describe('membersListMachine', () => { + it('holds the search value uncommitted until SEARCH_SUBMIT', () => { + const actor = createActor(membersListMachine); + actor.start(); + + actor.send({ type: 'SEARCH_CHANGE', value: 'alice' }); + expect(actor.getSnapshot().context.search).toBe('alice'); + // The committed query the controller keys off is unchanged until submit. + expect(actor.getSnapshot().context.query).toBe(''); + + actor.send({ type: 'SEARCH_SUBMIT' }); + expect(actor.getSnapshot().context.query).toBe('alice'); + }); + + it('invokes the injected remove effect and returns to ready on success', async () => { + const run = vi.fn(() => Promise.resolve()); + const actor = createActor(membersListMachine); + actor.start(); + + actor.send({ type: 'REMOVE_MEMBER', membershipId: 'mem_1', run }); + + expect(actor.getSnapshot().value).toBe('removing'); + expect(actor.getSnapshot().context.pendingMembershipId).toBe('mem_1'); + expect(run).toHaveBeenCalledTimes(1); + + await tick(); + + expect(actor.getSnapshot().value).toBe('ready'); + expect(actor.getSnapshot().context.pendingMembershipId).toBeNull(); + expect(actor.getSnapshot().context.error).toBeNull(); + }); + + it('returns to ready with an error message when removal fails', async () => { + const run = vi.fn(() => Promise.reject(new Error('cannot remove'))); + const actor = createActor(membersListMachine); + actor.start(); + + actor.send({ type: 'REMOVE_MEMBER', membershipId: 'mem_1', run }); + await tick(); + + expect(actor.getSnapshot().value).toBe('ready'); + expect(actor.getSnapshot().context.pendingMembershipId).toBeNull(); + expect(actor.getSnapshot().context.error).toBe('cannot remove'); + }); + + it('clears a prior error when a new removal starts', async () => { + const actor = createActor(membersListMachine); + actor.start(); + + actor.send({ type: 'REMOVE_MEMBER', membershipId: 'mem_1', run: () => Promise.reject(new Error('boom')) }); + await tick(); + expect(actor.getSnapshot().context.error).toBe('boom'); + + actor.send({ type: 'REMOVE_MEMBER', membershipId: 'mem_2', run: () => Promise.resolve() }); + expect(actor.getSnapshot().context.error).toBeNull(); + }); +}); diff --git a/packages/ui/src/mosaic/sections/__tests__/members-list.view.test.tsx b/packages/ui/src/mosaic/sections/__tests__/members-list.view.test.tsx new file mode 100644 index 00000000000..160b4e66b6a --- /dev/null +++ b/packages/ui/src/mosaic/sections/__tests__/members-list.view.test.tsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Snapshot } from '../../machine/types'; +import { MosaicProvider } from '../../MosaicProvider'; +import type { MemberRow } from '../members-list.controller'; +import type { MembersListContext } from '../members-list.machine'; +import { MembersListView } from '../members-list.view'; + +function snapshot(overrides: Partial = {}): Snapshot { + return { + value: 'ready', + status: 'active', + context: { + search: '', + query: '', + pendingMembershipId: null, + removeMember: async () => {}, + error: null, + ...overrides, + }, + }; +} + +function row(overrides: Partial = {}): MemberRow { + return { + id: 'mem_1', + name: 'Alice Doe', + identifier: 'alice@example.com', + roleLabel: 'Admin', + joinedAt: '1/1/2024', + isCurrentUser: false, + isBanned: false, + onRemove: vi.fn(), + ...overrides, + }; +} + +function renderView(props: Partial[0]> = {}) { + const send = vi.fn(); + const onPageChange = vi.fn(); + render( + + + , + ); + return { send, onPageChange }; +} + +describe('MembersListView', () => { + it('renders a member row with name, identifier, role, and joined date', () => { + renderView(); + + expect(screen.getByText('Alice Doe')).toBeInTheDocument(); + expect(screen.getByText('alice@example.com')).toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); + expect(screen.getByText('1/1/2024')).toBeInTheDocument(); + }); + + it('emits SEARCH_CHANGE while typing without committing the query', () => { + const { send } = renderView(); + + fireEvent.change(screen.getByLabelText('Search members'), { target: { value: 'bob' } }); + + expect(send).toHaveBeenCalledWith({ type: 'SEARCH_CHANGE', value: 'bob' }); + expect(send).not.toHaveBeenCalledWith({ type: 'SEARCH_SUBMIT' }); + }); + + it('emits SEARCH_SUBMIT when the search form is submitted', () => { + const { send } = renderView(); + + const form = screen.getByLabelText('Search members').closest('form'); + expect(form).not.toBeNull(); + fireEvent.submit(form!); + + expect(send).toHaveBeenCalledWith({ type: 'SEARCH_SUBMIT' }); + }); + + it('marks the current user with a badge and disables their remove action', () => { + renderView({ rows: [row({ isCurrentUser: true })] }); + + expect(screen.getByText('You')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove' })).toBeDisabled(); + }); + + it('marks banned members with a badge', () => { + renderView({ rows: [row({ isBanned: true })] }); + + expect(screen.getByText('Banned')).toBeInTheDocument(); + }); + + it('invokes the row remove handler when Remove is clicked', () => { + const onRemove = vi.fn(); + renderView({ rows: [row({ onRemove })] }); + + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('hides the remove action entirely when the user cannot manage members', () => { + renderView({ canManage: false }); + + expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument(); + expect(screen.queryByText('Actions')).not.toBeInTheDocument(); + }); + + it('disables and relabels the row currently being removed', () => { + renderView({ + snapshot: { ...snapshot({ pendingMembershipId: 'mem_1' }), value: 'removing' }, + rows: [row({ id: 'mem_1' })], + }); + + expect(screen.getByRole('button', { name: 'Removing…' })).toBeDisabled(); + }); + + it('shows an empty state when there are no members and it is not loading', () => { + renderView({ rows: [] }); + + expect(screen.getByText('There are no members to display')).toBeInTheDocument(); + }); + + it('does not show the empty state while loading', () => { + renderView({ rows: [], isLoading: true }); + + expect(screen.queryByText('There are no members to display')).not.toBeInTheDocument(); + }); + + it('surfaces a machine error without Clerk fixtures', () => { + renderView({ snapshot: snapshot({ error: 'Removal failed' }) }); + + expect(screen.getByRole('alert')).toHaveTextContent('Removal failed'); + }); + + it('renders pagination and moves pages via onPageChange', () => { + const { onPageChange } = renderView({ page: 2, pageCount: 3 }); + + expect(screen.getByText('Page 2 of 3')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(onPageChange).toHaveBeenCalledWith(3); + + fireEvent.click(screen.getByRole('button', { name: 'Previous' })); + expect(onPageChange).toHaveBeenCalledWith(1); + }); + + it('hides pagination when there is a single page', () => { + renderView({ page: 1, pageCount: 1 }); + + expect(screen.queryByText(/Page 1 of/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/sections/members-list.controller.tsx b/packages/ui/src/mosaic/sections/members-list.controller.tsx new file mode 100644 index 00000000000..97e6e4d9afc --- /dev/null +++ b/packages/ui/src/mosaic/sections/members-list.controller.tsx @@ -0,0 +1,120 @@ +import { useOrganization, useSession, useUser } from '@clerk/shared/react'; + +import type { Snapshot } from '../machine/types'; +import { useMachine } from '../machine/useMachine'; +import type { MembersListContext, MembersListEvent } from './members-list.machine'; +import { membersListMachine } from './members-list.machine'; + +/** A single active member, reduced to plain data the view can render without Clerk. */ +export interface MemberRow { + id: string; + /** Display name, falling back to the identifier when no name is set. */ + name: string; + /** Email / phone / username used to identify the user. */ + identifier: string; + /** Human-readable role label (`roleName`, falling back to the raw role key). */ + roleLabel: string; + /** Locale-formatted join date. */ + joinedAt: string; + /** Whether this row is the signed-in user (their own remove action is disabled). */ + isCurrentUser: boolean; + /** Whether the member is banned (surfaced as a badge). */ + isBanned: boolean; + /** Dispatches the removal for this member; wired by the controller so the view stays Clerk-free. */ + onRemove: () => void; +} + +type MembersListController = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + snapshot: Snapshot; + send: (event: MembersListEvent) => void; + rows: MemberRow[]; + /** Whether the user can manage memberships (gates the remove action). */ + canManage: boolean; + page: number; + pageCount: number; + /** First-page load with no data yet — the view shows a skeleton. */ + isLoading: boolean; + onPageChange: (page: number) => void; + }; + +function memberName(firstName: string | null, lastName: string | null, identifier: string): string { + const full = [firstName, lastName].filter(Boolean).join(' ').trim(); + return full || identifier; +} + +export function useMembersListController(): MembersListController { + const [snapshot, send] = useMachine(membersListMachine); + const { isLoaded: isSessionLoaded, session } = useSession(); + const { user } = useUser(); + + // Permissions gate both what we fetch and what the view can do. Computing them + // from the session (which is available before the org fetch resolves) lets us + // skip requesting memberships the user isn't allowed to read. + const canRead = session?.checkAuthorization({ permission: 'org:sys_memberships:read' }) ?? false; + const canManage = session?.checkAuthorization({ permission: 'org:sys_memberships:manage' }) ?? false; + + const { + isLoaded: isOrganizationLoaded, + organization, + memberships, + } = useOrganization({ + memberships: canRead ? { keepPreviousData: true, query: snapshot.context.query || undefined } : undefined, + }); + + // Both the session and the organization must be resolved before we can decide + // between 'ready' and 'hidden' — treat either still loading as 'loading'. + if (!isSessionLoaded || !isOrganizationLoaded) { + return { status: 'loading' }; + } + + if (!organization || !canRead) { + return { status: 'hidden' }; + } + + const currentUserId = user?.id; + const rows: MemberRow[] = (memberships?.data ?? []).map(membership => { + const publicUserData = membership.publicUserData; + return { + id: membership.id, + name: memberName( + publicUserData?.firstName ?? null, + publicUserData?.lastName ?? null, + publicUserData?.identifier ?? '', + ), + identifier: publicUserData?.identifier ?? '', + roleLabel: membership.roleName || membership.role, + joinedAt: membership.createdAt.toLocaleDateString(), + isCurrentUser: !!currentUserId && publicUserData?.userId === currentUserId, + isBanned: !!publicUserData?.banned, + onRemove: () => + send({ + type: 'REMOVE_MEMBER', + membershipId: membership.id, + run: async () => { + await membership.destroy(); + // Refresh the list so the removed row disappears. Not awaited: a stale + // list must not make a successful removal look like it failed. + void memberships?.revalidate?.(); + }, + }), + }; + }); + + return { + status: 'ready', + snapshot, + send, + rows, + canManage, + page: memberships?.page ?? 1, + pageCount: memberships?.pageCount ?? 0, + isLoading: !!memberships?.isLoading && (memberships?.data?.length ?? 0) === 0, + onPageChange: (page: number) => { + void memberships?.fetchPage?.(page); + }, + }; +} diff --git a/packages/ui/src/mosaic/sections/members-list.machine.ts b/packages/ui/src/mosaic/sections/members-list.machine.ts new file mode 100644 index 00000000000..1c4f675836e --- /dev/null +++ b/packages/ui/src/mosaic/sections/members-list.machine.ts @@ -0,0 +1,70 @@ +import { setup } from '../machine/setup'; + +export interface MembersListContext { + /** The current value of the search box (uncommitted). */ + search: string; + /** The committed query the controller keys the memberships fetch off of. */ + query: string; + /** The membership currently being removed, so the view can disable just that row. */ + pendingMembershipId: string | null; + /** Injected per-removal effect — destroys a membership and revalidates the list. */ + removeMember: () => Promise; + error: string | null; +} + +export type MembersListEvent = + | { type: 'SEARCH_CHANGE'; value: string } + | { type: 'SEARCH_SUBMIT' } + | { type: 'REMOVE_MEMBER'; membershipId: string; run: () => Promise }; + +const { createMachine, assign, fromPromise } = setup(); + +export const membersListMachine = createMachine({ + id: 'membersList', + initial: 'ready', + context: { + search: '', + query: '', + pendingMembershipId: null, + removeMember: async () => {}, + error: null, + }, + states: { + ready: { + on: { + // Typing only updates the uncommitted search value; the fetch is unchanged + // until SEARCH_SUBMIT commits it into `query` (mirrors the legacy + // search/query split so keystrokes don't trigger a request each time). + SEARCH_CHANGE: { + actions: assign((_, event) => ({ search: event.value })), + }, + SEARCH_SUBMIT: { + actions: assign(context => ({ query: context.search })), + }, + REMOVE_MEMBER: { + target: 'removing', + actions: assign((_, event) => ({ + pendingMembershipId: event.membershipId, + removeMember: event.run, + error: null, + })), + }, + }, + }, + removing: { + invoke: fromPromise(context => context.removeMember(), { + onDone: { + target: 'ready', + actions: assign(() => ({ pendingMembershipId: null })), + }, + onError: { + target: 'ready', + actions: assign((_, event) => ({ + pendingMembershipId: null, + error: event.error instanceof Error ? event.error.message : 'Something went wrong. Please try again.', + })), + }, + }), + }, + }, +}); diff --git a/packages/ui/src/mosaic/sections/members-list.tsx b/packages/ui/src/mosaic/sections/members-list.tsx new file mode 100644 index 00000000000..72c89197dda --- /dev/null +++ b/packages/ui/src/mosaic/sections/members-list.tsx @@ -0,0 +1,21 @@ +import { useMembersListController } from './members-list.controller'; +import { MembersListView } from './members-list.view'; + +export function MembersList() { + const controller = useMembersListController(); + if (controller.status !== 'ready') { + return null; + } + return ( + + ); +} diff --git a/packages/ui/src/mosaic/sections/members-list.view.tsx b/packages/ui/src/mosaic/sections/members-list.view.tsx new file mode 100644 index 00000000000..0690529d10c --- /dev/null +++ b/packages/ui/src/mosaic/sections/members-list.view.tsx @@ -0,0 +1,295 @@ +import type { ReactNode } from 'react'; + +import { Box } from '../components/box'; +import { Button } from '../components/button'; +import { Input } from '../components/input'; +import { Skeleton } from '../components/skeleton'; +import { Text } from '../components/text'; +import type { Snapshot } from '../machine/types'; +import type { MemberRow } from './members-list.controller'; +import type { MembersListContext, MembersListEvent } from './members-list.machine'; + +interface MembersListViewProps { + snapshot: Snapshot; + send: (event: MembersListEvent) => void; + rows: MemberRow[]; + canManage: boolean; + page: number; + pageCount: number; + isLoading: boolean; + onPageChange: (page: number) => void; +} + +/** Small pill used for the "You" / "Banned" row markers. */ +function Badge({ children, intent = 'neutral' }: { children: ReactNode; intent?: 'neutral' | 'destructive' }) { + return ( + } + sx={t => ({ + display: 'inline-flex', + alignItems: 'center', + ...t.text('xs'), + fontWeight: t.font.medium, + paddingInline: t.spacing(1.5), + paddingBlock: t.spacing(0.5), + borderRadius: t.rounded.full, + color: intent === 'destructive' ? t.color.destructive : t.color.mutedForeground, + backgroundColor: intent === 'destructive' ? t.alpha('destructive', 12) : t.alpha('primary', 8), + })} + > + {children} + + ); +} + +function HeaderCell({ children, align = 'start' }: { children?: ReactNode; align?: 'start' | 'end' }) { + return ( + } + sx={t => ({ + ...t.text('xs'), + fontWeight: t.font.medium, + color: t.color.mutedForeground, + textAlign: align, + paddingInline: t.spacing(3), + paddingBlock: t.spacing(2), + whiteSpace: 'nowrap', + })} + > + {children} + + ); +} + +function Cell({ children, align = 'start' }: { children?: ReactNode; align?: 'start' | 'end' }) { + return ( + } + sx={t => ({ + ...t.text('sm'), + textAlign: align, + paddingInline: t.spacing(3), + paddingBlock: t.spacing(3), + verticalAlign: 'middle', + })} + > + {children} + + ); +} + +export function MembersListView({ + snapshot, + send, + rows, + canManage, + page, + pageCount, + isLoading, + onPageChange, +}: MembersListViewProps) { + const isRemoving = snapshot.value === 'removing'; + const columnCount = canManage ? 4 : 3; + + return ( + + ( +
{ + event.preventDefault(); + send({ type: 'SEARCH_SUBMIT' }); + }} + /> + )} + sx={t => ({ + display: 'flex', + gap: t.spacing(2), + marginBlockEnd: t.spacing(4), + })} + > + send({ type: 'SEARCH_CHANGE', value: event.currentTarget.value })} + /> + + + + {snapshot.context.error ? ( + ({ marginBlockEnd: t.spacing(3) })} + > + {snapshot.context.error} + + ) : null} + + } + sx={{ + width: '100%', + borderCollapse: 'collapse', + }} + > + }> + } + sx={t => ({ borderBottom: `1px solid ${t.alpha('primary', 10)}` })} + > + Member + Joined + Role + {canManage ? Actions : null} + + + }> + {isLoading ? ( + Array.from({ length: 3 }).map((_, index) => ( + } + > + {Array.from({ length: columnCount }).map((__, cellIndex) => ( + + + + ))} + + )) + ) : rows.length === 0 ? ( + }> + ( + } + sx={t => ({ borderBottom: `1px solid ${t.alpha('primary', 6)}` })} + > + + ({ display: 'flex', flexDirection: 'column', gap: t.spacing(0.5) })}> + ({ display: 'flex', alignItems: 'center', gap: t.spacing(1.5) })}> + ({ fontWeight: t.font.medium })} + > + {row.name} + + {row.isCurrentUser ? You : null} + {row.isBanned ? Banned : null} + + {row.identifier && row.identifier !== row.name ? ( + + {row.identifier} + + ) : null} + + + + + {row.joinedAt} + + + + {row.roleLabel} + + {canManage ? ( + + + + ) : null} + + ); + }) + )} + + + + {pageCount > 1 ? ( + ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: t.spacing(3), + marginBlockStart: t.spacing(4), + })} + > + + Page {page} of {pageCount} + + + + + ) : null} + + ); +}
+ )} + sx={t => ({ + ...t.text('sm'), + color: t.color.mutedForeground, + textAlign: 'center', + paddingBlock: t.spacing(8), + })} + > + There are no members to display + + + ) : ( + rows.map(row => { + const isRemovingRow = isRemoving && snapshot.context.pendingMembershipId === row.id; + return ( +