-
Notifications
You must be signed in to change notification settings - Fork 477
feat(mosaic): add invitations tab to organization members panel #9886
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
austincalvelage
merged 11 commits into
main
from
austin/organization-invitations-table-tab
Sep 24, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
69ffa32
feat(mosaic): add invitations table tab view
austincalvelage 138131e
fix(swingset): render invitations tab content without navigation
austincalvelage 6167aac
feat(mosaic): add invitations to the organization members panel
austincalvelage 91c79b6
docs(swingset): list invitations table under panels
austincalvelage 473ae3d
fix(mosaic): align invitations tab with API key table patterns
austincalvelage 3fa7a8f
test(mosaic): focus invitations coverage on table behavior
austincalvelage da34066
fix(mosaic): preserve invitation fallback states
austincalvelage 8fe2c0b
fix(mosaic): announce empty invitation results
austincalvelage 5858109
fix(swingset): preview invitations inside the members panel
austincalvelage 542c4de
refactor(mosaic): use shared action menu for invitations
austincalvelage 5a27e1e
refactor(mosaic): use item for invitation identity cells
austincalvelage File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
189 changes: 189 additions & 0 deletions
189
...es/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| import { act, render, screen, waitFor, within } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
| import { useState } from 'react'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { deferred } from '../../../machines/__tests__/test-utils'; | ||
| import { MosaicProvider } from '../../../MosaicProvider'; | ||
| import type { InvitationsTableTabViewProps } from '../invitations-table-tab.types'; | ||
| import { InvitationsTableTabView } from '../invitations-table-tab.view'; | ||
|
|
||
| function propsFor(overrides: Partial<InvitationsTableTabViewProps> = {}): InvitationsTableTabViewProps { | ||
| return { | ||
| invitations: [{ id: 'invite-1', email: 'ada@example.com', invitedAtLabel: 'Sep 1, 2026', roleLabel: 'Admin' }], | ||
| totalCount: 1, | ||
| page: 1, | ||
| searchValue: '', | ||
| isLoading: false, | ||
| onSearchChange: vi.fn(), | ||
| onPageChange: vi.fn(), | ||
| ...overrides, | ||
| }; | ||
| } | ||
| function renderView(overrides: Partial<InvitationsTableTabViewProps> = {}) { | ||
| const props = propsFor(overrides); | ||
| return { | ||
| props, | ||
| ...render( | ||
| <MosaicProvider> | ||
| <InvitationsTableTabView {...props} /> | ||
| </MosaicProvider>, | ||
| ), | ||
| }; | ||
| } | ||
|
|
||
| describe('InvitationsTableTabView', () => { | ||
| it.each(['', ' '])('uses the localized revoke error when the rejection message is "%s"', async message => { | ||
| const user = userEvent.setup(); | ||
| render( | ||
| <MosaicProvider | ||
| localization={{ overrides: { 'invitationsTableTab.revokeError': 'Could not revoke invitation.' } }} | ||
| > | ||
| <InvitationsTableTabView {...propsFor({ onRevoke: vi.fn().mockRejectedValue(new Error(message)) })} /> | ||
| </MosaicProvider>, | ||
| ); | ||
|
|
||
| await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); | ||
| await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); | ||
| await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); | ||
|
|
||
| expect(await screen.findByRole('alert')).toHaveTextContent('Could not revoke invitation.'); | ||
| }); | ||
|
|
||
| it('distinguishes loading, an empty invitation list, and an empty search', () => { | ||
| const { props, rerender } = renderView({ invitations: [], totalCount: 0, isLoading: true }); | ||
| expect(screen.getByRole('status')).toHaveTextContent('Loading invitations'); | ||
| rerender( | ||
| <MosaicProvider> | ||
| <InvitationsTableTabView | ||
| {...props} | ||
| isLoading={false} | ||
| /> | ||
| </MosaicProvider>, | ||
| ); | ||
| expect(screen.getByText('No pending invitations')).toBeVisible(); | ||
| rerender( | ||
| <MosaicProvider> | ||
| <InvitationsTableTabView | ||
| {...props} | ||
| isLoading={false} | ||
| searchValue='Nobody' | ||
| /> | ||
| </MosaicProvider>, | ||
| ); | ||
| expect(screen.getByRole('status')).toHaveTextContent('No invitations found'); | ||
| rerender( | ||
| <MosaicProvider> | ||
| <InvitationsTableTabView | ||
| {...propsFor()} | ||
| isFetching | ||
| searchValue='Nobody' | ||
| /> | ||
| </MosaicProvider>, | ||
| ); | ||
| expect(screen.getByText('ada@example.com')).toBeVisible(); | ||
| expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'true'); | ||
| }); | ||
| it('connects invitation search, sorting, and paging while clearing the old selection', async () => { | ||
| const user = userEvent.setup(); | ||
| const { props, rerender } = renderView({ | ||
| totalCount: 21, | ||
| onBulkAction: vi.fn(), | ||
| onSortChange: vi.fn(), | ||
| onPageSizeChange: vi.fn(), | ||
| }); | ||
| await user.click(screen.getByRole('checkbox', { name: 'Select ada@example.com' })); | ||
| await user.click(screen.getByRole('button', { name: 'Invited' })); | ||
| expect(props.onSortChange).toHaveBeenCalledWith({ column: 'invitedAt', direction: 'ascending' }); | ||
| expect(screen.getByRole('checkbox', { name: 'Select ada@example.com' })).not.toBeChecked(); | ||
| rerender( | ||
| <MosaicProvider> | ||
| <InvitationsTableTabView | ||
| {...props} | ||
| sort={{ column: 'invitedAt', direction: 'ascending' }} | ||
| /> | ||
| </MosaicProvider>, | ||
| ); | ||
| await user.click(screen.getByRole('button', { name: 'Invited' })); | ||
| expect(props.onSortChange).toHaveBeenLastCalledWith({ column: 'invitedAt', direction: 'descending' }); | ||
| await user.type(screen.getByRole('searchbox', { name: 'Search invitations' }), 'A'); | ||
| expect(props.onSearchChange).toHaveBeenCalledWith('A'); | ||
| await user.click(screen.getByRole('button', { name: 'Next invitations page' })); | ||
| expect(props.onPageChange).toHaveBeenCalledWith(2); | ||
| await user.click(screen.getByRole('combobox', { name: /^Results per page/ })); | ||
| await user.click(screen.getByRole('option', { name: '20', exact: true })); | ||
| expect(props.onPageSizeChange).toHaveBeenCalledWith(20); | ||
| expect(props.onPageChange).toHaveBeenLastCalledWith(1); | ||
| }); | ||
| it('routes invite and withholds unavailable actions', async () => { | ||
| const user = userEvent.setup(); | ||
| const { props, rerender } = renderView({ onInvite: vi.fn(), onRevoke: vi.fn() }); | ||
| await user.click(screen.getByRole('button', { name: 'Invite members' })); | ||
| expect(props.onInvite).toHaveBeenCalledOnce(); | ||
| expect(screen.getByRole('button', { name: 'Manage ada@example.com' })).toBeVisible(); | ||
| rerender( | ||
| <MosaicProvider> | ||
| <InvitationsTableTabView | ||
| {...props} | ||
| onInvite={undefined} | ||
| onRevoke={undefined} | ||
| /> | ||
| </MosaicProvider>, | ||
| ); | ||
| expect(screen.queryByRole('button', { name: /Manage|Invite/ })).not.toBeInTheDocument(); | ||
| expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); | ||
| }); | ||
| }); | ||
|
|
||
| it.each([true, false])( | ||
| 'revokes the confirmed invitation 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().invitations[0], id: 'ada', email: 'ada@example.com' }, | ||
| { ...propsFor().invitations[0], id: 'grace', email: 'Grace' }, | ||
| ]); | ||
| return ( | ||
| <MosaicProvider> | ||
| <InvitationsTableTabView | ||
| {...propsFor()} | ||
| invitations={items} | ||
| totalCount={items.length} | ||
| onInvite={hasInvite ? vi.fn() : undefined} | ||
| onRevoke={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@example.com' })); | ||
| await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); | ||
| expect(onMutation).not.toHaveBeenCalled(); | ||
| await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); | ||
| expect(onMutation).toHaveBeenCalledExactlyOnceWith('ada'); | ||
| expect(screen.getByRole('alertdialog')).toBeInTheDocument(); | ||
| await act(async () => { | ||
| pending.resolve(); | ||
| await pending.promise; | ||
| }); | ||
| await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); | ||
| expect(screen.getByRole('button', { name: 'Manage Grace' })).toHaveFocus(); | ||
| await user.click(screen.getByRole('button', { name: 'Manage Grace' })); | ||
| await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); | ||
| await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); | ||
| await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); | ||
| expect(onMutation).toHaveBeenLastCalledWith('grace'); | ||
| expect( | ||
| hasInvite ? screen.getByRole('button', { name: 'Invite members' }) : screen.getByRole('searchbox'), | ||
| ).toHaveFocus(); | ||
| }, | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
27 changes: 27 additions & 0 deletions
27
packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| export const invitationsTableTabMessages = { | ||
| title: 'Invitations', | ||
| email: 'User', | ||
| invitedAt: 'Invited', | ||
| roleLabel: 'Role', | ||
| actions: 'Actions', | ||
| search: 'Search invitations', | ||
| clearSearch: 'Clear search', | ||
| invite: 'Invite members', | ||
| empty: 'No invitations found', | ||
| emptyDescription: 'Your search for "{query}" did not return any results.', | ||
| noInvitations: 'No pending invitations', | ||
| noInvitationsDescription: 'Invite people to add them to this organization.', | ||
| loading: 'Loading invitations', | ||
| selectAll: 'Select all invitations on this page', | ||
| select: 'Select {name}', | ||
| manage: 'Manage {name}', | ||
| revoke: 'Revoke invitation', | ||
| revokeTitle: 'Revoke {name}?', | ||
| revokeDescription: 'This invitation will no longer allow the recipient to join the organization.', | ||
| revokeError: 'Unable to revoke this invitation. Please try again.', | ||
| cancel: 'Cancel', | ||
| pagination: 'Invitations pagination', | ||
| previousPage: 'Previous invitations page', | ||
| nextPage: 'Next invitations page', | ||
| pageSize: 'Results per page', | ||
| } as const; |
15 changes: 15 additions & 0 deletions
15
packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again this is duplicated across each for now. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| 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' }, | ||
| dateCell: { whiteSpace: 'nowrap' }, | ||
| }); |
30 changes: 30 additions & 0 deletions
30
packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| export interface OrganizationProfileInvitation { | ||
| id: string; | ||
| email: string; | ||
| imageUrl?: string; | ||
| invitedAtLabel: string; | ||
| roleLabel: string; | ||
| } | ||
|
|
||
| export interface InvitationsTableSort { | ||
| column: 'email' | 'invitedAt' | 'roleLabel'; | ||
| direction: 'ascending' | 'descending'; | ||
| } | ||
|
|
||
| export interface InvitationsTableTabViewProps { | ||
| invitations: OrganizationProfileInvitation[]; | ||
| totalCount: number; | ||
| page: number; | ||
| pageSize?: number; | ||
| searchValue: string; | ||
| isLoading: boolean; | ||
| isFetching?: boolean; | ||
| onPageChange: (page: number) => void; | ||
| onPageSizeChange?: (pageSize: number) => void; | ||
| onSearchChange: (value: string) => void; | ||
| onInvite?: () => void; | ||
| onRevoke?: (id: string) => void | Promise<void>; | ||
| onBulkAction?: (ids: string[]) => void; | ||
| sort?: InvitationsTableSort | null; | ||
| onSortChange?: (sort: InvitationsTableSort | null) => void; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Add an explicit return type to
Example.Declare the React element return type on this new named test component. As per coding guidelines, “Always define explicit return types for functions, especially public APIs.”
🤖 Prompt for AI Agents
Source: Coding guidelines