Skip to content

Commit 4bfeebb

Browse files
committed
feature(crr): add wizard shell with Configure destination step
1 parent 2d37330 commit 4bfeebb

14 files changed

Lines changed: 824 additions & 8 deletions

src/react/Routes.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import DataBrowser from './databrowser/DataBrowser';
1717
import EndpointCreate from './endpoint/EndpointCreate';
1818
import Endpoints from './endpoint/Endpoints';
1919
import { ISVSteps } from './ISV/components/ISVSteps';
20+
import { CRRSetupWizard } from './locations/CRRSetupWizard/CRRSetupWizard';
21+
import { useCRRFeature } from './locations/CRRSetupWizard/hooks/useCRRFeature';
2022
import LocationEditor from './locations/LocationEditor';
2123
import { Locations } from './locations/Locations';
2224
import ManagementProvider from './ManagementProvider';
@@ -69,14 +71,14 @@ const RedirectToAccount = () => {
6971
}
7072
};
7173

72-
7374
export function PrivateRoutes({ hideSideBar = false }: { hideSideBar?: boolean }): JSX.Element {
7475
const { isClientsLoaded } = useAuthLoading();
7576
const config = useConfig();
7677

7778
const { isPlatformAdmin } = useAuthGroups();
7879
const metalK8sInstances = useDeployedMetalk8sInstances();
7980
const isMetalK8sEnabled = metalK8sInstances.length > 0;
81+
const isCRRWizardEnabled = useCRRFeature();
8082
if (!isClientsLoaded) {
8183
return (
8284
<Loader centered>
@@ -175,6 +177,16 @@ export function PrivateRoutes({ hideSideBar = false }: { hideSideBar?: boolean }
175177
</DataServiceRoleProvider>
176178
}
177179
/>
180+
{isCRRWizardEnabled && (
181+
<Route
182+
path="create-crr-configuration/*"
183+
element={
184+
<DataServiceRoleProvider>
185+
<CRRSetupWizard />
186+
</DataServiceRoleProvider>
187+
}
188+
/>
189+
)}
178190
<Route
179191
path={`accounts/:accountName/policies/:policyArn/attachments/*`}
180192
element={
@@ -293,6 +305,7 @@ function InternalRoutes(): JSX.Element {
293305
'/accounts/:accountName/users/:user/update-user',
294306
'/accounts/:accountName/create-policy',
295307
'/isv/configuration',
308+
'/create-crr-configuration',
296309
'/truststore/import-certificate',
297310
'/accounts/:accountName/buckets/-/create',
298311
'/accounts/:accountName/buckets/:bucketName/lifecycle/create',
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { render, screen } 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 { CRRSetupWizard } from './CRRSetupWizard';
7+
8+
const VERIFY_URL = '/crr-configurator/api/v1/verify';
9+
const server = setupServer();
10+
11+
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
12+
afterEach(() => server.resetHandlers());
13+
afterAll(() => server.close());
14+
15+
const PEM = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----';
16+
17+
/**
18+
* Fills the whole Configure form with valid values from a user's point of view —
19+
* clicks the labelled inputs by their accessible name, disambiguating the two
20+
* "Account Name" fields by the surrounding section title.
21+
*/
22+
const fillValidForm = async () => {
23+
const accountNameInputs = screen.getAllByRole('textbox', { name: /Account Name/i });
24+
const [sourceAccountName, destinationAccountName] = accountNameInputs;
25+
await userEvent.type(sourceAccountName, 'source-account');
26+
await userEvent.type(screen.getByRole('textbox', { name: /^URL/i }), 'https://10.0.0.42:8443');
27+
await userEvent.type(screen.getByRole('textbox', { name: /^Username/i }), 'scality');
28+
await userEvent.type(screen.getByLabelText(/^Password/i), 'super-secret');
29+
await userEvent.type(screen.getByRole('textbox', { name: /^Certificate/i }), PEM);
30+
await userEvent.type(destinationAccountName, 'dest-account');
31+
};
32+
33+
describe('CRRSetupWizard — Configure step', () => {
34+
it('confirms the destination is reachable when the user clicks Check Connection', async () => {
35+
server.use(
36+
rest.post(VERIFY_URL, (_req, res, ctx) =>
37+
res(ctx.json({ ok: true, mode: 'management-network', instanceName: 'ageless-valley' })),
38+
),
39+
);
40+
render(<CRRSetupWizard />, { wrapper: Wrapper });
41+
42+
await fillValidForm();
43+
await userEvent.click(screen.getByRole('button', { name: /Check Connection/i }));
44+
45+
expect(await screen.findByText(/Destination reachable/i)).toBeInTheDocument();
46+
});
47+
48+
it('surfaces the ARTESCA problem code when Check Connection is rejected', async () => {
49+
server.use(
50+
rest.post(VERIFY_URL, (_req, res, ctx) =>
51+
res(
52+
ctx.status(400),
53+
ctx.set('Content-Type', 'application/problem+json'),
54+
ctx.body(
55+
JSON.stringify({
56+
type: 'about:blank',
57+
title: 'Invalid destination certificate',
58+
status: 400,
59+
code: 'DestinationCertificateInvalid',
60+
}),
61+
),
62+
),
63+
),
64+
);
65+
render(<CRRSetupWizard />, { wrapper: Wrapper });
66+
67+
await fillValidForm();
68+
await userEvent.click(screen.getByRole('button', { name: /Check Connection/i }));
69+
70+
expect(await screen.findByText(/pasted certificate is not a valid PEM/i)).toBeInTheDocument();
71+
});
72+
73+
it('blocks the user on Configure with an error toast when the silent verify fails on Continue', async () => {
74+
server.use(
75+
rest.post(VERIFY_URL, (_req, res, ctx) =>
76+
res(
77+
ctx.status(502),
78+
ctx.set('Content-Type', 'application/problem+json'),
79+
ctx.body(
80+
JSON.stringify({
81+
type: 'about:blank',
82+
title: 'Destination unreachable',
83+
status: 502,
84+
code: 'DestinationUnreachable',
85+
}),
86+
),
87+
),
88+
),
89+
);
90+
render(<CRRSetupWizard />, { wrapper: Wrapper });
91+
92+
await fillValidForm();
93+
await userEvent.click(screen.getByRole('button', { name: /Continue/i }));
94+
95+
expect(await screen.findByText(/destination cluster did not respond/i)).toBeInTheDocument();
96+
});
97+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Stepper, spacing } from '@scality/core-ui';
2+
import { Box } from '@scality/core-ui/dist/next';
3+
import { useTheme } from 'styled-components';
4+
import { ApplyActionsStep } from './steps/ApplyActionsStep';
5+
import { ConfigureStep } from './steps/ConfigureStep';
6+
import { SummaryStep } from './steps/SummaryStep';
7+
8+
const STEPS = [
9+
{ label: 'Configure', Component: ConfigureStep },
10+
{ label: 'Apply Actions', Component: ApplyActionsStep },
11+
{ label: 'Summary', Component: SummaryStep },
12+
] as const;
13+
14+
export const CRRSetupWizard = () => {
15+
const theme = useTheme();
16+
return (
17+
<Box
18+
height="100%"
19+
backgroundColor={theme.backgroundLevel4}
20+
paddingTop={spacing.r16}
21+
style={{ boxSizing: 'border-box' }}
22+
>
23+
<Stepper steps={STEPS} />
24+
</Box>
25+
);
26+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Banner, Icon } from '@scality/core-ui';
2+
import { Box } from '@scality/core-ui/dist/next';
3+
4+
export const ApplyActionsStep = () => (
5+
<Box padding="r24">
6+
<Banner variant="base" icon={<Icon name="Info-circle" />} title="Apply Actions">
7+
This step runs the destination-side setup chain. It lands in the next brick.
8+
</Banner>
9+
</Box>
10+
);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { Form, Icon, Stack, useToast } from '@scality/core-ui';
2+
import { useStepper } from '@scality/core-ui/dist/components/steppers/Stepper.component';
3+
import { Button } from '@scality/core-ui/dist/next';
4+
import { useBasenameRelativeNavigate } from '@scality/module-federation';
5+
import { useRef } from 'react';
6+
import { FormProvider, useForm } from 'react-hook-form';
7+
import { ServiceError } from '../../api/crrConfiguratorClient';
8+
import type { ProblemCode, VerifyRequestBody } from '../../api/types';
9+
import { useCRRConfigurationVerifyMutation } from '../../hooks/useCRRConfigurationVerifyMutation';
10+
import { DestinationAccountSection } from './DestinationAccountSection';
11+
import { DestinationConnectionSection } from './DestinationConnectionSection';
12+
import { ReplicationSection } from './ReplicationSection';
13+
import { SourceSection } from './SourceSection';
14+
import { type ConfigureFormValues, configureResolver, defaultConfigureValues, toVerifyBody } from './schema';
15+
16+
/** Step index for the wizard's Stepper.next() calls. */
17+
export const CONFIGURE_STEP_INDEX = 0;
18+
19+
const errorCopy: Partial<Record<ProblemCode, string>> = {
20+
DestinationUnreachable: 'The destination cluster did not respond.',
21+
DestinationDnsResolutionFailed: 'One or more destination hostnames could not be resolved.',
22+
DestinationCertificateInvalid: 'The pasted certificate is not a valid PEM bundle.',
23+
DestinationAuthFailed: 'The destination refused these admin credentials.',
24+
AssumeRoleFailed: 'The destination rejected the storage-manager role assumption.',
25+
Unauthorized: 'Your session was rejected by the source cluster. Sign in again.',
26+
Forbidden: 'You need the Storage Manager role to run this wizard.',
27+
};
28+
29+
const errorMessage = (error: unknown): string => {
30+
if (error instanceof ServiceError) {
31+
const code = error.problem.code as ProblemCode | undefined;
32+
return (code && errorCopy[code]) ?? error.problem.title ?? 'Connection to the destination failed.';
33+
}
34+
return (error as Error)?.message ?? 'Connection to the destination failed.';
35+
};
36+
37+
export const ConfigureStep = () => {
38+
const { next } = useStepper(CONFIGURE_STEP_INDEX);
39+
const navigate = useBasenameRelativeNavigate();
40+
const { showToast } = useToast();
41+
const verify = useCRRConfigurationVerifyMutation();
42+
const lastVerifiedRef = useRef<string | null>(null);
43+
44+
const formMethods = useForm<ConfigureFormValues>({
45+
mode: 'all',
46+
resolver: configureResolver,
47+
defaultValues: defaultConfigureValues,
48+
});
49+
const {
50+
handleSubmit,
51+
getValues,
52+
trigger,
53+
formState: { isValid },
54+
} = formMethods;
55+
56+
const runVerify = async (body: VerifyRequestBody) => {
57+
await verify.mutateAsync(body);
58+
lastVerifiedRef.current = JSON.stringify(body);
59+
};
60+
61+
const onCheckConnection = async () => {
62+
const valid = await trigger();
63+
if (!valid) return;
64+
const body = toVerifyBody(getValues());
65+
try {
66+
await runVerify(body);
67+
showToast({ open: true, status: 'success', message: 'Destination reachable' });
68+
} catch (error) {
69+
showToast({ open: true, status: 'error', message: errorMessage(error) });
70+
}
71+
};
72+
73+
const onContinue = handleSubmit(async (values) => {
74+
const body = toVerifyBody(values);
75+
const snapshot = JSON.stringify(body);
76+
if (lastVerifiedRef.current === snapshot) {
77+
next({ ...values });
78+
return;
79+
}
80+
try {
81+
await runVerify(body);
82+
next({ ...values });
83+
} catch (error) {
84+
showToast({ open: true, status: 'error', message: errorMessage(error) });
85+
}
86+
});
87+
88+
return (
89+
<FormProvider {...formMethods}>
90+
<Form
91+
onSubmit={onContinue}
92+
requireMode="partial"
93+
layout={{ title: 'Configure Cross-Region Location', kind: 'page' }}
94+
rightActions={
95+
<Stack gap="r16">
96+
<Button type="button" variant="outline" label="Cancel" onClick={() => navigate('/locations')} />
97+
<Button
98+
type="submit"
99+
variant="primary"
100+
label="Continue"
101+
isLoading={verify.isLoading}
102+
disabled={!isValid}
103+
icon={<Icon name="Arrow-right" />}
104+
/>
105+
</Stack>
106+
}
107+
>
108+
<SourceSection />
109+
<DestinationConnectionSection isCheckingConnection={verify.isLoading} onCheckConnection={onCheckConnection} />
110+
<DestinationAccountSection />
111+
<ReplicationSection />
112+
</Form>
113+
</FormProvider>
114+
);
115+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { FormGroup, FormSection } from '@scality/core-ui';
2+
import { Input } from '@scality/core-ui/dist/next';
3+
import { useFormContext } from 'react-hook-form';
4+
import type { ConfigureFormValues } from './schema';
5+
6+
export const DestinationAccountSection = () => {
7+
const {
8+
register,
9+
formState: { errors, touchedFields },
10+
} = useFormContext<ConfigureFormValues>();
11+
const nameError = touchedFields.destinationAccountName ? errors.destinationAccountName?.message : undefined;
12+
13+
return (
14+
<FormSection forceLabelWidth={280} title={{ name: 'Destination Account' }}>
15+
<FormGroup
16+
id="destinationAccountName"
17+
direction="horizontal"
18+
label="Account Name"
19+
required
20+
helpErrorPosition="bottom"
21+
error={nameError}
22+
content={<Input id="destinationAccountName" autoComplete="off" {...register('destinationAccountName')} />}
23+
/>
24+
</FormSection>
25+
);
26+
};

0 commit comments

Comments
 (0)