From 69ffa32cb7bc86f54c5b5c59af7dca0e8b41abc3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 22 Sep 2026 20:17:13 -0600 Subject: [PATCH 01/11] feat(mosaic): add invitations table tab view --- .changeset/invitations-table-tab.md | 2 + .../invitations-table-tab.view.test.tsx | 130 ++++++++++ .../invitations-table-tab.messages.ts | 23 ++ .../invitations-table-tab.styles.ts | 17 ++ .../invitations-table-tab.types.ts | 30 +++ .../invitations-table-tab.view.tsx | 225 ++++++++++++++++++ packages/mosaic/src/localization/registry.ts | 2 + packages/mosaic/src/styles/index.ts | 1 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 14 ++ .../stories/fixtures/invitations-table-tab.ts | 76 ++++++ .../src/stories/invitations-table-tab.mdx | 31 +++ .../stories/invitations-table-tab.stories.tsx | 58 +++++ 13 files changed, 610 insertions(+) create mode 100644 .changeset/invitations-table-tab.md create mode 100644 packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx create mode 100644 packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts create mode 100644 packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts create mode 100644 packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts create mode 100644 packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx create mode 100644 packages/swingset/src/stories/fixtures/invitations-table-tab.ts create mode 100644 packages/swingset/src/stories/invitations-table-tab.mdx create mode 100644 packages/swingset/src/stories/invitations-table-tab.stories.tsx diff --git a/.changeset/invitations-table-tab.md b/.changeset/invitations-table-tab.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/invitations-table-tab.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx new file mode 100644 index 00000000000..ece69e58555 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import type { InvitationsTableTabViewProps } from '../invitations-table-tab.types'; +import { InvitationsTableTabView } from '../invitations-table-tab.view'; + +function propsFor(overrides: Partial = {}): InvitationsTableTabViewProps { + return { + invitations: [{ id: 'invite-1', email: 'ada@example.com', invitedAtLabel: 'Sep 1, 2026', roleLabel: 'Admin' }], + totalCount: 1, + page: 1, + searchValue: '', + isLoading: false, + onSearchChange: vi.fn(), + onPageChange: vi.fn(), + ...overrides, + }; +} +function renderView(overrides: Partial = {}) { + const props = propsFor(overrides); + return { + props, + ...render( + + + , + ), + }; +} + +describe('InvitationsTableTabView', () => { + it('distinguishes loading, an empty invitation list, and an empty search', () => { + const { props, rerender } = renderView({ invitations: [], totalCount: 0, isLoading: true }); + expect(screen.getByRole('status')).toHaveTextContent('Loading invitations'); + rerender( + + + , + ); + expect(screen.getByText('No pending invitations')).toBeVisible(); + rerender( + + + , + ); + expect(screen.getByText('No invitations found')).toBeVisible(); + rerender( + + + , + ); + expect(screen.getByText('ada@example.com')).toBeVisible(); + expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'true'); + }); + it('connects invitation search, sorting, and paging while clearing the old selection', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ + totalCount: 21, + onBulkAction: vi.fn(), + onSortChange: vi.fn(), + onPageSizeChange: vi.fn(), + }); + await user.click(screen.getByRole('checkbox', { name: 'Select ada@example.com' })); + await user.click(screen.getByRole('button', { name: 'Invited' })); + expect(props.onSortChange).toHaveBeenCalledWith({ column: 'invitedAt', direction: 'ascending' }); + expect(screen.getByRole('checkbox', { name: 'Select ada@example.com' })).not.toBeChecked(); + rerender( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Invited' })); + expect(props.onSortChange).toHaveBeenLastCalledWith({ column: 'invitedAt', direction: 'descending' }); + await user.type(screen.getByRole('searchbox', { name: 'Search invitations' }), 'A'); + expect(props.onSearchChange).toHaveBeenCalledWith('A'); + await user.click(screen.getByRole('button', { name: 'Next invitations page' })); + expect(props.onPageChange).toHaveBeenCalledWith(2); + await user.click(screen.getByRole('combobox', { name: /^Results per page/ })); + await user.click(screen.getByRole('option', { name: '20', exact: true })); + expect(props.onPageSizeChange).toHaveBeenCalledWith(20); + expect(props.onPageChange).toHaveBeenLastCalledWith(1); + expect(props.onBulkAction).not.toHaveBeenCalled(); + }); + it('renders invitation metadata and routes optional invite and revoke commands', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ onInvite: vi.fn(), onRevoke: vi.fn() }); + const table = screen.getByRole('table', { name: 'Invitations' }); + expect( + within(table) + .getAllByRole('columnheader') + .map(header => header.textContent), + ).toEqual(['User', 'Invited', 'Role', 'Actions']); + expect(within(table).getByText('ada@example.com')).toBeVisible(); + expect(within(table).getByText('Sep 1, 2026')).toBeVisible(); + expect(within(table).getByText('Admin')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Invite members' })); + expect(props.onInvite).toHaveBeenCalledOnce(); + await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + expect(props.onRevoke).toHaveBeenCalledWith('invite-1'); + rerender( + + + , + ); + expect(screen.queryByRole('columnheader', { name: 'Actions' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Manage|Invite/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts new file mode 100644 index 00000000000..9ddca940509 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts @@ -0,0 +1,23 @@ +export const invitationsTableTabMessages = { + title: 'Invitations', + email: 'User', + invitedAt: 'Invited', + roleLabel: 'Role', + actions: 'Actions', + search: 'Search invitations', + clearSearch: 'Clear search', + invite: 'Invite members', + empty: 'No invitations found', + emptyDescription: 'Your search for "{query}" did not return any results.', + noInvitations: 'No pending invitations', + noInvitationsDescription: 'Invite people to add them to this organization.', + loading: 'Loading invitations', + selectAll: 'Select all invitations on this page', + select: 'Select {name}', + manage: 'Manage {name}', + revoke: 'Revoke invitation', + pagination: 'Invitations pagination', + previousPage: 'Previous invitations page', + nextPage: 'Next invitations page', + pageSize: 'Results per page', +} as const; diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts new file mode 100644 index 00000000000..9e99c43909b --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts @@ -0,0 +1,17 @@ +import * as stylex from '@stylexjs/stylex'; + +import { fontWeightVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + toolbar: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + search: { maxWidth: '100%', width: '17rem' }, + identity: { gap: space['3'], alignItems: 'center', display: 'flex' }, + name: { gap: space['2'], alignItems: 'center', display: 'flex', fontWeight: fontWeightVars['--cl-font-medium'] }, + dateCell: { whiteSpace: 'nowrap' }, +}); diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts new file mode 100644 index 00000000000..fdd61acc1f2 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts @@ -0,0 +1,30 @@ +export interface OrganizationProfileInvitation { + id: string; + email: string; + imageUrl?: string; + invitedAtLabel: string; + roleLabel: string; +} + +export interface InvitationsTableSort { + column: 'email' | 'invitedAt' | 'roleLabel'; + direction: 'ascending' | 'descending'; +} + +export interface InvitationsTableTabViewProps { + invitations: OrganizationProfileInvitation[]; + totalCount: number; + page: number; + pageSize?: number; + searchValue: string; + isLoading: boolean; + isFetching?: boolean; + onPageChange: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + onSearchChange: (value: string) => void; + onInvite?: () => void; + onRevoke?: (id: string) => void; + onBulkAction?: (ids: string[]) => void; + sort?: InvitationsTableSort | null; + onSortChange?: (sort: InvitationsTableSort | null) => void; +} diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx new file mode 100644 index 00000000000..b5fbc34d42b --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx @@ -0,0 +1,225 @@ +import * as stylex from '@stylexjs/stylex'; +import { useRef } from 'react'; + +import { Avatar } from '../../components/avatar'; +import { Button } from '../../components/button'; +import { EmptyState } from '../../components/empty-state'; +import { Icon } from '../../components/icon'; +import { InputGroup } from '../../components/input-group'; +import { Menu } from '../../components/menu'; +import { Pagination } from '../../components/pagination'; +import { panelStyles } from '../../components/profile'; +import { Spinner } from '../../components/spinner'; +import { Table, type TableHeaderCellProps } from '../../components/table'; +import { Text } from '../../components/text'; +import { VisuallyHidden } from '../../components/visually-hidden'; +import { fill, useMessages } from '../../localization'; +import { useDataTable } from '../../primitives/hooks'; +import { mergeStyleProps, themeProps } from '../../props'; +import { styles } from './invitations-table-tab.styles'; +import type { + InvitationsTableSort, + InvitationsTableTabViewProps, + OrganizationProfileInvitation, +} from './invitations-table-tab.types'; + +const getRowId = (invitation: OrganizationProfileInvitation) => invitation.id; + +export function InvitationsTableTabView({ + invitations, + onInvite, + onRevoke, + totalCount, + page, + pageSize = 10, + searchValue, + onSearchChange, + onPageChange, + onPageSizeChange, + onBulkAction, + sort, + onSortChange, + isLoading, + isFetching = false, +}: InvitationsTableTabViewProps) { + const m = useMessages('invitationsTableTab'); + const columnCount = 3 + Number(Boolean(onRevoke)) + Number(Boolean(onBulkAction)); + const query = searchValue.trim(); + const searchInput = useRef(null); + const table = useDataTable({ data: invitations, getRowId, totalCount }); + const sortHeader = (column: InvitationsTableSort['column']): Pick => ({ + sort: sort?.column === column ? sort.direction : 'none', + onSort: onSortChange + ? () => { + table.setRowSelection({}); + onSortChange( + sort?.column !== column + ? { column, direction: 'ascending' } + : sort.direction === 'ascending' + ? { column, direction: 'descending' } + : null, + ); + } + : undefined, + }); + return ( +
+
+ + + + + { + table.setRowSelection({}); + onSearchChange(event.currentTarget.value); + }} + /> + {searchValue ? ( + + + + ) : null} + + {onInvite ? : null} +
+ + + + {onBulkAction ? ( + + ) : null} + {m.email} + {m.invitedAt} + {m.roleLabel} + {onRevoke ? ( + + {m.actions} + + ) : null} + + + + {isLoading ? ( + + + + {m.loading} + + + ) : table.rows.length === 0 ? ( + + + + {query ? m.empty : m.noInvitations} + + {query ? fill(m.emptyDescription, { query }) : m.noInvitationsDescription} + + + + ) : ( + table.rows.map(row => ( + + {onBulkAction ? ( + + ) : null} + +
+ + {row.original.imageUrl ? ( + + ) : null} + + + {row.original.email} +
+
+ {row.original.invitedAtLabel} + {row.original.roleLabel} + {onRevoke ? ( + + + + + onRevoke(row.id)} + > + {m.revoke} + + + + + ) : null} +
+ )) + )} +
+
+ {totalCount > pageSize || (totalCount > 0 && onPageSizeChange) ? ( + { + table.setRowSelection({}); + onPageChange(next); + }} + onPageSizeChange={ + onPageSizeChange + ? next => { + table.setRowSelection({}); + onPageSizeChange(next); + onPageChange(1); + } + : undefined + } + /> + ) : null} +
+ ); +} diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index b61ac4e864e..45420c68d94 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -1,4 +1,5 @@ import { formMessages } from '../components/form/form.messages'; +import { invitationsTableTabMessages } from '../features/organization-profile/invitations-table-tab.messages'; import { membersTableTabMessages } from '../features/organization-profile/members-table-tab.messages'; import { organizationProfileMessages } from '../features/organization-profile/organization-profile.messages'; import { organizationProfileApiKeysPanelMessages } from '../features/organization-profile/organization-profile-api-keys-panel.messages'; @@ -28,6 +29,7 @@ import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-pr export const mosaicMessages = { form: formMessages, membersTableTab: membersTableTabMessages, + invitationsTableTab: invitationsTableTabMessages, organizationProfile: organizationProfileMessages, organizationProfileDangerSection: organizationProfileDangerSectionMessages, organizationProfileWorkspaceSection: organizationProfileWorkspaceSectionMessages, diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index b2b46e057a1..0a5f5b4be47 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -232,3 +232,4 @@ export { OrganizationProfileApiKeysPanelView } from '../features/organization-pr export { OrganizationProfileCreateAPIKeyDialog } from '../features/organization-profile/organization-profile-create-api-key.dialog'; export { MembersTableTabView } from '../features/organization-profile/members-table-tab.view'; +export { InvitationsTableTabView } from '../features/organization-profile/invitations-table-tab.view'; diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3b475d09e5d..5777e547d95 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -41,6 +41,7 @@ const docModules: Record> = { }, 'organization-profile': { 'organization-profile-members-panel': dynamic(() => import('../stories/organization-profile-members-panel.mdx')), + 'invitations-table-tab': dynamic(() => import('../stories/invitations-table-tab.mdx')), 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), 'organization-profile-workspace-section': dynamic( diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 7376ebed605..44cfa77a001 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -112,6 +112,12 @@ import { meta as inputGroupMeta, Sizes as InputGroupSizes, } from '../stories/input-group.stories'; +import { + Empty as InvitationsTableTabEmpty, + Legacy as InvitationsTableTabLegacy, + meta as invitationsTableTabMeta, + Proposed as InvitationsTableTabProposed, +} from '../stories/invitations-table-tab.stories'; import { Default as ItemDefault, Group as ItemGroup, @@ -875,6 +881,13 @@ const organizationProfileMembersPanelModule: StoryModule = { Empty: OrganizationProfileMembersPanelEmpty, }; +const invitationsTableTabModule: StoryModule = { + meta: invitationsTableTabMeta, + Legacy: InvitationsTableTabLegacy, + Proposed: InvitationsTableTabProposed, + Empty: InvitationsTableTabEmpty, +}; + export const registry: StoryModule[] = [ // User Button userButtonModule, @@ -904,6 +917,7 @@ export const registry: StoryModule[] = [ organizationProfileGeneralPanelModule, organizationProfileApiKeysPanelModule, organizationProfileMembersPanelModule, + invitationsTableTabModule, // Organization Profile · Sections organizationProfileWorkspaceSectionModule, organizationProfileDangerSectionModule, diff --git a/packages/swingset/src/stories/fixtures/invitations-table-tab.ts b/packages/swingset/src/stories/fixtures/invitations-table-tab.ts new file mode 100644 index 00000000000..e2073663e99 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/invitations-table-tab.ts @@ -0,0 +1,76 @@ +import type { + InvitationsTableSort, + InvitationsTableTabViewProps, +} from '@clerk/mosaic/features/organization-profile/invitations-table-tab.types'; +import { useLocale } from '@clerk/mosaic/localization'; +import { useState } from 'react'; + +const exampleInvitations = [ + 'ada', + 'grace', + 'alan', + 'katherine', + 'margaret', + 'edsger', + 'barbara', + 'donald', + 'radia', + 'john', + 'frances', + 'ken', +].map((name, index) => ({ + id: `invitation-${index}`, + email: `${name}@example.com`, + invitedAt: Date.UTC(2026, 8, index + 1), + roleLabel: index % 3 === 0 ? 'Admin' : 'Member', +})); + +export function useInvitationsTableFixture({ proposed = false, empty = false } = {}): InvitationsTableTabViewProps { + const locale = useLocale(); + const [items, setItems] = useState(empty ? [] : exampleInvitations); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [searchValue, setSearchValue] = useState(''); + const [sort, setSort] = useState(null); + const query = searchValue.trim().toLowerCase(); + const filtered = items.filter(item => item.email.toLowerCase().includes(query)); + const sorted = sort + ? [...filtered].sort((a, b) => { + const comparison = + sort.column === 'invitedAt' ? a.invitedAt - b.invitedAt : a[sort.column].localeCompare(b[sort.column]); + return sort.direction === 'ascending' ? comparison : -comparison; + }) + : filtered; + const currentPage = Math.min(page, Math.max(1, Math.ceil(sorted.length / pageSize))); + return { + invitations: sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize).map(item => ({ + ...item, + invitedAtLabel: new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }).format(item.invitedAt), + })), + totalCount: sorted.length, + page: currentPage, + pageSize, + searchValue, + isLoading: false, + onSearchChange: value => { + setSearchValue(value); + setPage(1); + }, + onPageChange: setPage, + onPageSizeChange: setPageSize, + onRevoke: id => setItems(current => current.filter(item => item.id !== id)), + onBulkAction: proposed ? () => undefined : undefined, + sort, + onSortChange: proposed + ? next => { + setSort(next); + setPage(1); + } + : undefined, + }; +} diff --git a/packages/swingset/src/stories/invitations-table-tab.mdx b/packages/swingset/src/stories/invitations-table-tab.mdx new file mode 100644 index 00000000000..e0f254bcd67 --- /dev/null +++ b/packages/swingset/src/stories/invitations-table-tab.mdx @@ -0,0 +1,31 @@ +import * as Stories from './invitations-table-tab.stories'; + +# InvitationsTableTab + +A controlled invitations table for the organization profile’s Invitations tab. The caller supplies the current page, role labels, loading state, and optional invite and revoke callbacks. + +## Example + +### Legacy + +The baseline table shows the invited email address, invitation date, role, and optional revoke action. + + + +## Examples + +### Proposed + +Sorting and selection are enabled through optional callbacks, following the API keys table. The caller sorts before pagination. Selection clears when search, sorting, or pagination changes. The bulk callback enables selection; no bulk action toolbar is rendered. + + + +### Empty + + diff --git a/packages/swingset/src/stories/invitations-table-tab.stories.tsx b/packages/swingset/src/stories/invitations-table-tab.stories.tsx new file mode 100644 index 00000000000..f3f0b9905d7 --- /dev/null +++ b/packages/swingset/src/stories/invitations-table-tab.stories.tsx @@ -0,0 +1,58 @@ +import { Tabs } from '@clerk/mosaic/components/tabs'; +import { InvitationsTableTabView } from '@clerk/mosaic/features/organization-profile/invitations-table-tab.view'; +import type { ReactNode } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import { useInvitationsTableFixture } from './fixtures/invitations-table-tab'; + +export { default as __source } from './invitations-table-tab.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs wire-up', + title: 'InvitationsTableTab', + label: 'Invitations table tab', + navigation: { category: 'Tabs' }, + source: 'packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx', +}; + +function InvitationsTab({ children }: { children: ReactNode }) { + return ( + + + Invitations + + + {children} + + ); +} + +export function Legacy() { + const props = useInvitationsTableFixture(); + return ( + + + + ); +} + +export function Proposed() { + const props = useInvitationsTableFixture({ proposed: true }); + return ( + + + + ); +} + +export function Empty() { + const props = useInvitationsTableFixture({ empty: true }); + return ( + + + + ); +} From 138131ede263cb3954db8aadd554ecdc67124ab0 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 22 Sep 2026 20:37:25 -0600 Subject: [PATCH 02/11] fix(swingset): render invitations tab content without navigation --- .../src/stories/invitations-table-tab.mdx | 3 +- .../stories/invitations-table-tab.stories.tsx | 32 ++----------------- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/packages/swingset/src/stories/invitations-table-tab.mdx b/packages/swingset/src/stories/invitations-table-tab.mdx index e0f254bcd67..a41f3517634 100644 --- a/packages/swingset/src/stories/invitations-table-tab.mdx +++ b/packages/swingset/src/stories/invitations-table-tab.mdx @@ -4,6 +4,8 @@ import * as Stories from './invitations-table-tab.stories'; A controlled invitations table for the organization profile’s Invitations tab. The caller supplies the current page, role labels, loading state, and optional invite and revoke callbacks. +Render this view inside the organization members panel’s tab panel; the parent owns tab navigation. + ## Example ### Legacy @@ -11,7 +13,6 @@ A controlled invitations table for the organization profile’s Invitations tab. The baseline table shows the invited email address, invitation date, role, and optional revoke action. - - Invitations - - - {children} - - ); -} - export function Legacy() { const props = useInvitationsTableFixture(); - return ( - - - - ); + return ; } export function Proposed() { const props = useInvitationsTableFixture({ proposed: true }); - return ( - - - - ); + return ; } export function Empty() { const props = useInvitationsTableFixture({ empty: true }); - return ( - - - - ); + return ; } From 6167aac3ccf034237d8000e11209665fed1143ba Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 22 Sep 2026 20:41:33 -0600 Subject: [PATCH 03/11] feat(mosaic): add invitations to the organization members panel --- ...rganization-profile-members-panel.view.tsx | 11 +++++++- .../organization-profile.view.tsx | 2 +- ...anization-profile-invitations-tab.test.tsx | 27 +++++++++++++++++++ .../stories/fixtures/organization-profile.ts | 4 ++- .../src/stories/organization-profile.mdx | 2 +- 5 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 packages/swingset/src/stories/__tests__/organization-profile-invitations-tab.test.tsx diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx index 34129a51995..adc5c5677cf 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx @@ -4,22 +4,31 @@ import { panelStyles, Profile } from '../../components/profile'; import { Tabs } from '../../components/tabs'; import { useMessages } from '../../localization'; import { mergeStyleProps, themeProps } from '../../props'; +import type { InvitationsTableTabViewProps } from './invitations-table-tab.types'; +import { InvitationsTableTabView } from './invitations-table-tab.view'; import type { MembersTableTabViewProps } from './members-table-tab.types'; import { MembersTableTabView } from './members-table-tab.view'; export interface OrganizationProfileMembersPanelViewProps { members?: MembersTableTabViewProps; + invitations?: InvitationsTableTabViewProps; } -export function OrganizationProfileMembersPanelView({ members }: OrganizationProfileMembersPanelViewProps) { +export function OrganizationProfileMembersPanelView({ members, invitations }: OrganizationProfileMembersPanelViewProps) { const m = useMessages('organizationProfile'); const membersMessages = useMessages('membersTableTab'); + const invitationsMessages = useMessages('invitationsTableTab'); const tabs = [ { id: 'members', label: membersMessages.title, content: members ? : null, }, + { + id: 'invitations', + label: invitationsMessages.title, + content: invitations ? : null, + }, ].filter(tab => tab.content !== null); return ( diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx index c56d98db059..f8388fc68b4 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx @@ -42,7 +42,7 @@ function BuiltInPage({ return pages.apiKeys ? : null; } if (id === 'members') { - return pages.members?.members ? ( + return pages.members?.members || pages.members?.invitations ? ( ) : ( diff --git a/packages/swingset/src/stories/__tests__/organization-profile-invitations-tab.test.tsx b/packages/swingset/src/stories/__tests__/organization-profile-invitations-tab.test.tsx new file mode 100644 index 00000000000..44f5559dbda --- /dev/null +++ b/packages/swingset/src/stories/__tests__/organization-profile-invitations-tab.test.tsx @@ -0,0 +1,27 @@ +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { Default, Overlay } from '../organization-profile.stories'; + +describe('organization profile invitations tab', () => { + it.each([Default, Overlay])('renders invitations inside the members panel (%#)', async Story => { + const user = userEvent.setup(); + render( + + + , + ); + if (Story === Overlay) { + await user.click(screen.getByRole('button', { name: 'Manage workspace' })); + } + + await user.click(screen.getByRole('tab', { name: 'Members' })); + const tabs = screen.getByRole('tablist', { name: 'Members' }); + await user.click(within(tabs).getByRole('tab', { name: 'Invitations' })); + const panel = screen.getByRole('tabpanel', { name: 'Invitations' }); + expect(within(panel).getByRole('table', { name: 'Invitations' })).toBeVisible(); + expect(within(panel).getByText('ada@example.com')).toBeVisible(); + }); +}); diff --git a/packages/swingset/src/stories/fixtures/organization-profile.ts b/packages/swingset/src/stories/fixtures/organization-profile.ts index b9f1e69f5f5..095e53ea9ce 100644 --- a/packages/swingset/src/stories/fixtures/organization-profile.ts +++ b/packages/swingset/src/stories/fixtures/organization-profile.ts @@ -2,6 +2,7 @@ import { OrganizationProfileSaveError } from '@clerk/mosaic/features/organizatio import type { OrganizationProfileViewProps } from '@clerk/mosaic/features/organization-profile/organization-profile.view'; import { useState } from 'react'; +import { useInvitationsTableFixture } from './invitations-table-tab'; import { useMembersTableFixture } from './members-table-tab'; import { useOrganizationProfileAPIKeysFixture } from './organization-profile-api-keys'; import { usePreviewImage } from './use-preview-image'; @@ -24,6 +25,7 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF const { imageUrl, showFile, clearImage } = usePreviewImage(); const apiKeys = useOrganizationProfileAPIKeysFixture(); const members = useMembersTableFixture(); + const invitations = useInvitationsTableFixture(); const save = async (field: 'name' | 'slug', apply: () => void) => { await settleAfter(800); @@ -50,7 +52,7 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF const pages: OrganizationProfileViewProps['pages'] = { general, - members: { members }, + members: { members, invitations }, security: {}, billing: {}, apiKeys, diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx index de79616d7d7..07230efe87d 100644 --- a/packages/swingset/src/stories/organization-profile.mdx +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -4,7 +4,7 @@ import * as Stories from './organization-profile.stories'; The organization profile: a `Profile` whose navigation lists the General, Members, Security, Billing, and API Keys pages it was given content for, then any pages of the consumer's own, in the -order asked for. The Members panel owns its table tabs, including Members. Security and +order asked for. The Members panel owns its table tabs, including Members and Invitations. Security and Billing hold their place with a placeholder. Date: Tue, 22 Sep 2026 21:01:35 -0600 Subject: [PATCH 04/11] docs(swingset): list invitations table under panels --- .../swingset/src/stories/invitations-table-tab.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/invitations-table-tab.stories.tsx b/packages/swingset/src/stories/invitations-table-tab.stories.tsx index d4d52b76ae5..d3a7d4a7d50 100644 --- a/packages/swingset/src/stories/invitations-table-tab.stories.tsx +++ b/packages/swingset/src/stories/invitations-table-tab.stories.tsx @@ -11,8 +11,8 @@ export const meta: StoryMeta = { status: 'wip', substatus: 'needs wire-up', title: 'InvitationsTableTab', - label: 'Invitations table tab', - navigation: { category: 'Tabs' }, + label: 'Invitations table', + navigation: { category: 'Panels' }, source: 'packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx', }; From 473ae3d79807a9327262141317dd88f460ef4dfd Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 23 Sep 2026 09:17:22 -0600 Subject: [PATCH 05/11] fix(mosaic): align invitations tab with API key table patterns --- .../invitations-table-tab.view.test.tsx | 62 ++- .../invitations-table-tab.messages.ts | 4 + .../invitations-table-tab.types.ts | 2 +- .../invitations-table-tab.view.tsx | 404 +++++++++++------- .../src/stories/invitations-table-tab.mdx | 3 + 5 files changed, 310 insertions(+), 165 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx index ece69e58555..d6dde7010b3 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx @@ -1,7 +1,9 @@ -import { render, screen, within } from '@testing-library/react'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { deferred } from '../../../machines/__tests__/test-utils'; import { MosaicProvider } from '../../../MosaicProvider'; import type { InvitationsTableTabViewProps } from '../invitations-table-tab.types'; import { InvitationsTableTabView } from '../invitations-table-tab.view'; @@ -113,6 +115,9 @@ describe('InvitationsTableTabView', () => { expect(props.onInvite).toHaveBeenCalledOnce(); await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + expect(props.onRevoke).not.toHaveBeenCalled(); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); expect(props.onRevoke).toHaveBeenCalledWith('invite-1'); rerender( @@ -128,3 +133,58 @@ describe('InvitationsTableTabView', () => { expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); }); + +it.each([true, false])('restores focus after confirmed removal with invite available: %s', async hasInvite => { + const user = userEvent.setup(); + const pending = deferred(); + const onMutation = vi + .fn<(id: string) => Promise>() + .mockImplementationOnce(() => pending.promise) + .mockResolvedValue(undefined); + function Example() { + const [items, setItems] = useState([ + { ...propsFor().invitations[0], id: 'ada', email: 'ada@example.com' }, + { ...propsFor().invitations[0], id: 'grace', email: 'Grace' }, + ]); + return ( + + { + await onMutation(id); + setItems(current => current.filter(item => item.id !== id)); + }} + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + expect(onMutation).not.toHaveBeenCalled(); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onMutation).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + expect(onMutation).toHaveBeenCalledExactlyOnceWith('ada'); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + await act(async () => { + pending.resolve(); + await pending.promise; + }); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Manage Grace' })).toHaveFocus(); + await user.click(screen.getByRole('button', { name: 'Manage Grace' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onMutation).toHaveBeenLastCalledWith('grace'); + expect( + hasInvite ? screen.getByRole('button', { name: 'Invite members' }) : screen.getByRole('searchbox'), + ).toHaveFocus(); +}); diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts index 9ddca940509..cc22f0bf241 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts @@ -16,6 +16,10 @@ export const invitationsTableTabMessages = { select: 'Select {name}', manage: 'Manage {name}', revoke: 'Revoke invitation', + revokeTitle: 'Revoke {name}?', + revokeDescription: 'This invitation will no longer allow the recipient to join the organization.', + revokeError: 'Unable to revoke this invitation. Please try again.', + cancel: 'Cancel', pagination: 'Invitations pagination', previousPage: 'Previous invitations page', nextPage: 'Next invitations page', diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts index fdd61acc1f2..0823c5751cd 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts @@ -23,7 +23,7 @@ export interface InvitationsTableTabViewProps { onPageSizeChange?: (pageSize: number) => void; onSearchChange: (value: string) => void; onInvite?: () => void; - onRevoke?: (id: string) => void; + onRevoke?: (id: string) => void | Promise; onBulkAction?: (ids: string[]) => void; sort?: InvitationsTableSort | null; onSortChange?: (sort: InvitationsTableSort | null) => void; diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx index b5fbc34d42b..dfab270d5b3 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx @@ -1,6 +1,7 @@ import * as stylex from '@stylexjs/stylex'; -import { useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; +import { Confirmation } from '../../blocks/confirmation'; import { Avatar } from '../../components/avatar'; import { Button } from '../../components/button'; import { EmptyState } from '../../components/empty-state'; @@ -13,6 +14,7 @@ import { Spinner } from '../../components/spinner'; import { Table, type TableHeaderCellProps } from '../../components/table'; import { Text } from '../../components/text'; import { VisuallyHidden } from '../../components/visually-hidden'; +import { useListRemovalFocus } from '../../hooks/useListRemovalFocus'; import { fill, useMessages } from '../../localization'; import { useDataTable } from '../../primitives/hooks'; import { mergeStyleProps, themeProps } from '../../props'; @@ -46,180 +48,256 @@ export function InvitationsTableTabView({ const columnCount = 3 + Number(Boolean(onRevoke)) + Number(Boolean(onBulkAction)); const query = searchValue.trim(); const searchInput = useRef(null); - const table = useDataTable({ data: invitations, getRowId, totalCount }); - const sortHeader = (column: InvitationsTableSort['column']): Pick => ({ - sort: sort?.column === column ? sort.direction : 'none', - onSort: onSortChange - ? () => { + const inviteButton = useRef(null); + const removalFocus = useListRemovalFocus({ + ids: invitations.map(getRowId), + onRemove: onRevoke, + fallback: () => inviteButton.current ?? searchInput.current, + }); + const revokeDialog = useMemo(() => Confirmation.createHandle(), []); + const pagination = { pageIndex: page - 1, pageSize }; + const table = useDataTable({ + data: invitations, + totalCount, + getRowId, + sorting: sort ? [{ id: sort.column, desc: sort.direction === 'descending' }] : [], + onSortingChange: onSortChange + ? update => { + const next = typeof update === 'function' ? update(table.sorting) : update; + const active = next[0]; table.setRowSelection({}); onSortChange( - sort?.column !== column - ? { column, direction: 'ascending' } - : sort.direction === 'ascending' - ? { column, direction: 'descending' } - : null, + active && (active.id === 'email' || active.id === 'invitedAt' || active.id === 'roleLabel') + ? { column: active.id, direction: active.desc ? 'descending' : 'ascending' } + : null, ); } : undefined, + pagination, + onPaginationChange: update => { + const next = typeof update === 'function' ? update(pagination) : update; + table.setRowSelection({}); + if (next.pageSize !== pageSize) { + onPageSizeChange?.(next.pageSize); + } + onPageChange(next.pageIndex + 1); + }, + globalFilter: searchValue, + onGlobalFilterChange: update => { + table.setRowSelection({}); + onSearchChange(typeof update === 'function' ? update(searchValue) : update); + }, }); + const sortHeader = (column: InvitationsTableSort['column']): Pick => { + const active = table.sorting[0]; + return { + sort: active?.id === column ? (active.desc ? 'descending' : 'ascending') : 'none', + onSort: onSortChange + ? () => + table.setSorting(current => { + const active = current[0]; + if (active?.id !== column) { + return [{ id: column, desc: false }]; + } + return active.desc ? [] : [{ id: column, desc: true }]; + }) + : undefined, + }; + }; return ( -
-
- +
+
+ + + + + table.setGlobalFilter(event.currentTarget.value)} + /> + {table.globalFilter ? ( + + + + ) : null} + + {onInvite ? ( + + ) : null} +
+ - - - - { - table.setRowSelection({}); - onSearchChange(event.currentTarget.value); - }} + + + {onBulkAction ? ( + + ) : null} + {m.email} + {m.invitedAt} + {m.roleLabel} + {onRevoke ? ( + + {m.actions} + + ) : null} + + + + {isLoading ? ( + + + + {m.loading} + + + ) : table.rows.length === 0 ? ( + + + + {query ? m.empty : m.noInvitations} + + {query ? fill(m.emptyDescription, { query }) : m.noInvitationsDescription} + + + + ) : ( + table.rows.map(row => ( + + {onBulkAction ? ( + + ) : null} + +
+ + {row.original.imageUrl ? ( + + ) : null} + + + {row.original.email} +
+
+ {row.original.invitedAtLabel} + {row.original.roleLabel} + {onRevoke ? ( + + revokeDialog.open(invitation)} + /> + + ) : null} +
+ )) + )} +
+
+ {table.getPageCount() > 1 || (totalCount > 0 && onPageSizeChange) ? ( + table.setPagination(current => ({ ...current, pageIndex: next - 1 }))} + onPageSizeChange={ + onPageSizeChange ? next => table.setPagination({ pageIndex: 0, pageSize: next }) : undefined + } /> - {searchValue ? ( - - - - ) : null} - - {onInvite ? : null} + ) : null}
- - - - {onBulkAction ? ( - - ) : null} - {m.email} - {m.invitedAt} - {m.roleLabel} - {onRevoke ? ( - - {m.actions} - - ) : null} - - - - {isLoading ? ( - - - - {m.loading} - - - ) : table.rows.length === 0 ? ( - - - - {query ? m.empty : m.noInvitations} - - {query ? fill(m.emptyDescription, { query }) : m.noInvitationsDescription} - - - - ) : ( - table.rows.map(row => ( - - {onBulkAction ? ( - - ) : null} - -
- - {row.original.imageUrl ? ( - - ) : null} - - - {row.original.email} -
-
- {row.original.invitedAtLabel} - {row.original.roleLabel} - {onRevoke ? ( - - - - - onRevoke(row.id)} - > - {m.revoke} - - - - - ) : null} -
- )) - )} -
-
- {totalCount > pageSize || (totalCount > 0 && onPageSizeChange) ? ( - { - table.setRowSelection({}); - onPageChange(next); + {onRevoke ? ( + fill(m.revokeTitle, { name: invitation.email })} + description={m.revokeDescription} + actionLabel={m.revoke} + cancelLabel={m.cancel} + onConfirm={async invitation => { + try { + await removalFocus.remove(invitation.id); + } catch (error) { + throw error instanceof Error ? error : new Error(m.revokeError); + } }} - onPageSizeChange={ - onPageSizeChange - ? next => { - table.setRowSelection({}); - onPageSizeChange(next); - onPageChange(1); - } - : undefined - } + finalFocus={removalFocus.finalFocus} /> ) : null} -
+ + ); +} + +function InvitationActions({ + invitation, + registerTrigger, + onSelect, +}: { + invitation: OrganizationProfileInvitation; + registerTrigger: (id: string) => (element: HTMLButtonElement | null) => void; + onSelect: (invitation: OrganizationProfileInvitation) => void; +}) { + const m = useMessages('invitationsTableTab'); + const [triggerRef] = useState(() => registerTrigger(invitation.id)); + return ( + + + + onSelect(invitation)} + > + {m.revoke} + + + ); } diff --git a/packages/swingset/src/stories/invitations-table-tab.mdx b/packages/swingset/src/stories/invitations-table-tab.mdx index a41f3517634..6e879bf347f 100644 --- a/packages/swingset/src/stories/invitations-table-tab.mdx +++ b/packages/swingset/src/stories/invitations-table-tab.mdx @@ -4,6 +4,8 @@ import * as Stories from './invitations-table-tab.stories'; A controlled invitations table for the organization profile’s Invitations tab. The caller supplies the current page, role labels, loading state, and optional invite and revoke callbacks. +Revoking an invitation opens a confirmation dialog. The dialog awaits the action callback and keeps failures visible. After a successful removal, focus moves to a surviving row action or a toolbar control. + Render this view inside the organization members panel’s tab panel; the parent owns tab navigation. ## Example @@ -13,6 +15,7 @@ Render this view inside the organization members panel’s tab panel; the parent The baseline table shows the invited email address, invitation date, role, and optional revoke action. Date: Wed, 23 Sep 2026 11:59:18 -0600 Subject: [PATCH 06/11] test(mosaic): focus invitations coverage on table behavior --- .../invitations-table-tab.view.test.tsx | 126 ++++++++---------- 1 file changed, 54 insertions(+), 72 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx index d6dde7010b3..cc12eebd75d 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx @@ -97,28 +97,13 @@ describe('InvitationsTableTabView', () => { await user.click(screen.getByRole('option', { name: '20', exact: true })); expect(props.onPageSizeChange).toHaveBeenCalledWith(20); expect(props.onPageChange).toHaveBeenLastCalledWith(1); - expect(props.onBulkAction).not.toHaveBeenCalled(); }); - it('renders invitation metadata and routes optional invite and revoke commands', async () => { + it('routes invite and withholds unavailable actions', async () => { const user = userEvent.setup(); const { props, rerender } = renderView({ onInvite: vi.fn(), onRevoke: vi.fn() }); - const table = screen.getByRole('table', { name: 'Invitations' }); - expect( - within(table) - .getAllByRole('columnheader') - .map(header => header.textContent), - ).toEqual(['User', 'Invited', 'Role', 'Actions']); - expect(within(table).getByText('ada@example.com')).toBeVisible(); - expect(within(table).getByText('Sep 1, 2026')).toBeVisible(); - expect(within(table).getByText('Admin')).toBeVisible(); await user.click(screen.getByRole('button', { name: 'Invite members' })); expect(props.onInvite).toHaveBeenCalledOnce(); - await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); - await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); - expect(props.onRevoke).not.toHaveBeenCalled(); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(props.onRevoke).toHaveBeenCalledWith('invite-1'); + expect(screen.getByRole('button', { name: 'Manage ada@example.com' })).toBeVisible(); rerender( { /> , ); - expect(screen.queryByRole('columnheader', { name: 'Actions' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Manage|Invite/ })).not.toBeInTheDocument(); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); }); -it.each([true, false])('restores focus after confirmed removal with invite available: %s', async hasInvite => { - const user = userEvent.setup(); - const pending = deferred(); - const onMutation = vi - .fn<(id: string) => Promise>() - .mockImplementationOnce(() => pending.promise) - .mockResolvedValue(undefined); - function Example() { - const [items, setItems] = useState([ - { ...propsFor().invitations[0], id: 'ada', email: 'ada@example.com' }, - { ...propsFor().invitations[0], id: 'grace', email: 'Grace' }, - ]); - return ( - - { - await onMutation(id); - setItems(current => current.filter(item => item.id !== id)); - }} - /> - - ); - } - render(); - await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); - await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); - expect(onMutation).not.toHaveBeenCalled(); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onMutation).not.toHaveBeenCalled(); - await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); - await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); - expect(onMutation).toHaveBeenCalledExactlyOnceWith('ada'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - await act(async () => { - pending.resolve(); - await pending.promise; - }); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Manage Grace' })).toHaveFocus(); - await user.click(screen.getByRole('button', { name: 'Manage Grace' })); - await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onMutation).toHaveBeenLastCalledWith('grace'); - expect( - hasInvite ? screen.getByRole('button', { name: 'Invite members' }) : screen.getByRole('searchbox'), - ).toHaveFocus(); -}); +it.each([true, false])( + 'revokes the confirmed invitation and restores focus with invite available: %s', + async hasInvite => { + const user = userEvent.setup(); + const pending = deferred(); + const onMutation = vi + .fn<(id: string) => Promise>() + .mockImplementationOnce(() => pending.promise) + .mockResolvedValue(undefined); + function Example() { + const [items, setItems] = useState([ + { ...propsFor().invitations[0], id: 'ada', email: 'ada@example.com' }, + { ...propsFor().invitations[0], id: 'grace', email: 'Grace' }, + ]); + return ( + + { + await onMutation(id); + setItems(current => current.filter(item => item.id !== id)); + }} + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + expect(onMutation).not.toHaveBeenCalled(); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + expect(onMutation).toHaveBeenCalledExactlyOnceWith('ada'); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + await act(async () => { + pending.resolve(); + await pending.promise; + }); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Manage Grace' })).toHaveFocus(); + await user.click(screen.getByRole('button', { name: 'Manage Grace' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onMutation).toHaveBeenLastCalledWith('grace'); + expect( + hasInvite ? screen.getByRole('button', { name: 'Invite members' }) : screen.getByRole('searchbox'), + ).toHaveFocus(); + }, +); From da34066e3855460c93501ed1a1222a29ffa6dff8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 23 Sep 2026 12:07:19 -0600 Subject: [PATCH 07/11] fix(mosaic): preserve invitation fallback states --- .../invitations-table-tab.view.test.tsx | 17 +++++++++++++++++ .../invitations-table-tab.view.tsx | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx index cc12eebd75d..fd847d6c53e 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx @@ -33,6 +33,23 @@ function renderView(overrides: Partial = {}) { } describe('InvitationsTableTabView', () => { + it.each(['', ' '])('uses the localized revoke error when the rejection message is "%s"', async message => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not revoke invitation.'); + }); + it('distinguishes loading, an empty invitation list, and an empty search', () => { const { props, rerender } = renderView({ invitations: [], totalCount: 0, isLoading: true }); expect(screen.getByRole('status')).toHaveTextContent('Loading invitations'); diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx index dfab270d5b3..5cc35752503 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx @@ -262,7 +262,7 @@ export function InvitationsTableTabView({ try { await removalFocus.remove(invitation.id); } catch (error) { - throw error instanceof Error ? error : new Error(m.revokeError); + throw error instanceof Error && error.message.trim() ? error : new Error(m.revokeError); } }} finalFocus={removalFocus.finalFocus} From 8fe2c0b730fe401938a3b2c0e8ca4c96d18e4995 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 23 Sep 2026 12:09:51 -0600 Subject: [PATCH 08/11] fix(mosaic): announce empty invitation results --- .../__tests__/invitations-table-tab.view.test.tsx | 2 +- .../organization-profile/invitations-table-tab.view.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx index fd847d6c53e..1caec7afc8d 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx @@ -71,7 +71,7 @@ describe('InvitationsTableTabView', () => { /> , ); - expect(screen.getByText('No invitations found')).toBeVisible(); + expect(screen.getByRole('status')).toHaveTextContent('No invitations found'); rerender( ) : table.rows.length === 0 ? ( - + {query ? m.empty : m.noInvitations} From 58581099e9c5404af4666a4bdd2fd6d3d7af4a78 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 23 Sep 2026 12:23:09 -0600 Subject: [PATCH 09/11] fix(swingset): preview invitations inside the members panel --- .../swingset/src/components/DocsViewer.tsx | 1 - packages/swingset/src/lib/registry.ts | 14 -------- .../src/stories/invitations-table-tab.mdx | 35 ------------------- .../stories/invitations-table-tab.stories.tsx | 32 ----------------- .../organization-profile-members-panel.mdx | 6 ++-- ...nization-profile-members-panel.stories.tsx | 16 +++++---- 6 files changed, 14 insertions(+), 90 deletions(-) delete mode 100644 packages/swingset/src/stories/invitations-table-tab.mdx delete mode 100644 packages/swingset/src/stories/invitations-table-tab.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 5777e547d95..3b475d09e5d 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -41,7 +41,6 @@ const docModules: Record> = { }, 'organization-profile': { 'organization-profile-members-panel': dynamic(() => import('../stories/organization-profile-members-panel.mdx')), - 'invitations-table-tab': dynamic(() => import('../stories/invitations-table-tab.mdx')), 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), 'organization-profile-workspace-section': dynamic( diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 44cfa77a001..7376ebed605 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -112,12 +112,6 @@ import { meta as inputGroupMeta, Sizes as InputGroupSizes, } from '../stories/input-group.stories'; -import { - Empty as InvitationsTableTabEmpty, - Legacy as InvitationsTableTabLegacy, - meta as invitationsTableTabMeta, - Proposed as InvitationsTableTabProposed, -} from '../stories/invitations-table-tab.stories'; import { Default as ItemDefault, Group as ItemGroup, @@ -881,13 +875,6 @@ const organizationProfileMembersPanelModule: StoryModule = { Empty: OrganizationProfileMembersPanelEmpty, }; -const invitationsTableTabModule: StoryModule = { - meta: invitationsTableTabMeta, - Legacy: InvitationsTableTabLegacy, - Proposed: InvitationsTableTabProposed, - Empty: InvitationsTableTabEmpty, -}; - export const registry: StoryModule[] = [ // User Button userButtonModule, @@ -917,7 +904,6 @@ export const registry: StoryModule[] = [ organizationProfileGeneralPanelModule, organizationProfileApiKeysPanelModule, organizationProfileMembersPanelModule, - invitationsTableTabModule, // Organization Profile · Sections organizationProfileWorkspaceSectionModule, organizationProfileDangerSectionModule, diff --git a/packages/swingset/src/stories/invitations-table-tab.mdx b/packages/swingset/src/stories/invitations-table-tab.mdx deleted file mode 100644 index 6e879bf347f..00000000000 --- a/packages/swingset/src/stories/invitations-table-tab.mdx +++ /dev/null @@ -1,35 +0,0 @@ -import * as Stories from './invitations-table-tab.stories'; - -# InvitationsTableTab - -A controlled invitations table for the organization profile’s Invitations tab. The caller supplies the current page, role labels, loading state, and optional invite and revoke callbacks. - -Revoking an invitation opens a confirmation dialog. The dialog awaits the action callback and keeps failures visible. After a successful removal, focus moves to a surviving row action or a toolbar control. - -Render this view inside the organization members panel’s tab panel; the parent owns tab navigation. - -## Example - -### Legacy - -The baseline table shows the invited email address, invitation date, role, and optional revoke action. - - - -## Examples - -### Proposed - -Sorting and selection are enabled through optional callbacks, following the API keys table. The caller sorts before pagination. Selection clears when search, sorting, or pagination changes. The bulk callback enables selection; no bulk action toolbar is rendered. - - - -### Empty - - diff --git a/packages/swingset/src/stories/invitations-table-tab.stories.tsx b/packages/swingset/src/stories/invitations-table-tab.stories.tsx deleted file mode 100644 index d3a7d4a7d50..00000000000 --- a/packages/swingset/src/stories/invitations-table-tab.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { InvitationsTableTabView } from '@clerk/mosaic/features/organization-profile/invitations-table-tab.view'; - -import type { StoryMeta } from '@/lib/types'; - -import { useInvitationsTableFixture } from './fixtures/invitations-table-tab'; - -export { default as __source } from './invitations-table-tab.stories?raw'; - -export const meta: StoryMeta = { - group: 'Organization Profile', - status: 'wip', - substatus: 'needs wire-up', - title: 'InvitationsTableTab', - label: 'Invitations table', - navigation: { category: 'Panels' }, - source: 'packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx', -}; - -export function Legacy() { - const props = useInvitationsTableFixture(); - return ; -} - -export function Proposed() { - const props = useInvitationsTableFixture({ proposed: true }); - return ; -} - -export function Empty() { - const props = useInvitationsTableFixture({ empty: true }); - return ; -} diff --git a/packages/swingset/src/stories/organization-profile-members-panel.mdx b/packages/swingset/src/stories/organization-profile-members-panel.mdx index f781adc97ef..9827dedd8d3 100644 --- a/packages/swingset/src/stories/organization-profile-members-panel.mdx +++ b/packages/swingset/src/stories/organization-profile-members-panel.mdx @@ -2,15 +2,17 @@ import * as Stories from './organization-profile-members-panel.stories'; # OrganizationProfileMembersPanel -The organization profile’s Members panel owns the page title and tab navigation. These examples show its Members tab with a controlled table. +The organization profile’s Members panel owns the page title and tab navigation. These examples show its Members and Invitations tabs with controlled tables. Removing a member opens a confirmation dialog. The dialog awaits the action callback and keeps failures visible. After a successful removal, focus moves to a surviving row action or a toolbar control. +Revoking an invitation opens a confirmation dialog. The dialog awaits the action callback and keeps failures visible. After a successful removal, focus moves to a surviving row action or a toolbar control. + ## Example ### Legacy -The baseline table shows the user, joined date, role, and optional row actions. +The Members table shows the user, joined date, role, and optional row actions. The Invitations table shows the invited email address, invitation date, role, and optional revoke action. ; + const members = useMembersTableFixture(); + const invitations = useInvitationsTableFixture(); + return ; } export function Proposed() { - const props = useMembersTableFixture({ proposed: true }); - return ; + const members = useMembersTableFixture({ proposed: true }); + const invitations = useInvitationsTableFixture({ proposed: true }); + return ; } export function Empty() { - const props = useMembersTableFixture({ empty: true }); - return ; + const members = useMembersTableFixture({ empty: true }); + const invitations = useInvitationsTableFixture({ empty: true }); + return ; } From 542c4deba701bbe6e60e5ce5c8676dfa34acc2dd Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 23 Sep 2026 13:59:51 -0600 Subject: [PATCH 10/11] refactor(mosaic): use shared action menu for invitations --- .../invitations-table-tab.view.tsx | 48 +++++-------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx index 6960c5a9d01..9ec825cd463 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx @@ -1,13 +1,13 @@ import * as stylex from '@stylexjs/stylex'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useRef } from 'react'; import { Confirmation } from '../../blocks/confirmation'; +import { ActionMenu } from '../../components/action-menu'; import { Avatar } from '../../components/avatar'; import { Button } from '../../components/button'; import { EmptyState } from '../../components/empty-state'; import { Icon } from '../../components/icon'; import { InputGroup } from '../../components/input-group'; -import { Menu } from '../../components/menu'; import { Pagination } from '../../components/pagination'; import { panelStyles } from '../../components/profile'; import { Spinner } from '../../components/spinner'; @@ -223,10 +223,16 @@ export function InvitationsTableTabView({ {row.original.roleLabel} {onRevoke ? ( - revokeDialog.open(invitation)} + revokeDialog.open(row.original), + }, + ]} /> ) : null} @@ -271,33 +277,3 @@ export function InvitationsTableTabView({ ); } - -function InvitationActions({ - invitation, - registerTrigger, - onSelect, -}: { - invitation: OrganizationProfileInvitation; - registerTrigger: (id: string) => (element: HTMLButtonElement | null) => void; - onSelect: (invitation: OrganizationProfileInvitation) => void; -}) { - const m = useMessages('invitationsTableTab'); - const [triggerRef] = useState(() => registerTrigger(invitation.id)); - return ( - - - - onSelect(invitation)} - > - {m.revoke} - - - - ); -} From 5a27e1e53d5e7e44ef07b72259256bbef2acbddb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 23 Sep 2026 14:19:15 -0600 Subject: [PATCH 11/11] refactor(mosaic): use item for invitation identity cells --- .../invitations-table-tab.styles.ts | 4 +-- .../invitations-table-tab.view.tsx | 36 ++++++++++--------- ...rganization-profile-members-panel.view.tsx | 5 ++- ...nization-profile-members-panel.stories.tsx | 21 +++++++++-- 4 files changed, 43 insertions(+), 23 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts index 9e99c43909b..d406ad8ac95 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { fontWeightVars, space } from '../../tokens.stylex'; +import { space } from '../../tokens.stylex'; export const styles = stylex.create({ toolbar: { @@ -11,7 +11,5 @@ export const styles = stylex.create({ justifyContent: 'space-between', }, search: { maxWidth: '100%', width: '17rem' }, - identity: { gap: space['3'], alignItems: 'center', display: 'flex' }, - name: { gap: space['2'], alignItems: 'center', display: 'flex', fontWeight: fontWeightVars['--cl-font-medium'] }, dateCell: { whiteSpace: 'nowrap' }, }); diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx index 9ec825cd463..25f9f292d65 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx @@ -8,11 +8,11 @@ import { Button } from '../../components/button'; import { EmptyState } from '../../components/empty-state'; import { Icon } from '../../components/icon'; import { InputGroup } from '../../components/input-group'; +import { Item } from '../../components/item'; import { Pagination } from '../../components/pagination'; import { panelStyles } from '../../components/profile'; import { Spinner } from '../../components/spinner'; import { Table, type TableHeaderCellProps } from '../../components/table'; -import { Text } from '../../components/text'; import { VisuallyHidden } from '../../components/visually-hidden'; import { useListRemovalFocus } from '../../hooks/useListRemovalFocus'; import { fill, useMessages } from '../../localization'; @@ -203,21 +203,25 @@ export function InvitationsTableTabView({ /> ) : null} -
- - {row.original.imageUrl ? ( - - ) : null} - - - {row.original.email} -
+ + + + {row.original.imageUrl ? ( + + ) : null} + + + + + {row.original.email} + +
{row.original.invitedAtLabel} {row.original.roleLabel} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx index adc5c5677cf..65f01ee3028 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx @@ -14,7 +14,10 @@ export interface OrganizationProfileMembersPanelViewProps { invitations?: InvitationsTableTabViewProps; } -export function OrganizationProfileMembersPanelView({ members, invitations }: OrganizationProfileMembersPanelViewProps) { +export function OrganizationProfileMembersPanelView({ + members, + invitations, +}: OrganizationProfileMembersPanelViewProps) { const m = useMessages('organizationProfile'); const membersMessages = useMessages('membersTableTab'); const invitationsMessages = useMessages('invitationsTableTab'); diff --git a/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx index afc0a62f98d..5c34265467b 100644 --- a/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx +++ b/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx @@ -20,17 +20,32 @@ export const meta: StoryMeta = { export function Legacy() { const members = useMembersTableFixture(); const invitations = useInvitationsTableFixture(); - return ; + return ( + + ); } export function Proposed() { const members = useMembersTableFixture({ proposed: true }); const invitations = useInvitationsTableFixture({ proposed: true }); - return ; + return ( + + ); } export function Empty() { const members = useMembersTableFixture({ empty: true }); const invitations = useInvitationsTableFixture({ empty: true }); - return ; + return ( + + ); }