Skip to content

Commit 16f804d

Browse files
refactor(mosaic): adopt usePendingAction in logo and enterprise accounts controllers (#10517)
1 parent 0aa0ac8 commit 16f804d

7 files changed

Lines changed: 55 additions & 83 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function OrganizationProfileLogoRowView({
4343
const errorText = useErrorText();
4444
const logo = useOrganizationProfileLogoController({ onChange, onRemove });
4545
const [rejection, setRejection] = useState<LocalizableError>();
46-
const error = rejection ?? logo.error;
46+
const error = rejection ? errorText(rejection) : logo.error;
4747
const remove = logo.onRemove;
4848
const handleRemove = remove
4949
? () => {
@@ -101,7 +101,7 @@ export function OrganizationProfileLogoRowView({
101101
onRemove={handleRemove}
102102
/>
103103
</Section.Item>
104-
<Section.Error>{error ? errorText(error) : undefined}</Section.Error>
104+
<Section.Error>{error}</Section.Error>
105105
</FileUpload.Root>
106106
);
107107
}
@@ -113,7 +113,7 @@ function LogoActions({
113113
}: {
114114
hasImage: boolean;
115115
canChange: boolean;
116-
onRemove?: () => Promise<void>;
116+
onRemove?: () => Promise<unknown>;
117117
}) {
118118
const m = useMessages('organizationProfileProfileSection');
119119
const { openFilePicker } = FileUpload.useFileUpload();
Lines changed: 19 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,51 @@
1-
import { useRef, useState } from 'react';
1+
import { useState } from 'react';
22

3-
import type { LocalizableError } from '../../../localization';
3+
import { usePendingAction } from '../../../hooks/use-pending-action';
44
import { FileUpload } from '../../../primitives/file-upload';
5-
import { toGlobalError } from '../../../utils/errors';
65

76
interface OrganizationProfileLogoControllerOptions {
87
onChange?: (file: File) => Promise<void>;
98
onRemove?: () => Promise<void>;
109
}
1110

1211
interface OrganizationProfileLogoController {
13-
onChange?: (file: File) => Promise<void>;
14-
onRemove?: () => Promise<void>;
12+
onChange?: (file: File) => Promise<boolean>;
13+
onRemove?: () => Promise<boolean>;
1514
isPending: boolean;
1615
previewUrl: string | undefined;
17-
error: LocalizableError | undefined;
16+
error: string | undefined;
1817
}
1918

2019
export function useOrganizationProfileLogoController({
2120
onChange,
2221
onRemove,
2322
}: OrganizationProfileLogoControllerOptions): OrganizationProfileLogoController {
24-
const [isPending, setIsPending] = useState(false);
25-
const [error, setError] = useState<LocalizableError>();
23+
const actions = usePendingAction<'change' | 'remove'>();
2624
const [preview, setPreview] = useState<File>();
2725
const previewUrl = FileUpload.useObjectUrl(preview);
28-
const inFlight = useRef(false);
29-
30-
const run = async (action: () => Promise<void>, revert?: () => void) => {
31-
if (inFlight.current) {
32-
return;
33-
}
34-
inFlight.current = true;
35-
setIsPending(true);
36-
setError(undefined);
37-
try {
38-
await action();
39-
} catch (cause) {
40-
revert?.();
41-
setError(toGlobalError(cause));
42-
} finally {
43-
inFlight.current = false;
44-
setIsPending(false);
45-
}
46-
};
4726

4827
return {
4928
onChange: onChange
50-
? file =>
51-
run(
52-
() => {
53-
setPreview(file);
54-
return onChange(file);
55-
},
56-
() => setPreview(undefined),
57-
)
29+
? async file => {
30+
const changed = await actions.run('change', () => {
31+
setPreview(file);
32+
return onChange(file);
33+
});
34+
if (!changed) {
35+
setPreview(current => (current === file ? undefined : current));
36+
}
37+
return changed;
38+
}
5839
: undefined,
5940
onRemove: onRemove
6041
? () =>
61-
run(async () => {
42+
actions.run('remove', async () => {
6243
await onRemove();
6344
setPreview(undefined);
6445
})
6546
: undefined,
66-
isPending,
47+
isPending: actions.isPending,
6748
previewUrl,
68-
error,
49+
error: actions.error,
6950
};
7051
}

‎packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus';
99
import { usePendingAction } from '../../../hooks/use-pending-action';
1010
import { useMessages } from '../../../localization';
1111
import type { ReverificationController } from '../../reverification';
12+
import { REDIRECT_HOLD_MS } from '../user-profile.utils';
1213
import type {
1314
ConnectedAccountActionResult,
1415
UserProfileConnectedAccount,
@@ -42,8 +43,6 @@ export interface UserProfileConnectedAccountsController {
4243
connectFinalFocus: DialogFocusTarget;
4344
}
4445

45-
const OAUTH_REDIRECT_HOLD_MS = 2000;
46-
4746
export function useUserProfileConnectedAccountsController({
4847
accounts,
4948
availableProviders,
@@ -74,7 +73,7 @@ export function useUserProfileConnectedAccountsController({
7473
const run = (id: string, action: (id: string) => Promise<ConnectedAccountActionResult>) =>
7574
void actions.run(id, async () => {
7675
if ((await action(id)) === 'redirecting') {
77-
await new Promise(resolve => setTimeout(resolve, OAUTH_REDIRECT_HOLD_MS));
76+
await new Promise(resolve => setTimeout(resolve, REDIRECT_HOLD_MS));
7877
}
7978
});
8079

‎packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,20 @@ describe('enterprise accounts', () => {
354354
retry.fail();
355355
});
356356

357+
it('clears the previous connection error when another connection starts', async () => {
358+
await renderSection();
359+
const user = userEvent.setup();
360+
const request = holdRequests('post', '/v1/me/external_accounts');
361+
await user.click(await screen.findByRole('button', { name: 'Connect Acme Okta' }));
362+
request.fail('enterprise_error', 'Acme is unavailable.');
363+
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Acme is unavailable.'));
364+
const other = holdRequests('post', '/v1/me/external_accounts');
365+
await user.click(screen.getByRole('button', { name: 'Connect Custom SAML' }));
366+
await waitFor(() => expect(other.requests).toHaveLength(1));
367+
expect(screen.queryByText('Acme is unavailable.')).toBeNull();
368+
other.fail();
369+
});
370+
357371
it('releases pending after the redirect grace period', async () => {
358372
const { clerk } = await renderSection();
359373
const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {});
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import { isReverificationCancelledError } from '@clerk/shared/error';
2-
import { useRef, useState } from 'react';
3-
4-
import type { ErrorDescription } from '../../../localization';
5-
import { useErrorText } from '../../../localization';
6-
import { toLocalizableError } from '../../../utils/errors';
1+
import { usePendingAction } from '../../../hooks/use-pending-action';
72
import type { ReverificationController } from '../../reverification';
3+
import { REDIRECT_HOLD_MS } from '../user-profile.utils';
84
import type {
95
EnterpriseAccountActionResult,
106
UserProfileEnterpriseAccount,
@@ -34,42 +30,20 @@ export function useUserProfileEnterpriseAccountsController({
3430
connectReverification,
3531
errorFallback,
3632
}: UserProfileEnterpriseAccountsControllerOptions): UserProfileEnterpriseAccountsController {
37-
const [pendingId, setPendingId] = useState<string>();
38-
const errorText = useErrorText();
39-
const [connectErrors, setConnectErrors] = useState<Record<string, ErrorDescription>>({});
40-
const connecting = useRef(false);
41-
42-
const run = async (id: string) => {
43-
if (connecting.current) {
44-
return;
45-
}
46-
connecting.current = true;
47-
setPendingId(id);
48-
setConnectErrors(({ [id]: _cleared, ...rest }) => rest);
49-
50-
try {
51-
if ((await onConnect(id)) === 'redirecting') {
52-
await new Promise(resolve => setTimeout(resolve, 2000));
53-
}
54-
} catch (error) {
55-
if (isReverificationCancelledError(error)) {
56-
return;
57-
}
58-
setConnectErrors(current => ({ ...current, [id]: toLocalizableError(error) }));
59-
} finally {
60-
connecting.current = false;
61-
setPendingId(undefined);
62-
}
63-
};
33+
const actions = usePendingAction({ errorFallback });
6434

6535
return {
6636
accounts,
67-
connections: connections.map(connection => {
68-
const error = connectErrors[connection.id];
69-
return error ? { ...connection, connectError: errorText(error, errorFallback) } : connection;
70-
}),
71-
pendingId,
72-
onConnect: id => void run(id),
37+
connections: connections.map(connection =>
38+
actions.errorKey === connection.id ? { ...connection, connectError: actions.error } : connection,
39+
),
40+
pendingId: actions.pendingKey,
41+
onConnect: id =>
42+
void actions.run(id, async () => {
43+
if ((await onConnect(id)) === 'redirecting') {
44+
await new Promise(resolve => setTimeout(resolve, REDIRECT_HOLD_MS));
45+
}
46+
}),
7347
connectReverification,
7448
};
7549
}

‎packages/mosaic/src/features/user-profile/user-profile.utils.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ import type { AttributeData } from '@clerk/shared/types';
33
export function isAttributeAvailable(attribute: AttributeData | undefined): boolean {
44
return Boolean(attribute?.enabled || attribute?.used_for_first_factor || attribute?.used_for_second_factor);
55
}
6+
7+
export const REDIRECT_HOLD_MS = 2000;

0 commit comments

Comments
 (0)