feat: added recursive validation for the repository url picker component
Co-authored-by: Johan Haals <johan.haals@gmail.com> Co-authored-by: Fredrik Adelöw <freben@users.noreply.github.com> Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com> Signed-off-by: Johan Haals <johan.haals@gmail.com>
This commit is contained in:
@@ -71,34 +71,37 @@ export const MultistepJsonForm = ({
|
||||
return (
|
||||
<>
|
||||
<Stepper activeStep={activeStep} orientation="vertical">
|
||||
{steps.map(({ title, schema, ...formProps }) => (
|
||||
<StepUI key={title}>
|
||||
<StepLabel>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
</StepLabel>
|
||||
<StepContent key={title}>
|
||||
<Form
|
||||
noHtml5Validate
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
fields={fields}
|
||||
widgets={widgets}
|
||||
onSubmit={e => {
|
||||
if (e.errors.length === 0) handleNext();
|
||||
}}
|
||||
{...formProps}
|
||||
{...transformSchemaToProps(schema)}
|
||||
>
|
||||
<Button disabled={activeStep === 0} onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="contained" color="primary" type="submit">
|
||||
Next step
|
||||
</Button>
|
||||
</Form>
|
||||
</StepContent>
|
||||
</StepUI>
|
||||
))}
|
||||
{steps.map(({ title, schema, ...formProps }) => {
|
||||
return (
|
||||
<StepUI key={title}>
|
||||
<StepLabel>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
</StepLabel>
|
||||
<StepContent key={title}>
|
||||
<Form
|
||||
showErrorList={false}
|
||||
fields={fields}
|
||||
widgets={widgets}
|
||||
noHtml5Validate
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onSubmit={e => {
|
||||
if (e.errors.length === 0) handleNext();
|
||||
}}
|
||||
{...formProps}
|
||||
{...transformSchemaToProps(schema)}
|
||||
>
|
||||
<Button disabled={activeStep === 0} onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="contained" color="primary" type="submit">
|
||||
Next step
|
||||
</Button>
|
||||
</Form>
|
||||
</StepContent>
|
||||
</StepUI>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
{activeStep === steps.length && (
|
||||
<Content>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { ScaffolderApi, scaffolderApiRef } from '../../api';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { TemplatePage } from './TemplatePage';
|
||||
import { TemplatePage, createValidator } from './TemplatePage';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
return {
|
||||
@@ -122,3 +122,38 @@ describe('TemplatePage', () => {
|
||||
expect(rendered.queryByText('This is root')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createValidator', () => {
|
||||
it('should validate deep schema', () => {
|
||||
const validator = createValidator({
|
||||
type: 'object',
|
||||
properties: {
|
||||
foo: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bar: {
|
||||
type: 'string',
|
||||
'ui:field': 'RepoUrlPicker',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const errors = { foo: { bar: { addError: jest.fn() } } };
|
||||
validator({ foo: { bar: 'github.com?owner=a' } }, errors as any);
|
||||
expect(errors.foo.bar.addError).toHaveBeenCalledWith(
|
||||
'Incomplete repository location provided',
|
||||
);
|
||||
jest.resetAllMocks();
|
||||
|
||||
validator({ foo: { bar: 'github.com?repo=b' } }, errors as any);
|
||||
expect(errors.foo.bar.addError).toHaveBeenCalledWith(
|
||||
'Incomplete repository location provided',
|
||||
);
|
||||
jest.resetAllMocks();
|
||||
|
||||
validator({ foo: { bar: 'github.com?owner=a&repo=b' } }, errors as any);
|
||||
expect(errors.foo.bar.addError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import { scaffolderApiRef } from '../../api';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { MultistepJsonForm } from '../MultistepJsonForm';
|
||||
import { RepoUrlPicker } from '../fields';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
|
||||
const useTemplateParameterSchema = (templateName: string) => {
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
@@ -49,12 +50,67 @@ const useTemplateParameterSchema = (templateName: string) => {
|
||||
return { schema: value, loading, error };
|
||||
};
|
||||
|
||||
function isObject(obj: unknown): obj is JsonObject {
|
||||
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
|
||||
}
|
||||
|
||||
export const createValidator = (rootSchema: JsonObject) => {
|
||||
function validate(
|
||||
schema: JsonObject,
|
||||
formData: JsonObject,
|
||||
errors: FormValidation,
|
||||
) {
|
||||
const schemaProps = schema.properties;
|
||||
if (!isObject(schemaProps)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, propData] of Object.entries(formData)) {
|
||||
const propErrors = errors[key];
|
||||
|
||||
if (isObject(propData)) {
|
||||
const propSchemaProps = schemaProps[key];
|
||||
if (isObject(propSchemaProps)) {
|
||||
validate(
|
||||
propSchemaProps,
|
||||
propData as JsonObject,
|
||||
propErrors as FormValidation,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const propSchema = schemaProps[key];
|
||||
if (
|
||||
isObject(propSchema) &&
|
||||
propSchema['ui:field'] === 'RepoUrlPicker'
|
||||
) {
|
||||
try {
|
||||
const { host, searchParams } = new URL(`https://${propData}`);
|
||||
if (
|
||||
!host ||
|
||||
!searchParams.get('owner') ||
|
||||
!searchParams.get('repo')
|
||||
) {
|
||||
propErrors.addError('Incomplete repository location provided');
|
||||
}
|
||||
} catch {
|
||||
propErrors.addError('Unable to parse the Repository URL');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (formData: JsonObject, errors: FormValidation) => {
|
||||
validate(rootSchema, formData, errors);
|
||||
return errors;
|
||||
};
|
||||
};
|
||||
|
||||
const storePathValidator = (
|
||||
formData: { storePath?: string },
|
||||
errors: FormValidation,
|
||||
) => {
|
||||
const { storePath } = formData;
|
||||
|
||||
if (!storePath) {
|
||||
errors.storePath.addError('Store path is required and not present');
|
||||
return errors;
|
||||
@@ -139,15 +195,19 @@ export const TemplatePage = () => {
|
||||
onReset={handleFormReset}
|
||||
onFinish={handleCreate}
|
||||
steps={schema.steps.map(step => {
|
||||
// TODO: Using this workaround to keep storePath validation, but we should replace
|
||||
// it with a custom store path selection widget
|
||||
// TODO: Can delete this function when the migration from v1 to v2 beta is completed
|
||||
// And just have the default validator for all fields.
|
||||
if ((step.schema as any)?.properties?.storePath) {
|
||||
return {
|
||||
...step,
|
||||
validate: (a, b) => storePathValidator(a, b),
|
||||
};
|
||||
}
|
||||
return step;
|
||||
|
||||
return {
|
||||
...step,
|
||||
validate: createValidator(step.schema),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</InfoCard>
|
||||
|
||||
@@ -14,16 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Field, Widget } from '@rjsf/core';
|
||||
import { Field } from '@rjsf/core';
|
||||
import { useApi, Progress } from '@backstage/core';
|
||||
import { scaffolderApiRef } from '../../../api';
|
||||
import { useAsync } from 'react-use';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
|
||||
import Select from '@material-ui/core/Select';
|
||||
import InputLabel from '@material-ui/core/InputLabel';
|
||||
import Input from '@material-ui/core/Input';
|
||||
import FormControl from '@material-ui/core/FormControl';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { rest } from 'msw/lib/types';
|
||||
|
||||
export const RepoUrlPicker: Field = ({ onChange, uiSchema }) => {
|
||||
export const RepoUrlPicker: Field = ({ onChange, uiSchema, ...rest }) => {
|
||||
const api = useApi(scaffolderApiRef);
|
||||
const allowedHosts = uiSchema['ui:options']?.allowedHosts as string[];
|
||||
|
||||
@@ -31,14 +33,16 @@ export const RepoUrlPicker: Field = ({ onChange, uiSchema }) => {
|
||||
return await api.getIntegrationsList({ allowedHosts });
|
||||
});
|
||||
|
||||
const [hostname, setHostname] = useState('');
|
||||
console.log(rest);
|
||||
|
||||
const [host, setHost] = useState('');
|
||||
const [owner, setOwner] = useState('');
|
||||
const [repo, setRepo] = useState('');
|
||||
|
||||
const updateHostname = useCallback(
|
||||
const updateHost = useCallback(
|
||||
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) =>
|
||||
setHostname(evt.target.value as string),
|
||||
[setHostname],
|
||||
setHost(evt.target.value as string),
|
||||
[setHost],
|
||||
);
|
||||
|
||||
const updateOwner = useCallback(
|
||||
@@ -54,18 +58,18 @@ export const RepoUrlPicker: Field = ({ onChange, uiSchema }) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hostname === '' && integrations?.length) {
|
||||
setHostname(integrations[0].host);
|
||||
if (host === '' && integrations?.length) {
|
||||
setHost(integrations[0].host);
|
||||
}
|
||||
}, [integrations, hostname]);
|
||||
}, [integrations, host]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('owner', owner);
|
||||
params.set('repo', repo);
|
||||
|
||||
onChange(`${hostname}?${params.toString()}`);
|
||||
}, [hostname, owner, repo, onChange]);
|
||||
onChange(`${encodeURIComponent(host)}?${params.toString()}`);
|
||||
}, [host, owner, repo, onChange]);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
@@ -73,23 +77,27 @@ export const RepoUrlPicker: Field = ({ onChange, uiSchema }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography>Repository Location</Typography>
|
||||
<TextField
|
||||
select
|
||||
label={hostname ? '' : 'Hostname'}
|
||||
value={hostname}
|
||||
onChange={updateHostname}
|
||||
>
|
||||
{integrations!
|
||||
.filter(i => allowedHosts?.includes(i.host))
|
||||
.map(({ host, title }) => (
|
||||
<MenuItem key={host} value={host}>
|
||||
{title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField label="Owner" onBlur={updateOwner} />
|
||||
<TextField label="Repository name" onBlur={updateRepo} />
|
||||
<Typography variant="body1">Repository Location</Typography>
|
||||
<FormControl margin="normal" required>
|
||||
<InputLabel htmlFor="hostInput">Host</InputLabel>
|
||||
<Select native id="hostInput" onChange={updateHost}>
|
||||
{integrations!
|
||||
.filter(i => allowedHosts?.includes(i.host))
|
||||
.map(({ host, title }) => (
|
||||
<option key={host} value={host}>
|
||||
{title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl margin="normal" required>
|
||||
<InputLabel htmlFor="ownerInput">Owner</InputLabel>
|
||||
<Input id="ownerInput" onBlur={updateOwner} />
|
||||
</FormControl>
|
||||
<FormControl margin="normal" required>
|
||||
<InputLabel htmlFor="repoInput">Repository</InputLabel>
|
||||
<Input id="repoInput" onBlur={updateRepo} />
|
||||
</FormControl>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user