Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cf08ac6
feat(mosaic): add members table tab view
austincalvelage Sep 23, 2026
d345e61
fix(swingset): render members tab content without navigation
austincalvelage Sep 23, 2026
60be7c7
feat(mosaic): add members table to the organization members panel
austincalvelage Sep 23, 2026
ffe9429
docs(swingset): list members table under panels
austincalvelage Sep 23, 2026
eecf5bc
fix(mosaic): align members tab with API key table patterns
austincalvelage Sep 23, 2026
8b7d529
fix(mosaic): guard member selection and improve table accessibility
austincalvelage Sep 23, 2026
a64b96b
fix(mosaic): defer icon visibility to shared defaults
austincalvelage Sep 23, 2026
73d1d54
refactor(mosaic): infer member view return types
austincalvelage Sep 23, 2026
4164cab
test(mosaic): focus members coverage on table behavior
austincalvelage Sep 23, 2026
b25d6fc
fix(mosaic): preserve the unconfigured members placeholder
austincalvelage Sep 23, 2026
0587401
fix(mosaic): show a disabled role select for the current member
austincalvelage Sep 23, 2026
064aee3
fix(swingset): preview members tables inside their panel
austincalvelage Sep 23, 2026
f128292
refactor(mosaic): use shared action menu for members
austincalvelage Sep 23, 2026
e178390
refactor(mosaic): use item for member identity cells
austincalvelage Sep 23, 2026
3558f25
refactor(mosaic): use default member item styling
austincalvelage Sep 23, 2026
67520eb
refactor(mosaic): use item actions for member badges
austincalvelage Sep 23, 2026
3ce8983
fix(mosaic): keep member badges beside names
austincalvelage Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/members-table-tab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<MembersTableTabViewProps> = {}) {
const props = propsFor(overrides);
return {
props,
...render(
<MosaicProvider>
<MembersTableTabView {...props} />
</MosaicProvider>,
),
};
}

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(
<MosaicProvider>
<MembersTableTabView
{...props}
isLoading={false}
/>
</MosaicProvider>,
);
expect(screen.getByText('No members yet')).toBeVisible();
rerender(
<MosaicProvider>
<MembersTableTabView
{...props}
isLoading={false}
searchValue='Nobody'
/>
</MosaicProvider>,
);
expect(screen.getByRole('status')).toHaveTextContent('No members found');
rerender(
<MosaicProvider>
<MembersTableTabView
{...propsFor()}
isFetching
searchValue='Nobody'
/>
</MosaicProvider>,
);
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<Partial<MembersTableTabViewProps>>([
{ 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(
<MosaicProvider>
<MembersTableTabView
{...props}
sort={{ column: 'name', direction: 'ascending' }}
isFetching
/>
</MosaicProvider>,
);
expect(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })).toBeChecked();
rerender(
<MosaicProvider>
<MembersTableTabView
{...props}
{...change}
/>
</MosaicProvider>,
);
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(
<MosaicProvider>
<MembersTableTabView
{...props}
members={members.map(item => ({ ...item, isCurrentUser: false, isDeprovisioned: false }))}
/>
</MosaicProvider>,
);
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<void>();
const onMutation = vi
.fn<(id: string) => Promise<void>>()
.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 (
<MosaicProvider>
<MembersTableTabView
{...propsFor()}
members={items}
totalCount={items.length}
onInvite={hasInvite ? vi.fn() : undefined}
onRemove={async id => {
await onMutation(id);
setItems(current => current.filter(item => item.id !== id));
}}
/>
</MosaicProvider>
);
}
render(<Example />);
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();
});
Original file line number Diff line number Diff line change
@@ -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(
<MosaicProvider>
<OrganizationProfileView
activePage='members'
onPageChange={vi.fn()}
pages={{
general: { name: 'Acme', slug: 'acme', memberCount: 1 },
members: {},
}}
/>
</MosaicProvider>,
);

expect(screen.getByText('Members is not built yet.')).toBeVisible();
});
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe is an abstraction, currently duping between each

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah wonder if the toolbar/search is a subcomponent of table component. dateCell should be a prop on the table.cell to prevent wrapping.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'll follow up there. Table.Toolbar, Table.Search, <Table.Cell noWrap />?

Original file line number Diff line number Diff line change
@@ -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' },
});
Original file line number Diff line number Diff line change
@@ -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<void>;
onChangeRole?: (id: string, role: string) => void;
onBulkAction?: (ids: string[]) => void;
sort?: MembersTableSort | null;
onSortChange?: (sort: MembersTableSort | null) => void;
}
Loading
Loading