diff --git a/.changeset/healthy-panthers-own.md b/.changeset/healthy-panthers-own.md new file mode 100644 index 0000000000..3e6c28a1f9 --- /dev/null +++ b/.changeset/healthy-panthers-own.md @@ -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. diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 4b88e78db3..4f9323c6e9 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -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 diff --git a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml index 5b90e6c121..e90b43b7f3 100644 --- a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml @@ -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 diff --git a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml index dd1e0ebd09..875664d2a8 100644 --- a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml @@ -5,4 +5,4 @@ metadata: spec: type: website lifecycle: experimental - owner: guest + owner: {{cookiecutter.owner | jsonify}} diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 5d173173ee..15b6c9935c 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -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 = () => { ({ + apiVersion: 'backstage.io/v1beta1', + kind, + metadata: { namespace, name }, +}); + +describe('', () => { + 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 = { + 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 }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + describe('without allowedKinds', () => { + beforeEach(() => { + uiSchema = { 'ui:options': {} }; + props = ({ + onChange, + schema, + required, + uiSchema, + rawErrors, + formData, + } as unknown) as FieldProps; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + }); + + it('searches for users and groups', async () => { + await renderInTestApp( + + + , + ); + + expect(catalogApi.getEntities).toHaveBeenCalledWith({ + filter: { + kind: ['Group', 'User'], + }, + }); + }); + + it('updates even if there is not an exact match', async () => { + const { getByLabelText } = await renderInTestApp( + + + , + ); + 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; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + }); + + it('searches for users and groups', async () => { + await renderInTestApp( + + + , + ); + + expect(catalogApi.getEntities).toHaveBeenCalledWith({ + filter: { + kind: ['User'], + }, + }); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx new file mode 100644 index 0000000000..0091a6741e --- /dev/null +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx @@ -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 ( + 0 && !formData} + > + ( + + )} + /> + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts b/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts new file mode 100644 index 0000000000..5202e5cd64 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/components/fields/index.ts b/plugins/scaffolder/src/components/fields/index.ts index fc3e70378d..d40d6a711c 100644 --- a/plugins/scaffolder/src/components/fields/index.ts +++ b/plugins/scaffolder/src/components/fields/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './RepoUrlPicker'; +export * from './OwnerPicker';