Skip to content
Open
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/account-button-infinite-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/account-button-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
7 changes: 2 additions & 5 deletions packages/swingset/src/stories/account-button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @jsxImportSource @emotion/react */
import { type AccountButtonProps, AccountButtonView } from '@clerk/ui/mosaic/account/account-button.view';
import { AccountButtonView, type AccountButtonViewProps } from '@clerk/ui/mosaic/account/account-button.view';

import type { StoryMeta } from '@/lib/types';

Expand Down Expand Up @@ -29,15 +29,14 @@ const handlers = {
onCreateOrganization: () => {},
onAddAccount: () => {},
onUpgrade: () => {},
} satisfies Partial<AccountButtonProps>;
} satisfies Partial<AccountButtonViewProps>;

const preston = { sessionId: 'sess_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@clerk.dev' };

export function Default(_args: Record<string, unknown>) {
return (
<AccountButtonView
{...handlers}
status='ready'
activeAccount={preston}
activeOrganizationId='org_clerk_app'
hasOrganizations
Expand Down Expand Up @@ -65,7 +64,6 @@ export function Personal(_args: Record<string, unknown>) {
return (
<AccountButtonView
{...handlers}
status='ready'
activeAccount={{
sessionId: 'sess_cam',
userId: 'user_cam',
Expand All @@ -88,7 +86,6 @@ export function MultipleAccounts(_args: Record<string, unknown>) {
return (
<AccountButtonView
{...handlers}
status='ready'
activeAccount={preston}
activeOrganizationId='org_clerk_app'
hasOrganizations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,19 @@ let user: FakeUser | null;
let session: { id: string; checkAuthorization: ReturnType<typeof vi.fn> } | null;
let organization: { id: string } | null;
let membershipRequests: { count: number };
let userMemberships: { data: unknown[]; count: number; revalidate: ReturnType<typeof vi.fn> };
let userInvitations: { data: unknown[]; count: number; revalidate: ReturnType<typeof vi.fn> };
let userSuggestions: { data: unknown[]; count: number; revalidate: ReturnType<typeof vi.fn> };
type FakePaginated = {
data: unknown[];
count: number;
revalidate: ReturnType<typeof vi.fn>;
hasNextPage?: boolean;
isFetching?: boolean;
};
let userMemberships: FakePaginated;
let userInvitations: FakePaginated;
let userSuggestions: FakePaginated;
let signedInSessions: FakeSession[];
let singleSessionMode: boolean;
let controllerOptions: AccountButtonControllerOptions | undefined;

let setActive: ReturnType<typeof vi.fn>;
let signOut: ReturnType<typeof vi.fn>;
Expand All @@ -50,8 +59,11 @@ vi.mock('@clerk/shared/react', async importOriginal => {
buildOrganizationProfileUrl: () => '/org-profile',
buildCreateOrganizationUrl: () => '/create-org',
buildSignInUrl: () => '/sign-in',
buildAfterSignOutUrl: () => '/after-signout',
buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-signout',
client: { signedInSessions },
__internal_environment: {
authConfig: { singleSessionMode },
displayConfig: {
afterCreateOrganizationUrl: '/after-create',
afterSwitchSessionUrl: '/after-switch',
Expand Down Expand Up @@ -121,14 +133,16 @@ beforeEach(() => {
setActive = vi.fn().mockResolvedValue(undefined);
signOut = vi.fn().mockResolvedValue(undefined);
navigate = vi.fn().mockResolvedValue(undefined);
singleSessionMode = false;
controllerOptions = undefined;
});

afterEach(() => {
vi.clearAllMocks();
});

function Harness() {
const c = useAccountButtonController();
const c = useAccountButtonController(controllerOptions);
if (c.status !== 'ready') {
return <output data-testid='status'>{c.status}</output>;
}
Expand All @@ -142,9 +156,14 @@ function Harness() {
<output data-testid='active-org'>{String(c.activeOrganizationId)}</output>
<output data-testid='has-orgs'>{String(c.hasOrganizations)}</output>
<output data-testid='additional'>{c.additionalAccounts.map(a => a.userId).join(',')}</output>
<output data-testid='can-add-account'>{String(Boolean(c.onAddAccount))}</output>
<output data-testid='can-sign-out-all'>{String(Boolean(c.onSignOutAll))}</output>
<output data-testid='memberships'>{JSON.stringify(c.memberships)}</output>
<output data-testid='suggestions'>{JSON.stringify(c.suggestions)}</output>
<output data-testid='invitations'>{JSON.stringify(c.invitations)}</output>
<output data-testid='has-more-rows'>{String(c.hasMoreRows)}</output>
<output data-testid='is-fetching-rows'>{String(c.isFetchingRows)}</output>
<output data-testid='has-load-more-ref'>{String(typeof c.loadMoreRef === 'function')}</output>
<button
type='button'
onClick={() => void c.onSelectOrganization?.('org_9')}
Expand Down Expand Up @@ -322,16 +341,55 @@ describe('useAccountButtonController', () => {
expect(rows[1].membershipRequestCount).toBeUndefined();
});

it('selects an organization via setActive with a redirect, and personal via a null organization', () => {
it('exposes a load-more ref and derives paging flags from the org lists', () => {
const { rerender } = render(<Harness />);
expect(screen.getByTestId('has-load-more-ref')).toHaveTextContent('true');
expect(screen.getByTestId('has-more-rows')).toHaveTextContent('false');
expect(screen.getByTestId('is-fetching-rows')).toHaveTextContent('false');

userInvitations.hasNextPage = true;
userSuggestions.isFetching = true;
rerender(<Harness />);
expect(screen.getByTestId('has-more-rows')).toHaveTextContent('true');
expect(screen.getByTestId('is-fetching-rows')).toHaveTextContent('true');
});

it('selects an organization and personal via setActive, with no redirect when no select url is configured', () => {
render(<Harness />);

fireEvent.click(screen.getByText('select-org'));
expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined });

fireEvent.click(screen.getByText('select-personal'));
expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined });
});

it('resolves a function-form afterSelect url from the selected organization and the user', () => {
controllerOptions = {
afterSelectOrganizationUrl: org => `/o/${org.id}`,
afterSelectPersonalUrl: u => `/me/${u.id}`,
};
render(<Harness />);

fireEvent.click(screen.getByText('select-org'));
expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/org_9' });

fireEvent.click(screen.getByText('select-personal'));
expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/me/user_1' });
});

it('resolves a token-string afterSelect url against the selected organization and the user', () => {
controllerOptions = {
afterSelectOrganizationUrl: '/o/:id',
afterSelectPersonalUrl: '/me/:id',
};
render(<Harness />);

fireEvent.click(screen.getByText('select-org'));
expect(setActive).toHaveBeenCalledWith(
expect.objectContaining({ organization: 'org_9', redirectUrl: '/after-create' }),
);
expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/org_9' });

fireEvent.click(screen.getByText('select-personal'));
expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ organization: null }));
expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/me/user_1' });
});

it('switches and signs out sessions via setActive and signOut', () => {
Expand All @@ -341,10 +399,18 @@ describe('useAccountButtonController', () => {
expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ session: 'sess_2' }));

fireEvent.click(screen.getByText('sign-out-one'));
expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2' });
expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-single-signout' });

fireEvent.click(screen.getByText('sign-out-all'));
expect(signOut).toHaveBeenCalledWith();
expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-signout' });
});

it('signs out the only session with the after-sign-out url, not the multi-session url', () => {
signedInSessions = [{ id: 'sess_1', user: user as FakeUser }];
render(<Harness />);

fireEvent.click(screen.getByText('sign-out-one'));
expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-signout' });
});

it('navigates for manage and create actions using clerk build URLs', () => {
Expand All @@ -363,6 +429,19 @@ describe('useAccountButtonController', () => {
expect(navigate).toHaveBeenCalledWith('/create-org');
});

it('offers add-account and sign-out-all in multi-session mode', () => {
render(<Harness />);
expect(screen.getByTestId('can-add-account')).toHaveTextContent('true');
expect(screen.getByTestId('can-sign-out-all')).toHaveTextContent('true');
});

it('omits add-account and sign-out-all in single-session mode', () => {
singleSessionMode = true;
render(<Harness />);
expect(screen.getByTestId('can-add-account')).toHaveTextContent('false');
expect(screen.getByTestId('can-sign-out-all')).toHaveTextContent('false');
});

it('accepts invitations and suggestions, then revalidates the collection', async () => {
render(<Harness />);

Expand Down
Loading
Loading