Add OwnerPicker component to scaffolder

Signed-off-by: James Turley <jamesturley1905@googlemail.com>
This commit is contained in:
James Turley
2021-03-24 17:01:23 +00:00
committed by James Turley
parent ba479febd3
commit 2ab6f3ff07
9 changed files with 299 additions and 3 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-scaffolder-backend': patch
---
Add OwnerPicker component to scaffolder for specifying a component's owner from users and groups in the catalog.
@@ -38,6 +38,14 @@ spec:
ui:autofocus: true
ui:options:
rows: 5
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowedKinds:
- Group
- title: Choose a location
required:
- repoUrl
@@ -59,6 +67,7 @@ spec:
url: ./template
values:
name: '{{ parameters.name }}'
owner: '{{ parameters.owner }}'
- id: fetch-docs
name: Fetch Docs
@@ -234,6 +243,26 @@ The `RepoUrlPicker` is a custom field that we provide part of the
`plugin-scaffolder`. It's currently not possible to create your own fields yet,
but contributions are welcome! :)
#### The Owner Picker
When the scaffolder needs to add new components to the catalog, it needs to have
an owner for them. Ideally, users should be able to select an owner when they go
through the scaffolder form from the users and groups already known to
Backstage. The `OwnerPicker` is a custom field that generates a searchable list
of groups and/or users already in the catalog to pick an owner from. You can
specify which of the two kinds are listed in the `allowedKinds` option:
```yaml
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowedKinds:
- Group
```
### `spec.steps` - `Action[]`
The `steps` is an array of the things that you want to happen part of this
@@ -12,6 +12,7 @@ spec:
- title: Fill in some steps
required:
- name
- owner
properties:
name:
title: Name
@@ -20,6 +21,14 @@ spec:
ui:autofocus: true
ui:options:
rows: 5
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowedKinds:
- Group
- title: Choose a location
required:
- repoUrl
@@ -40,6 +49,7 @@ spec:
url: ./template
values:
name: '{{ parameters.name }}'
owner: '{{ parameters.owner }}'
- id: fetch-docs
name: Fetch Docs
@@ -5,4 +5,4 @@ metadata:
spec:
type: website
lifecycle: experimental
owner: guest
owner: {{cookiecutter.owner | jsonify}}
@@ -33,7 +33,7 @@ import { useAsync } from 'react-use';
import { scaffolderApiRef } from '../../api';
import { rootRouteRef } from '../../routes';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { RepoUrlPicker } from '../fields';
import { RepoUrlPicker, OwnerPicker } from '../fields';
import { JsonObject } from '@backstage/config';
const useTemplateParameterSchema = (templateName: string) => {
@@ -190,7 +190,7 @@ export const TemplatePage = () => {
<InfoCard title={schema.title} noPadding>
<MultistepJsonForm
formData={formState}
fields={{ RepoUrlPicker }}
fields={{ RepoUrlPicker, OwnerPicker }}
onChange={handleChange}
onReset={handleFormReset}
onFinish={handleCreate}
@@ -0,0 +1,141 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import userEvent from '@testing-library/user-event';
import { Entity } from '@backstage/catalog-model';
import { FieldProps } from '@rjsf/core';
import { OwnerPicker } from './OwnerPicker';
const makeEntity = (kind: string, namespace: string, name: string): Entity => ({
apiVersion: 'backstage.io/v1beta1',
kind,
metadata: { namespace, name },
});
describe('<OwnerPicker />', () => {
let entities: Entity[];
const onChange = jest.fn();
const schema = {};
const required = false;
let uiSchema: { 'ui:options': { allowedKinds?: string[] } };
const rawErrors: string[] = [];
const formData = undefined;
let props: FieldProps;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(async () => ({ items: entities })),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(catalogApiRef, catalogApi);
entities = [
makeEntity('Group', 'default', 'team-a'),
makeEntity('Group', 'default', 'squad-b'),
];
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
describe('without allowedKinds', () => {
beforeEach(() => {
uiSchema = { 'ui:options': {} };
props = ({
onChange,
schema,
required,
uiSchema,
rawErrors,
formData,
} as unknown) as FieldProps<any>;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('searches for users and groups', async () => {
await renderInTestApp(
<Wrapper>
<OwnerPicker {...props} />
</Wrapper>,
);
expect(catalogApi.getEntities).toHaveBeenCalledWith({
filter: {
kind: ['Group', 'User'],
},
});
});
it('updates even if there is not an exact match', async () => {
const { getByLabelText } = await renderInTestApp(
<Wrapper>
<OwnerPicker {...props} />
</Wrapper>,
);
const input = getByLabelText('Owner');
userEvent.type(input, 'squ');
input.blur();
expect(onChange).toHaveBeenCalledWith('squ');
});
});
describe('with allowedKinds', () => {
beforeEach(() => {
uiSchema = { 'ui:options': { allowedKinds: ['User'] } };
props = ({
onChange,
schema,
required,
uiSchema,
rawErrors,
formData,
} as unknown) as FieldProps<any>;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('searches for users and groups', async () => {
await renderInTestApp(
<Wrapper>
<OwnerPicker {...props} />
</Wrapper>,
);
expect(catalogApi.getEntities).toHaveBeenCalledWith({
filter: {
kind: ['User'],
},
});
});
});
});
@@ -0,0 +1,93 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Field } from '@rjsf/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { useApi } from '@backstage/core';
import { useAsync } from 'react-use';
import Autocomplete from '@material-ui/lab/Autocomplete';
import FormControl from '@material-ui/core/FormControl';
import { Entity } from '@backstage/catalog-model';
import { TextField } from '@material-ui/core';
const entityRef = (entity: Entity | undefined): string => {
if (!entity) {
return '';
}
const {
kind,
metadata: { namespace, name },
} = entity;
const namespacePart =
!namespace || namespace === 'default' ? '' : `${namespace}/`;
const kindPart = kind.toLowerCase() === 'group' ? '' : `${kind}:`;
return `${kindPart}${namespacePart}${name}`;
};
export const OwnerPicker: Field = ({
onChange,
schema: { title = 'Owner', description = 'The owner of the component' },
required,
uiSchema,
rawErrors,
formData,
}) => {
const allowedKinds = (uiSchema['ui:options']?.allowedKinds || [
'Group',
'User',
]) as string[];
const catalogApi = useApi(catalogApiRef);
const { value: owners, loading } = useAsync(() =>
catalogApi.getEntities({ filter: { kind: allowedKinds } }),
);
const ownerRefs = owners?.items.map(entityRef);
const onSelect = (_: any, value: string | null) => {
onChange(value || '');
};
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !formData}
>
<Autocomplete
value={(formData as string) || ''}
loading={loading}
onChange={onSelect}
options={ownerRefs || []}
autoSelect
freeSolo
renderInput={params => (
<TextField
{...params}
label={title}
margin="normal"
helperText={description}
variant="outlined"
required={required}
InputProps={params.InputProps}
/>
)}
/>
</FormControl>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './OwnerPicker';
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export * from './RepoUrlPicker';
export * from './OwnerPicker';