Skip to content

Commit f3de1ab

Browse files
committed
feature(crr): render Apply Actions step-by-step against the SSE stream with per-row retry
1 parent eb18919 commit f3de1ab

8 files changed

Lines changed: 424 additions & 11 deletions

File tree

src/react/locations/CRRSetupWizard/steps/ApplyActionsStep.tsx

Lines changed: 0 additions & 10 deletions
This file was deleted.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { rest } from 'msw';
4+
import { setupServer } from 'msw/node';
5+
import { Wrapper } from '../../../../utils/testUtil';
6+
import type { ConfigureFormValues } from '../ConfigureStep/schema';
7+
import { ApplyActionsStep } from './ApplyActionsStep';
8+
9+
const mockNext = jest.fn();
10+
const mockPrev = jest.fn();
11+
12+
jest.mock('@scality/core-ui/dist/components/steppers/Stepper.component', () => ({
13+
useStepper: () => ({ next: mockNext, prev: mockPrev }),
14+
}));
15+
16+
const STREAM_URL = '/crr-configurator/api/v1/replication-setups';
17+
const server = setupServer();
18+
19+
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
20+
afterEach(() => {
21+
server.resetHandlers();
22+
mockNext.mockReset();
23+
mockPrev.mockReset();
24+
});
25+
afterAll(() => server.close());
26+
27+
const ndjson = (...lines: unknown[]) => `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`;
28+
29+
const VALUES: ConfigureFormValues = {
30+
accountNameType: 'create',
31+
accountName: 'source-acc',
32+
connectionMode: 'management-network',
33+
url: 'https://10.0.0.42:8443',
34+
baseDomain: '',
35+
s3Endpoint: '',
36+
username: 'scality',
37+
password: 'super-secret',
38+
certificate: '-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----',
39+
destinationAccountName: 'dest-account',
40+
createReplicationRule: true,
41+
sourceBucketName: 'source-bucket',
42+
targetBucketName: 'target-bucket',
43+
prefix: '',
44+
};
45+
46+
const SUCCESS_RESULT = {
47+
endpoint: 'https://cluster.example:8443',
48+
stsEndpoint: 'https://cluster.example:8443/sts',
49+
accessKey: 'AKIA',
50+
secretKey: 'secret',
51+
roleArn: 'arn:aws:iam::123456789012:role/crr',
52+
targetBucket: 'target-bucket',
53+
};
54+
55+
describe('ApplyActionsStep', () => {
56+
it('renders every step as pending before any event lands', () => {
57+
server.use(
58+
rest.post(STREAM_URL, (_req, res, ctx) =>
59+
res(ctx.set('Content-Type', 'application/x-ndjson'), ctx.body(ndjson())),
60+
),
61+
);
62+
render(<ApplyActionsStep {...VALUES} />, { wrapper: Wrapper });
63+
64+
expect(screen.getByText(/Authenticate against the destination/i)).toBeInTheDocument();
65+
expect(screen.getByText(/Create the target bucket/i)).toBeInTheDocument();
66+
expect(screen.getAllByLabelText(/Pending|Running/i).length).toBeGreaterThan(0);
67+
});
68+
69+
it('advances to the next step with the result once the setup completes', async () => {
70+
server.use(
71+
rest.post(STREAM_URL, (_req, res, ctx) =>
72+
res(
73+
ctx.set('Content-Type', 'application/x-ndjson'),
74+
ctx.body(
75+
ndjson(
76+
{ event: 'step.started', step: 'authenticate', at: 't1' },
77+
{ event: 'step.completed', step: 'authenticate', at: 't2' },
78+
{ event: 'setup.completed', at: 't3', result: SUCCESS_RESULT },
79+
),
80+
),
81+
),
82+
),
83+
);
84+
render(<ApplyActionsStep {...VALUES} />, { wrapper: Wrapper });
85+
86+
await waitFor(() => expect(mockNext).toHaveBeenCalled());
87+
expect(mockNext.mock.calls[0][0]).toMatchObject({ result: SUCCESS_RESULT });
88+
});
89+
90+
it('surfaces a Retry button on a failed step and re-runs the mutation on click', async () => {
91+
let requestCount = 0;
92+
server.use(
93+
rest.post(STREAM_URL, (_req, res, ctx) => {
94+
requestCount += 1;
95+
return res(
96+
ctx.set('Content-Type', 'application/x-ndjson'),
97+
ctx.body(
98+
ndjson(
99+
{ event: 'step.started', step: 'authenticate', at: 't1' },
100+
{
101+
event: 'step.failed',
102+
step: 'authenticate',
103+
at: 't2',
104+
error: { code: 'DestinationAuthFailed', message: 'creds refused' },
105+
},
106+
{ event: 'setup.failed', at: 't3', error: { code: 'DestinationAuthFailed', message: 'creds refused' } },
107+
),
108+
),
109+
);
110+
}),
111+
);
112+
render(<ApplyActionsStep {...VALUES} />, { wrapper: Wrapper });
113+
114+
const retry = await screen.findByRole('button', { name: /Retry/i });
115+
await userEvent.click(retry);
116+
await waitFor(() => expect(requestCount).toBe(2));
117+
});
118+
119+
it('drops create-bucket from the list when the user did not opt into replication rule creation', () => {
120+
server.use(
121+
rest.post(STREAM_URL, (_req, res, ctx) =>
122+
res(ctx.set('Content-Type', 'application/x-ndjson'), ctx.body(ndjson())),
123+
),
124+
);
125+
render(
126+
<ApplyActionsStep {...VALUES} createReplicationRule={false} targetBucketName="" />,
127+
{ wrapper: Wrapper },
128+
);
129+
expect(screen.queryByText(/Create the target bucket/i)).not.toBeInTheDocument();
130+
});
131+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Stack, Text } from '@scality/core-ui';
2+
import { useStepper } from '@scality/core-ui/dist/components/steppers/Stepper.component';
3+
import { Box, Button } from '@scality/core-ui/dist/next';
4+
import { useEffect, useMemo, useRef } from 'react';
5+
import { useTheme } from 'styled-components';
6+
import type { StartSetupBody } from '../../api/types';
7+
import { useCRRConfigurationSetupMutation } from '../../hooks/useCRRConfigurationSetupMutation';
8+
import { type ConfigureFormValues, toStartSetupBody } from '../ConfigureStep/schema';
9+
import { StepRow } from './StepRow';
10+
import { deriveStepViews, stepListFor } from './steps';
11+
12+
export const APPLY_ACTIONS_STEP_INDEX = 1;
13+
14+
type Props = Partial<ConfigureFormValues>;
15+
16+
const isCompleteFormValues = (values: Props): values is ConfigureFormValues =>
17+
values.connectionMode !== undefined && values.certificate !== undefined && values.username !== undefined;
18+
19+
export const ApplyActionsStep = (props: Props) => {
20+
const theme = useTheme();
21+
const { next, prev } = useStepper(APPLY_ACTIONS_STEP_INDEX);
22+
const setup = useCRRConfigurationSetupMutation();
23+
24+
const body: StartSetupBody | null = useMemo(
25+
() => (isCompleteFormValues(props) ? toStartSetupBody(props) : null),
26+
[props],
27+
);
28+
const steps = useMemo(() => stepListFor(body?.targetBucket !== undefined), [body]);
29+
const stepViews = useMemo(() => deriveStepViews(steps, setup.events), [steps, setup.events]);
30+
31+
const hasStartedRef = useRef(false);
32+
useEffect(() => {
33+
if (body && !hasStartedRef.current) {
34+
hasStartedRef.current = true;
35+
setup.mutate(body);
36+
}
37+
}, [body, setup]);
38+
39+
useEffect(() => {
40+
if (setup.data) {
41+
next({ result: setup.data, ...props });
42+
}
43+
}, [setup.data, next, props]);
44+
45+
const failedStep = stepViews.find((view) => view.state === 'failed');
46+
const onRetry = () => {
47+
if (!body) return;
48+
setup.reset();
49+
hasStartedRef.current = true;
50+
setup.mutate(body);
51+
};
52+
53+
if (!body) {
54+
return (
55+
<Box padding="r24">
56+
<Text>Please complete the previous step before running the setup.</Text>
57+
</Box>
58+
);
59+
}
60+
61+
return (
62+
<Box padding={"r24"} flex="1">
63+
<Stack direction="vertical" gap={"r16"}>
64+
<Stack direction="vertical" gap={"r4"}>
65+
<Text variant="Large">Applying destination setup</Text>
66+
<Text variant="Smaller" color="textSecondary">
67+
The configurator provisions the destination in the order below. Progress updates stream in real time.
68+
</Text>
69+
</Stack>
70+
71+
<Stack direction="vertical" gap={"r8"}>
72+
{stepViews.map((view) => (
73+
<StepRow key={view.id} step={view} onRetry={view.id === failedStep?.id ? onRetry : undefined} />
74+
))}
75+
</Stack>
76+
77+
<Stack direction="horizontal" gap={"r8"}>
78+
<Button variant="secondary" label="Back" onClick={() => prev(props)} disabled={setup.isLoading} />
79+
{setup.isLoading && (
80+
<Button variant="secondary" label="Cancel" onClick={setup.cancel} />
81+
)}
82+
</Stack>
83+
</Stack>
84+
</Box>
85+
);
86+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Icon, Loader, Stack, Text } from '@scality/core-ui';
2+
import { Box, Button } from '@scality/core-ui/dist/next';
3+
import { useTheme } from 'styled-components';
4+
import type { StepView } from './steps';
5+
6+
type StatusIndicatorProps = { state: StepView['state'] };
7+
8+
const StatusIndicator = ({ state }: StatusIndicatorProps) => {
9+
if (state === 'running') {
10+
return <Loader size="base" aria-label="Running" />;
11+
}
12+
if (state === 'succeeded') {
13+
return <Icon name="Check-circle" color="statusHealthy" ariaLabel="Succeeded" />;
14+
}
15+
if (state === 'failed') {
16+
return <Icon name="Times-circle" color="statusCritical" ariaLabel="Failed" />;
17+
}
18+
return <Icon name="Circle-empty" color="textSecondary" ariaLabel="Pending" />;
19+
};
20+
21+
type Props = {
22+
step: StepView;
23+
onRetry?: () => void;
24+
};
25+
26+
export const StepRow = ({ step, onRetry }: Props) => {
27+
const theme = useTheme();
28+
return (
29+
<Box
30+
padding="r12"
31+
style={{
32+
border: `1px solid ${theme.backgroundLevel3}`,
33+
borderRadius: '4px',
34+
backgroundColor: theme.backgroundLevel1,
35+
}}
36+
>
37+
<Stack direction="horizontal" gap="r16" style={{ alignItems: 'center' }}>
38+
<StatusIndicator state={step.state} />
39+
<Stack direction="vertical" gap="r4" style={{ flex: 1, minWidth: 0 }}>
40+
<Text variant="Larger">{step.label}</Text>
41+
{step.state === 'failed' && step.errorMessage && (
42+
<Text variant="Smaller" color="statusCritical">
43+
{step.errorMessage}
44+
</Text>
45+
)}
46+
</Stack>
47+
{step.state === 'failed' && onRetry && (
48+
<Button variant="secondary" label="Retry" onClick={onRetry} icon={<Icon name="Arrow-right" />} />
49+
)}
50+
</Stack>
51+
</Box>
52+
);
53+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { APPLY_ACTIONS_STEP_INDEX, ApplyActionsStep } from './ApplyActionsStep';
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { SetupEvent } from '../../api/types';
2+
import { deriveStepViews, stepListFor } from './steps';
3+
4+
describe('stepListFor', () => {
5+
it('drops create-bucket when the wizard did not opt in', () => {
6+
expect(stepListFor(false)).not.toContain('create-bucket');
7+
});
8+
9+
it('appends create-bucket at the end when the wizard opted in', () => {
10+
const list = stepListFor(true);
11+
expect(list[list.length - 1]).toBe('create-bucket');
12+
});
13+
});
14+
15+
describe('deriveStepViews', () => {
16+
const steps = stepListFor(true);
17+
18+
it('returns every step as pending when no event has landed yet', () => {
19+
const views = deriveStepViews(steps, []);
20+
expect(views.every((view) => view.state === 'pending')).toBe(true);
21+
});
22+
23+
it('marks a step as running once step.started is received', () => {
24+
const events: SetupEvent[] = [{ event: 'step.started', step: 'authenticate', at: 't' }];
25+
const [first, ...rest] = deriveStepViews(steps, events);
26+
expect(first.state).toBe('running');
27+
expect(rest.every((view) => view.state === 'pending')).toBe(true);
28+
});
29+
30+
it('marks a step as succeeded once step.completed follows step.started', () => {
31+
const events: SetupEvent[] = [
32+
{ event: 'step.started', step: 'authenticate', at: 't1' },
33+
{ event: 'step.completed', step: 'authenticate', at: 't2' },
34+
];
35+
const [first] = deriveStepViews(steps, events);
36+
expect(first.state).toBe('succeeded');
37+
});
38+
39+
it('marks a step as failed and surfaces the error message on step.failed', () => {
40+
const events: SetupEvent[] = [
41+
{ event: 'step.started', step: 'create-user', at: 't1' },
42+
{
43+
event: 'step.failed',
44+
step: 'create-user',
45+
at: 't2',
46+
error: { code: 'InternalError', message: 'IAM refused CreateUser' },
47+
},
48+
];
49+
const view = deriveStepViews(steps, events).find((v) => v.id === 'create-user');
50+
expect(view?.state).toBe('failed');
51+
expect(view?.errorMessage).toBe('IAM refused CreateUser');
52+
});
53+
54+
it('leaves subsequent steps as pending after an earlier step fails', () => {
55+
const events: SetupEvent[] = [
56+
{ event: 'step.started', step: 'authenticate', at: 't1' },
57+
{
58+
event: 'step.failed',
59+
step: 'authenticate',
60+
at: 't2',
61+
error: { code: 'DestinationAuthFailed', message: 'creds refused' },
62+
},
63+
];
64+
const views = deriveStepViews(steps, events);
65+
expect(views[0].state).toBe('failed');
66+
for (const view of views.slice(1)) {
67+
expect(view.state).toBe('pending');
68+
}
69+
});
70+
71+
it('ignores create-bucket events when the step is not in the requested list', () => {
72+
const withoutBucket = stepListFor(false);
73+
const events: SetupEvent[] = [{ event: 'step.started', step: 'create-bucket', at: 't' }];
74+
const views = deriveStepViews(withoutBucket, events);
75+
expect(views.find((v) => v.id === ('create-bucket' as never))).toBeUndefined();
76+
expect(views.every((v) => v.state === 'pending')).toBe(true);
77+
});
78+
});

0 commit comments

Comments
 (0)