Skip to content

Commit b9e19df

Browse files
committed
Improve loading state
1 parent cb300f4 commit b9e19df

5 files changed

Lines changed: 135 additions & 84 deletions

File tree

packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,7 @@ import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types';
33
import React from 'react';
44

55
import { useEnvironment, withCoreUserGuard } from '@/contexts';
6-
import {
7-
Box,
8-
Col,
9-
descriptors,
10-
Flex,
11-
Flow,
12-
Icon,
13-
localizationKeys,
14-
Spinner,
15-
Text,
16-
useAppearance,
17-
} from '@/customizables';
6+
import { Box, Col, descriptors, Flex, Flow, Icon, localizationKeys, Text, useAppearance } from '@/customizables';
187
import { ApplicationLogo } from '@/elements/ApplicationLogo';
198
import { withCardStateProvider } from '@/elements/contexts';
209
import { NavBar, NavbarContextProvider } from '@/elements/Navbar';
@@ -126,23 +115,12 @@ const AuthenticatedContent = withCoreUserGuard(() => {
126115
flex: 1,
127116
})}
128117
>
129-
{isLoadingEnterpriseConnections ? (
130-
<Flex
131-
align='center'
132-
justify='center'
133-
sx={{ flex: 1 }}
134-
>
135-
<Spinner
136-
size='lg'
137-
colorScheme='primary'
138-
elementDescriptor={descriptors.spinner}
139-
/>
140-
</Flex>
141-
) : (
142-
<ConfigureSSOFlowProvider enterpriseConnection={enterpriseConnection}>
143-
<ConfigureSSOWizardPanel />
144-
</ConfigureSSOFlowProvider>
145-
)}
118+
<ConfigureSSOFlowProvider
119+
enterpriseConnection={enterpriseConnection}
120+
isLoading={isLoadingEnterpriseConnections}
121+
>
122+
<ConfigureSSOWizardPanel />
123+
</ConfigureSSOFlowProvider>
146124
</Col>
147125
</NavbarContextProvider>
148126
</ProfileCard.Root>
@@ -156,6 +134,7 @@ const ConfigureSSOWizardPanel = () => {
156134
<Wizard.Root
157135
steps={CONFIGURE_SSO_STEPS}
158136
data={data}
137+
isLoading={data.isLoading}
159138
>
160139
<Wizard.Header />
161140
<Wizard.Content />

packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,25 @@ export interface ConfigureSSOData {
1818
enterpriseConnection: EnterpriseConnectionResource | undefined;
1919
}
2020

21-
export type ConfigureSSOContextValue = ConfigureSSOData;
22-
23-
interface ConfigureSSOFlowProviderProps {
21+
export interface ConfigureSSOContextValue extends ConfigureSSOData {
2422
/**
25-
* The user's enterprise connection, fetched by the parent so that
26-
* the wizard can show a loading state before mounting the panel.
27-
* `undefined` when the user has no enterprise connection
23+
* `true` while the parent is still fetching the user's enterprise
24+
* connection
2825
*/
26+
isLoading: boolean;
27+
}
28+
29+
interface ConfigureSSOFlowProviderProps {
2930
enterpriseConnection: EnterpriseConnectionResource | undefined;
31+
isLoading: boolean;
3032
}
3133

3234
const ConfigureSSOFlowContext = React.createContext<ConfigureSSOContextValue | null>(null);
3335
ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext';
3436

3537
export const ConfigureSSOFlowProvider = ({
3638
enterpriseConnection,
39+
isLoading,
3740
children,
3841
}: PropsWithChildren<ConfigureSSOFlowProviderProps>): JSX.Element => {
3942
const { user } = useUser();
@@ -44,8 +47,9 @@ export const ConfigureSSOFlowProvider = ({
4447
() => ({
4548
enterpriseConnection,
4649
domainAlreadyVerified,
50+
isLoading,
4751
}),
48-
[enterpriseConnection, domainAlreadyVerified],
52+
[enterpriseConnection, domainAlreadyVerified, isLoading],
4953
);
5054

5155
return <ConfigureSSOFlowContext.Provider value={value}>{children}</ConfigureSSOFlowContext.Provider>;

packages/ui/src/elements/Wizard/Wizard.tsx

Lines changed: 105 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22

3-
import { Badge, Button, descriptors, Flex, Icon, Text, useLocalizations } from '@/customizables';
3+
import { Badge, Box, Button, descriptors, Flex, Icon, Spinner, Text, useLocalizations } from '@/customizables';
44
import { CaretLeft, CaretRight } from '@/icons';
55
import { Route, Switch, useRouter } from '@/router';
66

@@ -14,11 +14,17 @@ interface WizardRootProps<TData = unknown> {
1414
* referenced shape changes, the active step list is recomputed
1515
*/
1616
data?: TData;
17+
/**
18+
* `true` while the parent flow is still loading async dependencies.
19+
* The header renders a skeleton breadcrumb, the content renders a
20+
* centered spinner, and the footer's buttons are disabled
21+
*/
22+
isLoading?: boolean;
1723
children: React.ReactNode;
1824
}
1925

2026
const Root = <TData,>(props: WizardRootProps<TData>): JSX.Element => {
21-
const { steps, data, children } = props;
27+
const { steps, data, isLoading = false, children } = props;
2228
const router = useRouter();
2329

2430
const activeSteps = React.useMemo(() => steps.filter(s => !s.shouldSkip?.(data as TData)), [steps, data]);
@@ -127,6 +133,7 @@ const Root = <TData,>(props: WizardRootProps<TData>): JSX.Element => {
127133
currentStep={currentStep}
128134
innerSteps={innerSteps}
129135
currentInnerStep={currentInnerStep}
136+
isLoading={isLoading}
130137
goNext={goNext}
131138
goPrev={goPrev}
132139
goToStep={goToStep}
@@ -169,7 +176,24 @@ const StepRoutes = <TData,>({ step }: { step: WizardStep<TData> }): JSX.Element
169176
* doesn't match another main step (e.g. its own inner-step paths)
170177
*/
171178
const Content = (): JSX.Element | null => {
172-
const { activeSteps } = useWizard();
179+
const { activeSteps, isLoading } = useWizard();
180+
181+
if (isLoading) {
182+
return (
183+
<Flex
184+
align='center'
185+
justify='center'
186+
sx={{ flex: 1 }}
187+
>
188+
<Spinner
189+
size='xs'
190+
colorScheme='neutral'
191+
elementDescriptor={descriptors.spinner}
192+
/>
193+
</Flex>
194+
);
195+
}
196+
173197
if (activeSteps.length === 0) {
174198
return null;
175199
}
@@ -196,7 +220,7 @@ const Content = (): JSX.Element | null => {
196220
* are clickable for backwards navigation, future steps are disabled
197221
*/
198222
const Header = (): JSX.Element => {
199-
const { activeSteps, currentIndex, goToStep } = useWizard();
223+
const { activeSteps, currentIndex, isLoading, goToStep } = useWizard();
200224
const { t } = useLocalizations();
201225

202226
return (
@@ -219,47 +243,51 @@ const Header = (): JSX.Element => {
219243

220244
return (
221245
<React.Fragment key={step.id}>
222-
<Button
223-
variant='unstyled'
224-
isDisabled={!isReachable}
225-
onClick={() => {
226-
if (isReachable) {
227-
void goToStep(step.id);
228-
}
229-
}}
230-
sx={theme => ({
231-
gap: theme.space.$1x5,
232-
padding: 0,
233-
color: isCurrent ? theme.colors.$colorForeground : theme.colors.$colorMutedForeground,
234-
})}
235-
>
236-
<Flex
237-
align='center'
238-
justify='center'
246+
{isLoading ? (
247+
<SkeletonBreadcrumbStep />
248+
) : (
249+
<Button
250+
variant='unstyled'
251+
isDisabled={!isReachable}
252+
onClick={() => {
253+
if (isReachable) {
254+
void goToStep(step.id);
255+
}
256+
}}
239257
sx={theme => ({
240-
width: theme.sizes.$5,
241-
height: theme.sizes.$5,
242-
borderRadius: theme.radii.$circle,
243-
fontSize: theme.fontSizes.$xs,
244-
fontWeight: theme.fontWeights.$semibold,
245-
backgroundColor: isCurrent
246-
? theme.colors.$colorForeground
247-
: isCompleted
248-
? theme.colors.$neutralAlpha200
249-
: theme.colors.$neutralAlpha100,
250-
color: isCurrent ? theme.colors.$colorBackground : theme.colors.$colorMutedForeground,
258+
gap: theme.space.$1x5,
259+
padding: 0,
260+
color: isCurrent ? theme.colors.$colorForeground : theme.colors.$colorMutedForeground,
251261
})}
252262
>
253-
{index + 1}
254-
</Flex>
255-
<Text
256-
as='span'
257-
variant='body'
258-
sx={{ fontWeight: 'inherit', color: 'inherit' }}
259-
>
260-
{label}
261-
</Text>
262-
</Button>
263+
<Flex
264+
align='center'
265+
justify='center'
266+
sx={theme => ({
267+
width: theme.sizes.$5,
268+
height: theme.sizes.$5,
269+
borderRadius: theme.radii.$circle,
270+
fontSize: theme.fontSizes.$xs,
271+
fontWeight: theme.fontWeights.$semibold,
272+
backgroundColor: isCurrent
273+
? theme.colors.$colorForeground
274+
: isCompleted
275+
? theme.colors.$neutralAlpha200
276+
: theme.colors.$neutralAlpha100,
277+
color: isCurrent ? theme.colors.$colorBackground : theme.colors.$colorMutedForeground,
278+
})}
279+
>
280+
{index + 1}
281+
</Flex>
282+
<Text
283+
as='span'
284+
variant='body'
285+
sx={{ fontWeight: 'inherit', color: 'inherit' }}
286+
>
287+
{label}
288+
</Text>
289+
</Button>
290+
)}
263291
{index < activeSteps.length - 1 && (
264292
<Icon
265293
icon={CaretRight}
@@ -274,6 +302,30 @@ const Header = (): JSX.Element => {
274302
);
275303
};
276304

305+
const SkeletonBreadcrumbStep = (): JSX.Element => (
306+
<Flex
307+
align='center'
308+
sx={t => ({ gap: t.space.$1x5 })}
309+
>
310+
<Box
311+
sx={t => ({
312+
width: t.sizes.$5,
313+
height: t.sizes.$5,
314+
borderRadius: t.radii.$circle,
315+
backgroundColor: t.colors.$neutralAlpha100,
316+
})}
317+
/>
318+
<Box
319+
sx={t => ({
320+
width: t.sizes.$16,
321+
height: t.space.$3,
322+
borderRadius: t.radii.$md,
323+
backgroundColor: t.colors.$neutralAlpha100,
324+
})}
325+
/>
326+
</Flex>
327+
);
328+
277329
/**
278330
* Compact "Step X / Y" badge that tracks the current main step's
279331
* inner-step progress. Renders nothing when the current step has no
@@ -322,6 +374,12 @@ interface FooterProps {
322374
* default)
323375
*/
324376
hidePrevious?: boolean;
377+
/**
378+
* Force-disables both Previous and Continue regardless of the
379+
* wizard's own state. Useful while async dependencies of the flow
380+
* are still loading
381+
*/
382+
isDisabled?: boolean;
325383
}
326384

327385
/**
@@ -330,8 +388,9 @@ interface FooterProps {
330388
* simply advances to the next step
331389
*/
332390
const Footer = (props: FooterProps): JSX.Element => {
333-
const { previousLabel = 'Previous', continueLabel = 'Continue', hidePrevious = false } = props;
334-
const { isFirstStep, isLastStep, goPrev, goNext, continueAction } = useWizard();
391+
const { previousLabel = 'Previous', continueLabel = 'Continue', hidePrevious = false, isDisabled = false } = props;
392+
const { isFirstStep, isLastStep, isLoading, goPrev, goNext, continueAction } = useWizard();
393+
const isForceDisabled = isDisabled || isLoading;
335394
const { t } = useLocalizations();
336395

337396
const continueLabelToShow =
@@ -367,7 +426,7 @@ const Footer = (props: FooterProps): JSX.Element => {
367426
<Button
368427
variant='outline'
369428
size='sm'
370-
isDisabled={isFirstStep}
429+
isDisabled={isForceDisabled || isFirstStep}
371430
onClick={() => void goPrev()}
372431
>
373432
<Icon
@@ -381,7 +440,7 @@ const Footer = (props: FooterProps): JSX.Element => {
381440
<Button
382441
variant='solid'
383442
size='sm'
384-
isDisabled={continueAction?.isDisabled || isLastStep}
443+
isDisabled={isForceDisabled || continueAction?.isDisabled || isLastStep}
385444
isLoading={continueAction?.isLoading}
386445
onClick={handleContinue}
387446
>

packages/ui/src/elements/Wizard/WizardContext.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ interface WizardProviderProps<TData> {
1818
currentStep: WizardStep<TData> | undefined;
1919
innerSteps: WizardInnerStep<TData>[];
2020
currentInnerStep: WizardInnerStep<TData> | undefined;
21+
isLoading: boolean;
2122
goNext: WizardContextValue<TData>['goNext'];
2223
goPrev: WizardContextValue<TData>['goPrev'];
2324
goToStep: WizardContextValue<TData>['goToStep'];
2425
children: React.ReactNode;
2526
}
2627

2728
export function WizardProvider<TData>(props: WizardProviderProps<TData>): JSX.Element {
28-
const { activeSteps, currentStep, innerSteps, currentInnerStep, goNext, goPrev, goToStep, children } = props;
29+
const { activeSteps, currentStep, innerSteps, currentInnerStep, isLoading, goNext, goPrev, goToStep, children } =
30+
props;
2931

3032
const [continueAction, setContinueAction] = React.useState<ContinueAction | undefined>(undefined);
3133

@@ -55,13 +57,14 @@ export function WizardProvider<TData>(props: WizardProviderProps<TData>): JSX.El
5557
totalInnerSteps,
5658
isFirstStep,
5759
isLastStep,
60+
isLoading,
5861
goNext,
5962
goPrev,
6063
goToStep,
6164
continueAction,
6265
setContinueAction,
6366
};
64-
}, [activeSteps, currentStep, innerSteps, currentInnerStep, goNext, goPrev, goToStep, continueAction]);
67+
}, [activeSteps, currentStep, innerSteps, currentInnerStep, isLoading, goNext, goPrev, goToStep, continueAction]);
6568

6669
return <WizardContext.Provider value={value}>{children}</WizardContext.Provider>;
6770
}

packages/ui/src/elements/Wizard/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ export interface WizardContextValue<TData = unknown> {
152152
* (last main step + last inner step, if any)
153153
*/
154154
isLastStep: boolean;
155+
/**
156+
* `true` while the parent flow is still loading async dependencies.
157+
* The header renders a skeleton breadcrumb, the content renders a
158+
* centered spinner, and the footer's buttons are disabled
159+
*/
160+
isLoading: boolean;
155161
/**
156162
* Navigate forward. Within a container step, advances through inner
157163
* steps first, otherwise (or on the last inner step) advances to

0 commit comments

Comments
 (0)