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..5445047fec0 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx @@ -0,0 +1,242 @@ +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'; + +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'); + rerender( + + + , + ); + expect(screen.getByText('No members yet')).toBeVisible(); + rerender( + + + , + ); + expect(screen.getByRole('status')).toHaveTextContent('No members found'); + 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); + }); + 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 invite and role changes while withholding protected member actions', 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.getByRole('combobox', { name: /^Change role for Ada Lovelace/ })).toBeDisabled(); + 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'); + }); + it('withholds controls when their callbacks are unavailable', () => { + renderView(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Manage|Invite/ })).not.toBeInTheDocument(); + }); +}); + +it.each([true, false])('removes the confirmed member 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().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: '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/__tests__/organization-profile.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx new file mode 100644 index 00000000000..4035c47b6e4 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import { expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { OrganizationProfileView } from '../organization-profile.view'; + +it('keeps the Members placeholder until a table is configured', () => { + render( + + + , + ); + + expect(screen.getByText('Members is not built yet.')).toBeVisible(); +}); 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..70913db2248 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts @@ -0,0 +1,31 @@ +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', + 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', + 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..c6dc19c44d5 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts @@ -0,0 +1,16 @@ +import * as stylex from '@stylexjs/stylex'; + +import { 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' }, + name: { gap: space['2'], alignItems: 'center', display: 'flex' }, + 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..a345caf7d36 --- /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 | Promise; + 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..7c06d620dcb --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx @@ -0,0 +1,330 @@ +import * as stylex from '@stylexjs/stylex'; +import { useEffect, useMemo, useRef } from 'react'; + +import { Confirmation } from '../../blocks/confirmation'; +import { ActionMenu } from '../../components/action-menu'; +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 { Item } from '../../components/item'; +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 { 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'; +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 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, + isRowSelectable: canManageMember, + 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( + 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 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 { + 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 columnCount = 3 + Number(Boolean(onRemove)) + Number(Boolean(onBulkAction)); + const query = searchValue.trim(); + return ( + <> +
+
+ + + + + table.setGlobalFilter(event.currentTarget.value)} + /> + {table.globalFilter ? ( + + + + ) : null} + + {onInvite ? ( + + ) : null} +
+ + + + {onBulkAction ? ( + + ) : 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 && !member.isDeprovisioned ? ( + { + if (value) { + onChangeRole(member.id, value); + } + }} + > + + + + ) : ( + member.roleLabel + )} + + {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); + } + }} + finalFocus={removalFocus.finalFocus} + /> + ) : null} + + ); +} 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..c56d98db059 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,13 @@ function BuiltInPage({ if (id === 'apiKeys') { return pages.apiKeys ? : null; } + if (id === 'members') { + return pages.members?.members ? ( + + ) : ( + + ); + } return ; } 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/primitives/hooks/use-data-table.test.ts b/packages/mosaic/src/primitives/hooks/use-data-table.test.ts index 7d8f2ad507e..1b16c857c08 100644 --- a/packages/mosaic/src/primitives/hooks/use-data-table.test.ts +++ b/packages/mosaic/src/primitives/hooks/use-data-table.test.ts @@ -437,6 +437,27 @@ describe('useDataTable', () => { expect(result.current.rowSelection).toEqual(controlled); }); + it('excludes non-selectable rows from direct, range, and select-all selection', () => { + const { result } = renderHook(() => + useDataTable({ + data: DATA, + getRowId: row => String(row.id), + isRowSelectable: row => row.role === 'Admin', + }), + ); + act(() => result.current.rows[1].toggleSelected()); + expect(result.current.rowSelection).toEqual({}); + act(() => result.current.rows[0].toggleSelected()); + expect(result.current.getIsSomeRowsSelected()).toBe(true); + act(() => result.current.rows[2].toggleSelected({ range: true })); + expect(result.current.rowSelection).toEqual({ '1': true, '3': true }); + expect(result.current.getIsAllRowsSelected()).toBe(true); + act(() => result.current.toggleAllRowsSelected()); + expect(result.current.rowSelection).toEqual({}); + act(() => result.current.toggleAllRowsSelected()); + expect(result.current.rowSelection).toEqual({ '1': true, '3': true }); + }); + it('row.toggleSelected({ range: true }) selects every row between the last toggled row and this one', () => { const { result } = renderHook(() => useDataTable({ data: DATA })); diff --git a/packages/mosaic/src/primitives/hooks/use-data-table.ts b/packages/mosaic/src/primitives/hooks/use-data-table.ts index 9a2190eb081..8e148da54d8 100644 --- a/packages/mosaic/src/primitives/hooks/use-data-table.ts +++ b/packages/mosaic/src/primitives/hooks/use-data-table.ts @@ -19,6 +19,7 @@ function functionalUpdate(updater: Updater, old: T): T { export interface UseDataTableOptions { data: TData[]; getRowId?: (row: TData, index: number) => string; + isRowSelectable?: (row: TData) => boolean; totalCount?: number; sorting?: SortingState; @@ -135,10 +136,11 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa // ── Rows ──────────────────────────────────────────────────────────────────── - const { getRowId } = opts; + const { getRowId, isRowSelectable } = opts; const selectionAnchor = useRef(null); const rows = useMemo[]>(() => { const ids = opts.data.map((original, i) => (getRowId ? getRowId(original, i) : String(i))); + const selectableIds = ids.filter((_, i) => !isRowSelectable || isRowSelectable(opts.data[i])); return opts.data.map((original, i) => { const id = ids[i]; return { @@ -146,21 +148,25 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa original, getIsSelected: () => !!rowSelection[id], toggleSelected: options => { + const index = selectableIds.indexOf(id); + if (index === -1) { + return; + } const anchor = selectionAnchor.current; - const anchorIndex = options?.range && anchor !== null ? ids.indexOf(anchor) : -1; + const anchorIndex = options?.range && anchor !== null ? selectableIds.indexOf(anchor) : -1; selectionAnchor.current = id; setRowSelection(old => { const selected = !old[id]; if (anchorIndex === -1) { return { ...old, [id]: selected }; } - const rangeIds = ids.slice(Math.min(anchorIndex, i), Math.max(anchorIndex, i) + 1); + const rangeIds = selectableIds.slice(Math.min(anchorIndex, index), Math.max(anchorIndex, index) + 1); return { ...old, ...Object.fromEntries(rangeIds.map(rangeId => [rangeId, selected])) }; }); }, }; }); - }, [opts.data, getRowId, rowSelection, setRowSelection]); + }, [opts.data, getRowId, isRowSelectable, rowSelection, setRowSelection]); // ── Pagination helpers ────────────────────────────────────────────────────── @@ -198,19 +204,24 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa // ── Selection helpers ─────────────────────────────────────────────────────── + const selectableRows = useMemo( + () => rows.filter(row => !isRowSelectable || isRowSelectable(row.original)), + [rows, isRowSelectable], + ); + const getIsAllRowsSelected = useCallback( - () => rows.length > 0 && rows.every(r => !!rowSelection[r.id]), - [rows, rowSelection], + () => selectableRows.length > 0 && selectableRows.every(r => !!rowSelection[r.id]), + [selectableRows, rowSelection], ); const getIsSomeRowsSelected = useCallback( - () => rows.some(r => !!rowSelection[r.id]) && !getIsAllRowsSelected(), - [rows, rowSelection, getIsAllRowsSelected], + () => selectableRows.some(r => !!rowSelection[r.id]) && !getIsAllRowsSelected(), + [selectableRows, rowSelection, getIsAllRowsSelected], ); const toggleAllRowsSelected = useCallback(() => { - setRowSelection(getIsAllRowsSelected() ? {} : Object.fromEntries(rows.map(r => [r.id, true]))); - }, [rows, getIsAllRowsSelected, setRowSelection]); + setRowSelection(getIsAllRowsSelected() ? {} : Object.fromEntries(selectableRows.map(r => [r.id, true]))); + }, [selectableRows, getIsAllRowsSelected, setRowSelection]); return { rows, 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..3b475d09e5d 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': { + 'organization-profile-members-panel': dynamic(() => import('../stories/organization-profile-members-panel.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..7376ebed605 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -152,6 +152,12 @@ import { meta as organizationProfileGeneralPanelMeta, ReadOnly as OrganizationProfileGeneralPanelReadOnly, } from '../stories/organization-profile-general-panel.stories'; +import { + Empty as OrganizationProfileMembersPanelEmpty, + Legacy as OrganizationProfileMembersPanelLegacy, + meta as organizationProfileMembersPanelMeta, + Proposed as OrganizationProfileMembersPanelProposed, +} from '../stories/organization-profile-members-panel.stories'; import { Default as OrganizationProfileWorkspaceSectionDefault, meta as organizationProfileWorkspaceSectionMeta, @@ -862,6 +868,13 @@ const organizationProfileApiKeysPanelModule: StoryModule = { Empty: OrganizationProfileApiKeysPanelEmpty, }; +const organizationProfileMembersPanelModule: StoryModule = { + meta: organizationProfileMembersPanelMeta, + Legacy: OrganizationProfileMembersPanelLegacy, + Proposed: OrganizationProfileMembersPanelProposed, + Empty: OrganizationProfileMembersPanelEmpty, +}; + export const registry: StoryModule[] = [ // User Button userButtonModule, @@ -890,6 +903,7 @@ export const registry: StoryModule[] = [ // Organization Profile · Panels organizationProfileGeneralPanelModule, organizationProfileApiKeysPanelModule, + organizationProfileMembersPanelModule, // Organization Profile · Sections organizationProfileWorkspaceSectionModule, organizationProfileDangerSectionModule, diff --git a/packages/swingset/src/stories/__tests__/organization-profile-members-panel.test.tsx b/packages/swingset/src/stories/__tests__/organization-profile-members-panel.test.tsx new file mode 100644 index 00000000000..7bcb242e820 --- /dev/null +++ b/packages/swingset/src/stories/__tests__/organization-profile-members-panel.test.tsx @@ -0,0 +1,17 @@ +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { render, screen, within } from '@testing-library/react'; +import { expect, it } from 'vitest'; + +import { Empty, Legacy, Proposed } from '../organization-profile-members-panel.stories'; + +it.each([Legacy, Proposed, Empty])('previews the table inside the Members panel (%#)', Story => { + render( + + + , + ); + + expect(screen.getByRole('heading', { name: 'Members' })).toBeVisible(); + expect(screen.getByRole('tablist', { name: 'Members' })).toBeVisible(); + expect(within(screen.getByRole('tabpanel')).getByRole('table')).toBeVisible(); +}); 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/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/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-members-panel.mdx b/packages/swingset/src/stories/organization-profile-members-panel.mdx new file mode 100644 index 00000000000..f781adc97ef --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members-panel.mdx @@ -0,0 +1,36 @@ +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. + +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. + +## 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/organization-profile-members-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx new file mode 100644 index 00000000000..4ddfcf733db --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx @@ -0,0 +1,32 @@ +import { OrganizationProfileMembersPanelView } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { useMembersTableFixture } from './fixtures/members-table-tab'; + +export { default as __source } from './organization-profile-members-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs wire-up', + title: 'OrganizationProfileMembersPanel', + label: 'Members panel', + navigation: { category: 'Panels' }, + source: 'packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx', +}; + +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 ; +} diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx index 9b493845107..de79616d7d7 100644 --- a/packages/swingset/src/stories/organization-profile.mdx +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -4,14 +4,15 @@ 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.