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:
blam
2021-02-24 11:23:25 +01:00
committed by Johan Haals
parent 59769bad27
commit b2ce7f18c0
4 changed files with 170 additions and 64 deletions
@@ -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>
</>
);
};