From cf08ac6c1279bf9ce31df976769ee810df77046e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 22 Sep 2026 20:11:00 -0600 Subject: [PATCH 01/17] feat(mosaic): add members table tab view --- .changeset/members-table-tab.md | 2 + .../__tests__/members-table-tab.view.test.tsx | 146 ++++++++++ .../members-table-tab.messages.ts | 27 ++ .../members-table-tab.styles.ts | 18 ++ .../members-table-tab.types.ts | 37 +++ .../members-table-tab.view.tsx | 274 ++++++++++++++++++ packages/mosaic/src/localization/registry.ts | 2 + packages/mosaic/src/styles/index.ts | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 14 + .../src/stories/fixtures/members-table-tab.ts | 89 ++++++ .../src/stories/members-table-tab.mdx | 32 ++ .../src/stories/members-table-tab.stories.tsx | 58 ++++ 13 files changed, 702 insertions(+) create mode 100644 .changeset/members-table-tab.md create mode 100644 packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx create mode 100644 packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts create mode 100644 packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts create mode 100644 packages/mosaic/src/features/organization-profile/members-table-tab.types.ts create mode 100644 packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx create mode 100644 packages/swingset/src/stories/fixtures/members-table-tab.ts create mode 100644 packages/swingset/src/stories/members-table-tab.mdx create mode 100644 packages/swingset/src/stories/members-table-tab.stories.tsx diff --git a/.changeset/members-table-tab.md b/.changeset/members-table-tab.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/members-table-tab.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx new file mode 100644 index 00000000000..81b681b445f --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx @@ -0,0 +1,146 @@ +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 { MembersTableTabViewProps } from '../members-table-tab.types'; +import { MembersTableTabView } from '../members-table-tab.view'; + +function propsFor(overrides: Partial = {}): MembersTableTabViewProps { + return { + members: [ + { + id: 'ada', + name: 'Ada Lovelace', + email: 'ada@example.com', + joinedAtLabel: 'Sep 1, 2026', + role: 'admin', + roleLabel: 'Admin', + isCurrentUser: true, + }, + { + id: 'grace', + name: 'Grace Hopper', + email: 'grace@example.com', + joinedAtLabel: 'Sep 2, 2026', + role: 'member', + roleLabel: 'Member', + }, + ], + roles: [ + { value: 'admin', label: 'Admin' }, + { value: 'member', label: 'Member' }, + ], + totalCount: 2, + page: 1, + searchValue: '', + isLoading: false, + onSearchChange: vi.fn(), + onPageChange: vi.fn(), + ...overrides, + }; +} + +function renderView(overrides: Partial = {}) { + const props = propsFor(overrides); + return { + props, + ...render( + + + , + ), + }; +} + +describe('MembersTableTabView', () => { + it('chooses loading, retained rows, and filtered empty results from the supplied state', () => { + const { props, rerender } = renderView({ members: [], totalCount: 0, isLoading: true }); + expect(screen.getByRole('status')).toHaveTextContent('Loading members'); + expect(screen.queryByText('No members yet')).not.toBeInTheDocument(); + rerender( + + + , + ); + expect(screen.getByText('No members yet')).toBeVisible(); + rerender( + + + , + ); + expect(screen.getByText('No members found')).toBeVisible(); + rerender( + + + , + ); + expect(screen.getByText('Grace Hopper')).toBeVisible(); + expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'true'); + }); + it('forwards table controls and clears selection when changing the result set', async () => { + const user = userEvent.setup(); + const { props } = renderView({ + totalCount: 25, + onBulkAction: vi.fn(), + onSortChange: vi.fn(), + onPageSizeChange: vi.fn(), + }); + await user.click(screen.getByRole('checkbox', { name: 'Select all members on this page' })); + expect(screen.getByRole('checkbox', { name: 'Select Ada Lovelace' })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })).toBeChecked(); + await user.click(screen.getByRole('button', { name: 'Joined' })); + expect(props.onSortChange).toHaveBeenCalledWith({ column: 'joinedAt', direction: 'ascending' }); + expect(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })).not.toBeChecked(); + await user.type(screen.getByRole('searchbox', { name: 'Search members' }), 'G'); + expect(props.onSearchChange).toHaveBeenCalledWith('G'); + await user.click(screen.getByRole('button', { name: 'Next members 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(); + expect(screen.queryByRole('toolbar')).not.toBeInTheDocument(); + }); + it('routes member actions by id and excludes protected members from removal and role editing', async () => { + const user = userEvent.setup(); + const { props } = renderView({ onRemove: vi.fn(), onChangeRole: vi.fn(), onInvite: vi.fn() }); + expect(screen.queryByRole('button', { name: 'Manage Ada Lovelace' })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox', { name: /^Change role for Ada Lovelace/ })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Invite members' })); + expect(props.onInvite).toHaveBeenCalledOnce(); + await user.click(screen.getByRole('combobox', { name: /^Change role for Grace Hopper/ })); + await user.click(screen.getByRole('option', { name: 'Admin' })); + expect(props.onChangeRole).toHaveBeenCalledWith('grace', 'admin'); + await user.click(screen.getByRole('button', { name: 'Manage Grace Hopper' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove from organization' })); + expect(props.onRemove).toHaveBeenCalledWith('grace'); + }); + it('renders the supplied member metadata without optional controls', () => { + renderView(); + const table = screen.getByRole('table', { name: 'Members' }); + expect( + within(table) + .getAllByRole('columnheader') + .map(header => header.textContent), + ).toEqual(['User', 'Joined', 'Role']); + expect(within(table).getByText('ada@example.com')).toBeVisible(); + expect(within(table).getByText('Sep 2, 2026')).toBeVisible(); + expect(within(table).getByText('You')).toBeVisible(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Manage|Invite/ })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts new file mode 100644 index 00000000000..bf47d445301 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts @@ -0,0 +1,27 @@ +export const membersTableTabMessages = { + title: 'Members', + name: 'User', + joinedAt: 'Joined', + role: 'Role', + actions: 'Actions', + search: 'Search members', + clearSearch: 'Clear search', + invite: 'Invite members', + empty: 'No members found', + emptyDescription: 'Your search for "{query}" did not return any results.', + noMembers: 'No members yet', + noMembersDescription: 'Invite people to add them to this organization.', + loading: 'Loading members', + selectAll: 'Select all members on this page', + select: 'Select {name}', + manage: 'Manage {name}', + changeRole: 'Change role for {name}', + remove: 'Remove from organization', + you: 'You', + deprovisioned: 'Deprovisioned', + banned: 'Banned', + pagination: 'Members pagination', + previousPage: 'Previous members page', + nextPage: 'Next members page', + pageSize: 'Results per page', +} as const; diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts new file mode 100644 index 00000000000..79808f619f2 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts @@ -0,0 +1,18 @@ +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' }, + metadata: { gap: space['0.5'], display: 'flex', flexDirection: 'column', minWidth: '20ch' }, + 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/members-table-tab.types.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.types.ts new file mode 100644 index 00000000000..c668b3f8d85 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.types.ts @@ -0,0 +1,37 @@ +export interface OrganizationProfileMember { + id: string; + name: string; + email: string; + imageUrl?: string; + joinedAtLabel: string; + role: string; + roleLabel: string; + isCurrentUser?: boolean; + isDeprovisioned?: boolean; + isBanned?: boolean; +} + +export interface MembersTableSort { + column: 'name' | 'joinedAt' | 'role'; + direction: 'ascending' | 'descending'; +} + +export interface MembersTableTabViewProps { + members: OrganizationProfileMember[]; + roles: { value: string; label: string }[]; + 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; + onRemove?: (id: string) => void; + onChangeRole?: (id: string, role: string) => void; + onBulkAction?: (ids: string[]) => void; + sort?: MembersTableSort | null; + onSortChange?: (sort: MembersTableSort | null) => void; +} diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx new file mode 100644 index 00000000000..f6689afb37b --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx @@ -0,0 +1,274 @@ +import * as stylex from '@stylexjs/stylex'; +import { useRef } from 'react'; + +import { Avatar } from '../../components/avatar'; +import { Badge } from '../../components/badge'; +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 { Select } from '../../components/select'; +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 './members-table-tab.styles'; +import type { MembersTableSort, MembersTableTabViewProps, OrganizationProfileMember } from './members-table-tab.types'; + +const getRowId = (member: OrganizationProfileMember) => member.id; +const canManageMember = (member: OrganizationProfileMember) => !member.isCurrentUser && !member.isDeprovisioned; + +export function MembersTableTabView({ + members, + roles, + onInvite, + onRemove, + onChangeRole, + totalCount, + page, + pageSize = 10, + searchValue, + onSearchChange, + onPageChange, + onPageSizeChange, + onBulkAction, + sort, + onSortChange, + isLoading, + isFetching = false, +}: MembersTableTabViewProps) { + const m = useMessages('membersTableTab'); + const searchInput = useRef(null); + const table = useDataTable({ data: members, getRowId, totalCount }); + const selectableRows = table.rows.filter(row => canManageMember(row.original)); + const allSelected = selectableRows.length > 0 && selectableRows.every(row => row.getIsSelected()); + const columnCount = 3 + Number(Boolean(onRemove)) + Number(Boolean(onBulkAction)); + const query = searchValue.trim(); + const sortHeader = (column: MembersTableSort['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 ? ( + row.getIsSelected())} + onChange={() => + table.setRowSelection(Object.fromEntries(selectableRows.map(row => [row.id, !allSelected]))) + } + /> + ) : null} + {m.name} + {m.joinedAt} + {m.role} + {onRemove ? ( + + {m.actions} + + ) : null} + + + + {isLoading ? ( + + + + {m.loading} + + + ) : table.rows.length === 0 ? ( + + + + {query ? m.empty : m.noMembers} + + {query ? fill(m.emptyDescription, { query }) : m.noMembersDescription} + + + + ) : ( + table.rows.map(row => { + const member = row.original; + return ( + + {onBulkAction ? ( + + ) : null} + +
+ + {member.imageUrl ? ( + + ) : null} + + +
+ + {member.name} + {member.isCurrentUser ? ( + {m.you} + ) : member.isDeprovisioned ? ( + {m.deprovisioned} + ) : member.isBanned ? ( + {m.banned} + ) : null} + + + {member.email} + +
+
+
+ {member.joinedAtLabel} + + {onChangeRole && canManageMember(member) ? ( + { + if (value) { + onChangeRole(member.id, value); + } + }} + > + + + + ) : ( + member.roleLabel + )} + + {onRemove ? ( + + {!canManageMember(member) ? null : ( + + + + onRemove(member.id)} + > + {m.remove} + + + + )} + + ) : 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 222e21bd691..b61ac4e864e 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 { 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'; import { organizationProfileDangerSectionMessages } from '../features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages'; @@ -26,6 +27,7 @@ import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-pr export const mosaicMessages = { form: formMessages, + membersTableTab: membersTableTabMessages, organizationProfile: organizationProfileMessages, organizationProfileDangerSection: organizationProfileDangerSectionMessages, organizationProfileWorkspaceSection: organizationProfileWorkspaceSectionMessages, diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 361aed6994e..b2b46e057a1 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -230,3 +230,5 @@ export { UserProfileApiKeysPanelView } from '../features/user-profile/user-profi export { OrganizationProfileApiKeysPanelView } from '../features/organization-profile/organization-profile-api-keys-panel.view'; export { OrganizationProfileCreateAPIKeyDialog } from '../features/organization-profile/organization-profile-create-api-key.dialog'; + +export { MembersTableTabView } from '../features/organization-profile/members-table-tab.view'; diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index c8cb44fcad3..1b984eb854f 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -40,6 +40,7 @@ const docModules: Record> = { 'user-profile-delete-section': dynamic(() => import('../stories/user-profile-delete-section.mdx')), }, 'organization-profile': { + 'members-table-tab': dynamic(() => import('../stories/members-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 a4b099d78bf..6802d229e97 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -128,6 +128,12 @@ import { Overrides as LocalizationOverrides, PluralRules as LocalizationPluralRules, } from '../stories/localization.stories'; +import { + Empty as MembersTableTabEmpty, + Legacy as MembersTableTabLegacy, + meta as membersTableTabMeta, + Proposed as MembersTableTabProposed, +} from '../stories/members-table-tab.stories'; import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { @@ -862,6 +868,13 @@ const organizationProfileApiKeysPanelModule: StoryModule = { Empty: OrganizationProfileApiKeysPanelEmpty, }; +const membersTableTabModule: StoryModule = { + meta: membersTableTabMeta, + Legacy: MembersTableTabLegacy, + Proposed: MembersTableTabProposed, + Empty: MembersTableTabEmpty, +}; + export const registry: StoryModule[] = [ // User Button userButtonModule, @@ -890,6 +903,7 @@ export const registry: StoryModule[] = [ // Organization Profile · Panels organizationProfileGeneralPanelModule, organizationProfileApiKeysPanelModule, + membersTableTabModule, // Organization Profile · Sections organizationProfileWorkspaceSectionModule, organizationProfileDangerSectionModule, diff --git a/packages/swingset/src/stories/fixtures/members-table-tab.ts b/packages/swingset/src/stories/fixtures/members-table-tab.ts new file mode 100644 index 00000000000..0148b11c166 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/members-table-tab.ts @@ -0,0 +1,89 @@ +import type { + MembersTableSort, + MembersTableTabViewProps, + OrganizationProfileMember, +} from '@clerk/mosaic/features/organization-profile/members-table-tab.types'; +import { useLocale } from '@clerk/mosaic/localization'; +import { useState } from 'react'; + +const roles = [ + { value: 'admin', label: 'Admin' }, + { value: 'member', label: 'Member' }, +]; +const exampleMembers = [ + 'Ada Lovelace', + 'Grace Hopper', + 'Alan Turing', + 'Katherine Johnson', + 'Margaret Hamilton', + 'Edsger Dijkstra', + 'Barbara Liskov', + 'Donald Knuth', + 'Radia Perlman', + 'John McCarthy', + 'Frances Allen', + 'Ken Thompson', +].map((name, index) => ({ + id: `member-${index}`, + name, + email: `${name.toLowerCase().replaceAll(' ', '.')}@example.com`, + joinedAt: Date.UTC(2026, 8, index + 1), + role: index % 3 === 0 ? 'admin' : 'member', + isCurrentUser: index === 0, +})); + +export function useMembersTableFixture({ proposed = false, empty = false } = {}): MembersTableTabViewProps { + const locale = useLocale(); + const [items, setItems] = useState(empty ? [] : exampleMembers); + 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.name} ${item.email}`.toLowerCase().includes(query)); + const sorted = sort + ? [...filtered].sort((a, b) => { + const comparison = + sort.column === 'joinedAt' ? a.joinedAt - b.joinedAt : 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))); + const members: OrganizationProfileMember[] = sorted + .slice((currentPage - 1) * pageSize, currentPage * pageSize) + .map(item => ({ + ...item, + joinedAtLabel: new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }).format(item.joinedAt), + roleLabel: roles.find(role => role.value === item.role)?.label ?? item.role, + })); + return { + members, + roles, + totalCount: sorted.length, + page: currentPage, + pageSize, + searchValue, + isLoading: false, + onSearchChange: value => { + setSearchValue(value); + setPage(1); + }, + onPageChange: setPage, + onPageSizeChange: setPageSize, + onChangeRole: (id, role) => setItems(current => current.map(item => (item.id === id ? { ...item, role } : item))), + onRemove: 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/members-table-tab.mdx b/packages/swingset/src/stories/members-table-tab.mdx new file mode 100644 index 00000000000..38dabd4b452 --- /dev/null +++ b/packages/swingset/src/stories/members-table-tab.mdx @@ -0,0 +1,32 @@ +import * as Stories from './members-table-tab.stories'; + +# MembersTableTab + +A controlled members table for the organization profile’s Members tab. The caller supplies the current page, role labels, loading state, and action callbacks. + +## Example + +### Legacy + +The baseline table shows the user, joined date, role, and optional row actions. + + + +## Examples + +### Proposed + +Sorting and selection are enabled through optional callbacks, following the API keys table. The caller sorts before pagination. The current user and deprovisioned members cannot be selected, removed, or have their role changed. 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/members-table-tab.stories.tsx b/packages/swingset/src/stories/members-table-tab.stories.tsx new file mode 100644 index 00000000000..0d150c66190 --- /dev/null +++ b/packages/swingset/src/stories/members-table-tab.stories.tsx @@ -0,0 +1,58 @@ +import { Tabs } from '@clerk/mosaic/components/tabs'; +import { MembersTableTabView } from '@clerk/mosaic/features/organization-profile/members-table-tab.view'; +import type { ReactNode } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import { useMembersTableFixture } from './fixtures/members-table-tab'; + +export { default as __source } from './members-table-tab.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs wire-up', + title: 'MembersTableTab', + label: 'Members table tab', + navigation: { category: 'Tabs' }, + source: 'packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx', +}; + +function MembersTab({ children }: { children: ReactNode }) { + return ( + + + Members + + + {children} + + ); +} + +export function Legacy() { + const props = useMembersTableFixture(); + return ( + + + + ); +} + +export function Proposed() { + const props = useMembersTableFixture({ proposed: true }); + return ( + + + + ); +} + +export function Empty() { + const props = useMembersTableFixture({ empty: true }); + return ( + + + + ); +} From d345e6152ad21f82d4baadfa8489c6b90ac6a022 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 22 Sep 2026 20:36:42 -0600 Subject: [PATCH 02/17] fix(swingset): render members tab content without navigation --- .../src/stories/members-table-tab.mdx | 3 +- .../src/stories/members-table-tab.stories.tsx | 32 ++----------------- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/packages/swingset/src/stories/members-table-tab.mdx b/packages/swingset/src/stories/members-table-tab.mdx index 38dabd4b452..b153de84eaa 100644 --- a/packages/swingset/src/stories/members-table-tab.mdx +++ b/packages/swingset/src/stories/members-table-tab.mdx @@ -4,6 +4,8 @@ import * as Stories from './members-table-tab.stories'; A controlled members table for the organization profile’s Members tab. The caller supplies the current page, role labels, loading state, and action 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 members table for the organization profile’s Members tab. The cal The baseline table shows the user, joined date, role, and optional row actions. - - Members - - - {children} - - ); -} - export function Legacy() { const props = useMembersTableFixture(); - return ( - - - - ); + return ; } export function Proposed() { const props = useMembersTableFixture({ proposed: true }); - return ( - - - - ); + return ; } export function Empty() { const props = useMembersTableFixture({ empty: true }); - return ( - - - - ); + return ; } From 60be7c735b4e466d6841ff48ffb7e81537433426 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 22 Sep 2026 20:42:59 -0600 Subject: [PATCH 03/17] feat(mosaic): add members table to the organization members panel --- ...rganization-profile-members-panel.view.tsx | 53 +++++++++++++++++++ .../organization-profile.types.ts | 3 +- .../organization-profile.view.tsx | 4 ++ .../organization-profile-members-tab.test.tsx | 27 ++++++++++ .../stories/fixtures/organization-profile.ts | 4 +- .../src/stories/organization-profile.mdx | 4 +- 6 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx create mode 100644 packages/swingset/src/stories/__tests__/organization-profile-members-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 new file mode 100644 index 00000000000..34129a51995 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx @@ -0,0 +1,53 @@ +import * as stylex from '@stylexjs/stylex'; + +import { panelStyles, Profile } from '../../components/profile'; +import { Tabs } from '../../components/tabs'; +import { useMessages } from '../../localization'; +import { mergeStyleProps, themeProps } from '../../props'; +import type { MembersTableTabViewProps } from './members-table-tab.types'; +import { MembersTableTabView } from './members-table-tab.view'; + +export interface OrganizationProfileMembersPanelViewProps { + members?: MembersTableTabViewProps; +} + +export function OrganizationProfileMembersPanelView({ members }: OrganizationProfileMembersPanelViewProps) { + const m = useMessages('organizationProfile'); + const membersMessages = useMessages('membersTableTab'); + const tabs = [ + { + id: 'members', + label: membersMessages.title, + content: members ? : null, + }, + ].filter(tab => tab.content !== null); + + return ( +
+ {m.pages.members} + {tabs.length > 0 ? ( + + + {tabs.map(tab => ( + + {tab.label} + + ))} + + + {tabs.map(tab => ( + + {tab.content} + + ))} + + ) : null} +
+ ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts index 485e27bdd3e..a811681c353 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts @@ -2,12 +2,13 @@ import type { ReactNode } from 'react'; import type { OrganizationProfileApiKeysPanelViewProps } from './organization-profile-api-keys-panel.types'; import type { OrganizationProfileGeneralPanelViewProps } from './organization-profile-general-panel.view'; +import type { OrganizationProfileMembersPanelViewProps } from './organization-profile-members-panel.view'; export type OrganizationProfilePageId = 'general' | 'members' | 'security' | 'billing' | 'apiKeys'; export interface OrganizationProfilePages { general: OrganizationProfileGeneralPanelViewProps; - members?: Record; + members?: OrganizationProfileMembersPanelViewProps; security?: Record; billing?: Record; apiKeys?: OrganizationProfileApiKeysPanelViewProps; 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 1b915c70345..544f0be8efb 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx @@ -16,6 +16,7 @@ import type { } from './organization-profile.types'; import { OrganizationProfileApiKeysPanelView } from './organization-profile-api-keys-panel.view'; import { OrganizationProfileGeneralPanelView } from './organization-profile-general-panel.view'; +import { OrganizationProfileMembersPanelView } from './organization-profile-members-panel.view'; import { OrganizationProfilePlaceholderPanelView } from './organization-profile-placeholder-panel.view'; export interface OrganizationProfileViewProps extends Omit { @@ -40,6 +41,9 @@ function BuiltInPage({ if (id === 'apiKeys') { return pages.apiKeys ? : null; } + if (id === 'members') { + return pages.members ? : null; + } return ; } diff --git a/packages/swingset/src/stories/__tests__/organization-profile-members-tab.test.tsx b/packages/swingset/src/stories/__tests__/organization-profile-members-tab.test.tsx new file mode 100644 index 00000000000..7d10afa52d1 --- /dev/null +++ b/packages/swingset/src/stories/__tests__/organization-profile-members-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 members tab', () => { + it.each([Default, Overlay])('renders members 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: 'Members' })); + const table = screen.getByRole('table', { name: 'Members' }); + expect(table).toBeVisible(); + expect(within(table).getByText('ada.lovelace@example.com')).toBeVisible(); + }); +}); diff --git a/packages/swingset/src/stories/fixtures/organization-profile.ts b/packages/swingset/src/stories/fixtures/organization-profile.ts index 42af19750b5..b9f1e69f5f5 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 { useMembersTableFixture } from './members-table-tab'; import { useOrganizationProfileAPIKeysFixture } from './organization-profile-api-keys'; import { usePreviewImage } from './use-preview-image'; @@ -22,6 +23,7 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF const [slug, setSlug] = useState(INITIAL_SLUG); const { imageUrl, showFile, clearImage } = usePreviewImage(); const apiKeys = useOrganizationProfileAPIKeysFixture(); + const members = useMembersTableFixture(); const save = async (field: 'name' | 'slug', apply: () => void) => { await settleAfter(800); @@ -48,7 +50,7 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF const pages: OrganizationProfileViewProps['pages'] = { general, - members: {}, + members: { members }, security: {}, billing: {}, apiKeys, diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx index 9b493845107..7d8f8abc527 100644 --- a/packages/swingset/src/stories/organization-profile.mdx +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -4,8 +4,8 @@ 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. General and API Keys are built; Members, Security, and Billing hold their place -with a placeholder. +order asked for. The Members panel owns its table tabs, including Members. Security and +Billing hold their place with a placeholder. Date: Tue, 22 Sep 2026 21:00:58 -0600 Subject: [PATCH 04/17] docs(swingset): list members table under panels --- packages/swingset/src/stories/members-table-tab.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/members-table-tab.stories.tsx b/packages/swingset/src/stories/members-table-tab.stories.tsx index a617c9496b7..86ccfdc2a98 100644 --- a/packages/swingset/src/stories/members-table-tab.stories.tsx +++ b/packages/swingset/src/stories/members-table-tab.stories.tsx @@ -11,8 +11,8 @@ export const meta: StoryMeta = { status: 'wip', substatus: 'needs wire-up', title: 'MembersTableTab', - label: 'Members table tab', - navigation: { category: 'Tabs' }, + label: 'Members table', + navigation: { category: 'Panels' }, source: 'packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx', }; From eecf5bc3796bcf2ec39f58fe036fda24f3281240 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 23 Sep 2026 09:18:38 -0600 Subject: [PATCH 05/17] fix(mosaic): align members tab with API key table patterns --- .../__tests__/members-table-tab.view.test.tsx | 62 ++- .../members-table-tab.messages.ts | 4 + .../members-table-tab.types.ts | 2 +- .../members-table-tab.view.tsx | 500 ++++++++++-------- .../src/stories/members-table-tab.mdx | 3 + 5 files changed, 358 insertions(+), 213 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx index 81b681b445f..fcee5139ae0 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/members-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 { MembersTableTabViewProps } from '../members-table-tab.types'; import { MembersTableTabView } from '../members-table-tab.view'; @@ -126,6 +128,9 @@ describe('MembersTableTabView', () => { expect(props.onChangeRole).toHaveBeenCalledWith('grace', 'admin'); await user.click(screen.getByRole('button', { name: 'Manage Grace Hopper' })); await user.click(screen.getByRole('menuitem', { name: 'Remove from organization' })); + expect(props.onRemove).not.toHaveBeenCalled(); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove from organization' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); expect(props.onRemove).toHaveBeenCalledWith('grace'); }); it('renders the supplied member metadata without optional controls', () => { @@ -144,3 +149,58 @@ describe('MembersTableTabView', () => { expect(screen.queryByRole('button', { name: /Manage|Invite/ })).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().members[1], id: 'ada', name: 'Ada Lovelace' }, + { ...propsFor().members[1], id: 'grace', name: 'Grace' }, + ]); + return ( + + { + await onMutation(id); + setItems(current => current.filter(item => item.id !== id)); + }} + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Manage Ada Lovelace' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove from organization' })); + 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 Lovelace' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove from organization' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove from organization' })); + 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: 'Remove from organization' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove from organization' })); + 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/members-table-tab.messages.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts index bf47d445301..70913db2248 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts @@ -20,6 +20,10 @@ export const membersTableTabMessages = { you: 'You', deprovisioned: 'Deprovisioned', banned: 'Banned', + removeTitle: 'Remove {name}?', + removeDescription: 'This member will lose access to the organization.', + removeError: 'Unable to remove this member. Please try again.', + cancel: 'Cancel', pagination: 'Members pagination', previousPage: 'Previous members page', nextPage: 'Next members page', diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.types.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.types.ts index c668b3f8d85..a345caf7d36 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.types.ts +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.types.ts @@ -29,7 +29,7 @@ export interface MembersTableTabViewProps { onPageSizeChange?: (pageSize: number) => void; onSearchChange: (value: string) => void; onInvite?: () => void; - onRemove?: (id: string) => void; + onRemove?: (id: string) => void | Promise; onChangeRole?: (id: string, role: string) => void; onBulkAction?: (ids: string[]) => void; sort?: MembersTableSort | null; diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx index f6689afb37b..66679cd079e 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/members-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 { Badge } from '../../components/badge'; import { Button } from '../../components/button'; @@ -15,6 +16,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'; @@ -45,230 +47,306 @@ export function MembersTableTabView({ }: MembersTableTabViewProps) { const m = useMessages('membersTableTab'); const searchInput = useRef(null); - const table = useDataTable({ data: members, getRowId, totalCount }); - const selectableRows = table.rows.filter(row => canManageMember(row.original)); - const allSelected = selectableRows.length > 0 && selectableRows.every(row => row.getIsSelected()); - const columnCount = 3 + Number(Boolean(onRemove)) + Number(Boolean(onBulkAction)); - const query = searchValue.trim(); - const sortHeader = (column: MembersTableSort['column']): Pick => ({ - sort: sort?.column === column ? sort.direction : 'none', - onSort: onSortChange - ? () => { + const inviteButton = useRef(null); + const removalFocus = useListRemovalFocus({ + ids: members.filter(canManageMember).map(getRowId), + onRemove, + fallback: () => inviteButton.current ?? searchInput.current, + }); + const removeDialog = useMemo(() => Confirmation.createHandle(), []); + const pagination = { pageIndex: page - 1, pageSize }; + const table = useDataTable({ + data: members, + 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 === 'name' || active.id === 'joinedAt' || active.id === 'role') + ? { 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: MembersTableSort['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, + }; + }; + const selectableRows = table.rows.filter(row => canManageMember(row.original)); + const allSelected = selectableRows.length > 0 && selectableRows.every(row => row.getIsSelected()); + const columnCount = 3 + Number(Boolean(onRemove)) + Number(Boolean(onBulkAction)); + const query = searchValue.trim(); return ( -
-
- - - - - { - table.setRowSelection({}); - onSearchChange(event.currentTarget.value); - }} - /> - {searchValue ? ( - - - - ) : null} - - {onInvite ? : null} -
- - - - {onBulkAction ? ( - row.getIsSelected())} - onChange={() => - table.setRowSelection(Object.fromEntries(selectableRows.map(row => [row.id, !allSelected]))) - } - /> - ) : null} - {m.name} - {m.joinedAt} - {m.role} - {onRemove ? ( - - {m.actions} - - ) : null} - - - - {isLoading ? ( - - - - {m.loading} - - - ) : table.rows.length === 0 ? ( - - - - {query ? m.empty : m.noMembers} - - {query ? fill(m.emptyDescription, { query }) : m.noMembersDescription} - - - - ) : ( - table.rows.map(row => { - const member = row.original; - return ( - +
+
+ + + + + table.setGlobalFilter(event.currentTarget.value)} + /> + {table.globalFilter ? ( + + + + ) : null} + + {onInvite ? ( + + ) : null} +
+ + + + {onBulkAction ? ( + row.getIsSelected())} + onChange={() => + table.setRowSelection(Object.fromEntries(selectableRows.map(row => [row.id, !allSelected]))) + } + /> + ) : null} + {m.name} + {m.joinedAt} + {m.role} + {onRemove ? ( + + {m.actions} + + ) : null} + + + + {isLoading ? ( + + + + {m.loading} + + + ) : table.rows.length === 0 ? ( + + + + {query ? m.empty : m.noMembers} + + {query ? fill(m.emptyDescription, { query }) : m.noMembersDescription} + + + + ) : ( + table.rows.map(row => { + const member = row.original; + return ( + + {onBulkAction ? ( + + ) : null} + +
+ - {member.email} - + {member.imageUrl ? ( + + ) : null} + + +
+ + {member.name} + {member.isCurrentUser ? ( + {m.you} + ) : member.isDeprovisioned ? ( + {m.deprovisioned} + ) : member.isBanned ? ( + {m.banned} + ) : null} + + + {member.email} + +
-
- - {member.joinedAtLabel} - - {onChangeRole && canManageMember(member) ? ( - { - if (value) { - onChangeRole(member.id, value); - } - }} - > - - - - ) : ( - member.roleLabel - )} - - {onRemove ? ( - - {!canManageMember(member) ? null : ( - - - - onRemove(member.id)} - > - {m.remove} - - - + + {member.joinedAtLabel} + + {onChangeRole && canManageMember(member) ? ( + { + if (value) { + onChangeRole(member.id, value); + } + }} + > + + + + ) : ( + member.roleLabel )} - ) : null} -
- ); - }) - )} -
-
- {totalCount > pageSize || (totalCount > 0 && onPageSizeChange) ? ( - { - table.setRowSelection({}); - onPageChange(next); + {onRemove ? ( + + {!canManageMember(member) ? null : ( + removeDialog.open(member)} + /> + )} + + ) : null} + + ); + }) + )} + + + {table.getPageCount() > 1 || (totalCount > 0 && onPageSizeChange) ? ( + table.setPagination(current => ({ ...current, pageIndex: next - 1 }))} + onPageSizeChange={ + onPageSizeChange ? next => table.setPagination({ pageIndex: 0, pageSize: next }) : undefined + } + /> + ) : null} +
+ {onRemove ? ( + fill(m.removeTitle, { name: member.name })} + description={m.removeDescription} + actionLabel={m.remove} + cancelLabel={m.cancel} + onConfirm={async member => { + try { + await removalFocus.remove(member.id); + } catch (error) { + throw error instanceof Error ? error : new Error(m.removeError); + } }} - onPageSizeChange={ - onPageSizeChange - ? next => { - table.setRowSelection({}); - onPageSizeChange(next); - onPageChange(1); - } - : undefined - } + finalFocus={removalFocus.finalFocus} /> ) : null} - + + ); +} + +function MemberActions({ + member, + registerTrigger, + onSelect, +}: { + member: OrganizationProfileMember; + registerTrigger: (id: string) => (element: HTMLButtonElement | null) => void; + onSelect: (member: OrganizationProfileMember) => void; +}) { + const m = useMessages('membersTableTab'); + const [triggerRef] = useState(() => registerTrigger(member.id)); + return ( + + + + onSelect(member)} + > + {m.remove} + + + ); } diff --git a/packages/swingset/src/stories/members-table-tab.mdx b/packages/swingset/src/stories/members-table-tab.mdx index b153de84eaa..6f7e10c3e5e 100644 --- a/packages/swingset/src/stories/members-table-tab.mdx +++ b/packages/swingset/src/stories/members-table-tab.mdx @@ -4,6 +4,8 @@ import * as Stories from './members-table-tab.stories'; A controlled members table for the organization profile’s Members tab. The caller supplies the current page, role labels, loading state, and action callbacks. +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. + 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 user, joined date, role, and optional row actions. Date: Wed, 23 Sep 2026 11:28:20 -0600 Subject: [PATCH 06/17] fix(mosaic): guard member selection and improve table accessibility --- .../__tests__/members-table-tab.view.test.tsx | 67 ++++++++++++++++++- .../members-table-tab.view.tsx | 34 ++++++---- .../primitives/hooks/use-data-table.test.ts | 21 ++++++ .../src/primitives/hooks/use-data-table.ts | 31 ++++++--- .../src/stories/members-table-tab.stories.tsx | 7 +- 5 files changed, 132 insertions(+), 28 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx index fcee5139ae0..6c375bacf06 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { act, render, type RenderResult, 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'; @@ -43,7 +43,9 @@ function propsFor(overrides: Partial = {}): MembersTab }; } -function renderView(overrides: Partial = {}) { +function renderView( + overrides: Partial = {}, +): RenderResult & { props: MembersTableTabViewProps } { const props = propsFor(overrides); return { props, @@ -78,7 +80,7 @@ describe('MembersTableTabView', () => { /> , ); - expect(screen.getByText('No members found')).toBeVisible(); + expect(screen.getByRole('status')).toHaveTextContent('No members found'); rerender( { expect(props.onBulkAction).not.toHaveBeenCalled(); expect(screen.queryByRole('toolbar')).not.toBeInTheDocument(); }); + it.each>([ + { page: 2 }, + { pageSize: 20 }, + { searchValue: 'Grace' }, + { sort: { column: 'role', direction: 'ascending' } }, + { sort: { column: 'name', direction: 'descending' } }, + ])('clears selection when the caller changes result state: %j', async change => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ onBulkAction: vi.fn(), sort: { column: 'name', direction: 'ascending' } }); + await user.click(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })); + rerender( + + + , + ); + expect(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })).toBeChecked(); + rerender( + + + , + ); + expect(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })).not.toBeChecked(); + }); + + it('does not retain hidden selection for protected members within a Shift-click range', async () => { + const user = userEvent.setup(); + const member = propsFor().members[1]; + const members = [ + { ...member, id: 'first', name: 'First' }, + { ...member, id: 'self', name: 'Self', isCurrentUser: true }, + { ...member, id: 'deprovisioned', name: 'Deprovisioned', isDeprovisioned: true }, + { ...member, id: 'last', name: 'Last' }, + ]; + const { props, rerender } = renderView({ members, totalCount: members.length, onBulkAction: vi.fn() }); + await user.click(screen.getByRole('checkbox', { name: 'Select First' })); + await user.keyboard('{Shift>}'); + await user.click(screen.getByRole('checkbox', { name: 'Select Last' })); + await user.keyboard('{/Shift}'); + expect(screen.getByRole('checkbox', { name: 'Select First' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Select Last' })).toBeChecked(); + rerender( + + ({ ...item, isCurrentUser: false, isDeprovisioned: false }))} + /> + , + ); + expect(screen.getByRole('checkbox', { name: 'Select Self' })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Select Deprovisioned' })).not.toBeChecked(); + }); + it('routes member actions by id and excludes protected members from removal and role editing', async () => { const user = userEvent.setup(); const { props } = renderView({ onRemove: vi.fn(), onChangeRole: vi.fn(), onInvite: vi.fn() }); diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx index 66679cd079e..59bb79ad1aa 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx @@ -1,5 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import { useMemo, useRef, useState } from 'react'; +import { type ReactElement, useEffect, useMemo, useRef, useState } from 'react'; import { Confirmation } from '../../blocks/confirmation'; import { Avatar } from '../../components/avatar'; @@ -44,7 +44,7 @@ export function MembersTableTabView({ onSortChange, isLoading, isFetching = false, -}: MembersTableTabViewProps) { +}: MembersTableTabViewProps): ReactElement { const m = useMessages('membersTableTab'); const searchInput = useRef(null); const inviteButton = useRef(null); @@ -59,6 +59,7 @@ export function MembersTableTabView({ data: members, totalCount, getRowId, + isRowSelectable: canManageMember, sorting: sort ? [{ id: sort.column, desc: sort.direction === 'descending' }] : [], onSortingChange: onSortChange ? update => { @@ -87,6 +88,13 @@ export function MembersTableTabView({ onSearchChange(typeof update === 'function' ? update(searchValue) : update); }, }); + const resetSelection = useRef(table.setRowSelection); + useEffect(() => { + resetSelection.current = table.setRowSelection; + }, [table.setRowSelection]); + useEffect(() => { + resetSelection.current({}); + }, [page, pageSize, searchValue, sort?.column, sort?.direction]); const sortHeader = (column: MembersTableSort['column']): Pick => { const active = table.sorting[0]; return { @@ -103,8 +111,6 @@ export function MembersTableTabView({ : undefined, }; }; - const selectableRows = table.rows.filter(row => canManageMember(row.original)); - const allSelected = selectableRows.length > 0 && selectableRows.every(row => row.getIsSelected()); const columnCount = 3 + Number(Boolean(onRemove)) + Number(Boolean(onBulkAction)); const query = searchValue.trim(); return ( @@ -116,7 +122,10 @@ export function MembersTableTabView({ xstyle={styles.search} > - + - +