Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/tidy-invitations-tab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 3 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
EmailAddressJSON,
EnterpriseConnectionJSON,
OAuthProvider,
OrganizationInvitationJSON,
OrganizationJSON,
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
Expand Down Expand Up @@ -59,6 +60,7 @@ export interface FakeFapiState {
roles: RoleJSON[];
hasRoleSetMigration: boolean;
invitations: UserOrganizationInvitationJSON[];
organizationInvitations: OrganizationInvitationJSON[];
suggestions: OrganizationSuggestionJSON[];
apiKeys: ApiKeyJSON[];
verification: FakeVerificationState;
Expand Down Expand Up @@ -293,6 +295,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
roles: [],
hasRoleSetMigration: false,
invitations: [],
organizationInvitations: [],
suggestions: [],
apiKeys: [],
passwordUpdates: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { http, HttpResponse } from 'msw';

import type { FakeFapiState } from '../fake-fapi';
import { envelope, missing } from './shared';
import { envelope, missing, rejectUnknownParams } from './shared';

const INVITATION_STATUSES = ['pending', 'accepted', 'revoked', 'expired'];

export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path: string) => string) {
return [
http.get(fapiUrl('/v1/organizations/:organizationId/memberships'), ({ params, request }) => {
const url = new URL(request.url);
const rejected = rejectUnknownParams(url, ['query', 'role']);
if (rejected) {
return rejected;
}
const query = url.searchParams.get('query')?.toLowerCase();
if (query === '') {
return HttpResponse.json(
Expand Down Expand Up @@ -39,6 +45,61 @@ export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path:
)
: missing();
}),
http.get(fapiUrl('/v1/organizations/:organizationId/invitations'), ({ params, request }) => {
const url = new URL(request.url);
const rejected = rejectUnknownParams(url, ['status']);
if (rejected) {
return rejected;
}
const statuses = url.searchParams.getAll('status');
const invalidStatus = statuses.find(status => !INVITATION_STATUSES.includes(status));
if (invalidStatus) {
return HttpResponse.json(
{
errors: [
{
code: 'form_param_value_invalid',
message: `${invalidStatus} is not a valid value for status.`,
meta: { param_name: 'status' },
},
],
},
{ status: 422 },
);
}
const matching = state.organizationInvitations.filter(
invitation =>
invitation.organization_id === params.organizationId &&
(statuses.length === 0 || statuses.includes(invitation.status)),
);
const offset = Number(url.searchParams.get('offset') ?? 0);
const limit = Number(url.searchParams.get('limit') ?? 10);
return envelope({ data: matching.slice(offset, offset + limit), total_count: matching.length }, null);
}),
http.post(fapiUrl('/v1/organizations/:organizationId/invitations/:invitationId/revoke'), ({ params }) => {
const invitation = state.organizationInvitations.find(
item => item.organization_id === params.organizationId && item.id === params.invitationId,
);
if (!invitation || invitation.status !== 'pending') {
return HttpResponse.json(
{
errors: [
{
code: 'organization_invitation_not_pending',
message: 'not pending',
long_message: "The organization invitation is not in the 'pending' status.",
},
],
},
{ status: 404 },
);
}
const revoked = { ...invitation, status: 'revoked' as const };
state.organizationInvitations = state.organizationInvitations.map(item =>
item.id === revoked.id ? revoked : item,
);
return envelope(revoked, null);
}),
http.post(fapiUrl('/v1/organizations/:organizationId/memberships/:userId'), async ({ params, request }) => {
const member = state.memberships.find(
item => item.organization.id === params.organizationId && item.public_user_data?.user_id === params.userId,
Expand Down
22 changes: 22 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,25 @@ export function updateUser(state: ClientState, user: UserJSON): void {
export function missing() {
return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
}

const paginationParams = ['limit', 'offset', 'paginated'];

export function rejectUnknownParams(url: URL, allowed: readonly string[]) {
const unknown = [...url.searchParams.keys()].find(
key => !key.startsWith('_') && !paginationParams.includes(key) && !allowed.includes(key),
);
return unknown
? HttpResponse.json(
{
errors: [
{
code: 'form_param_unknown',
message: `${unknown} is not a valid parameter for this request.`,
meta: { param_name: unknown },
},
],
},
{ status: 422 },
)
: undefined;
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { act, render, screen, waitFor, within } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { deferred } from '../../../__tests__/async';
import { MosaicProvider } from '../../../mosaic-provider';
import type { InvitationsTableTabViewProps } from '../invitations-table-tab.types';
import { InvitationsTableTabView } from '../invitations-table-tab.view';
Expand All @@ -13,9 +11,8 @@ function propsFor(overrides: Partial<InvitationsTableTabViewProps> = {}): Invita
invitations: [{ id: 'invite-1', email: 'ada@example.com', invitedAtLabel: 'Sep 1, 2026', roleLabel: 'Admin' }],
totalCount: 1,
page: 1,
searchValue: '',
pageSize: 10,
isLoading: false,
onSearchChange: vi.fn(),
onPageChange: vi.fn(),
...overrides,
};
Expand Down Expand Up @@ -58,7 +55,7 @@ describe('InvitationsTableTabView', () => {
},
);

it('distinguishes loading, an empty invitation list, and an empty search', () => {
it('chooses loading, retained rows, an empty list, and the error state from the supplied state', () => {
const { props, rerender } = renderView({ invitations: [], totalCount: 0, isLoading: true });
expect(screen.getByRole('status')).toHaveTextContent('Loading invitations');
rerender(
Expand All @@ -69,60 +66,43 @@ describe('InvitationsTableTabView', () => {
/>
</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');
expect(screen.getByRole('status')).toHaveTextContent('No pending invitations');
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' }}
{...propsFor()}
isError
onRetry={vi.fn()}
/>
</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');
expect(screen.queryByText('ada@example.com')).toBeNull();
expect(within(screen.getByRole('table')).getByRole('alert')).toHaveTextContent('Unable to load invitations');
expect(screen.getAllByRole('button', { name: 'Try again' })).toHaveLength(1);
});

it('forwards paging without search, sorting, or page-size controls', async () => {
const user = userEvent.setup();
const { props } = renderView({ totalCount: 21, onBulkAction: vi.fn() });
await user.click(screen.getByRole('checkbox', { name: 'Select ada@example.com' }));
expect(screen.getByRole('checkbox', { name: 'Select ada@example.com' })).toBeChecked();
await user.click(screen.getByRole('button', { name: 'Next invitations page' }));
expect(props.onPageChange).toHaveBeenCalledWith(2);
await user.click(screen.getByRole('combobox', { name: /^Results per page/ }));
await user.click(screen.getByRole('option', { name: '20', exact: true }));
expect(props.onPageSizeChange).toHaveBeenCalledWith(20);
expect(props.onPageChange).toHaveBeenLastCalledWith(1);
expect(screen.queryByRole('searchbox')).toBeNull();
expect(screen.queryByRole('combobox', { name: /^Results per page/ })).toBeNull();
expect(screen.queryByRole('button', { name: 'Invited' })).toBeNull();
});

it('routes invite and withholds unavailable actions', async () => {
const user = userEvent.setup();
const { props, rerender } = renderView({ onInvite: vi.fn(), onRevoke: vi.fn() });
Expand All @@ -142,48 +122,3 @@ describe('InvitationsTableTabView', () => {
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});
});

it('revokes the confirmed invitation', async () => {
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}
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());
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');
});
Loading
Loading