From 1157fa30757446879cb2b4c2b7fab0bb6d9f8913 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Wed, 19 May 2021 17:33:04 +0200 Subject: [PATCH 1/3] Add a `` field to the scaffolder Signed-off-by: Oliver Sand --- .changeset/red-ducks-yawn.md | 5 + .../v1beta2-demo/template.yaml | 10 ++ .../v1beta2-demo/template/catalog-info.yaml | 3 + .../components/TemplatePage/TemplatePage.tsx | 6 +- .../fields/EntityPicker/EntityPicker.test.tsx | 138 ++++++++++++++++++ .../fields/EntityPicker/EntityPicker.tsx | 81 ++++++++++ .../components/fields/EntityPicker/index.ts | 16 ++ .../scaffolder/src/components/fields/index.ts | 3 +- 8 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 .changeset/red-ducks-yawn.md create mode 100644 plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx create mode 100644 plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx create mode 100644 plugins/scaffolder/src/components/fields/EntityPicker/index.ts diff --git a/.changeset/red-ducks-yawn.md b/.changeset/red-ducks-yawn.md new file mode 100644 index 0000000000..81d18a6eab --- /dev/null +++ b/.changeset/red-ducks-yawn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Add a `` field to the scaffolder to pick arbitrary entity kinds, like systems. diff --git a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml index 1a01f7998c..b0693b31aa 100644 --- a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml @@ -29,6 +29,16 @@ spec: ui:options: allowedKinds: - Group + system: + title: System + type: string + description: System of the component + ui:field: EntityPicker + ui:options: + allowedKinds: + - System + defaultKind: System + - title: Choose a location required: - repoUrl 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 6519d68402..16e651eaf7 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 @@ -7,3 +7,6 @@ spec: type: website lifecycle: experimental owner: {{cookiecutter.owner | jsonify}} +{%- if cookiecutter.backstage_system != "" %} + system: {{ cookiecutter.system | jsonify }} +{%- endif %} diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 6f212d9d75..fec1390171 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { JsonObject, JsonValue } from '@backstage/config'; import { Content, errorApiRef, @@ -27,14 +28,13 @@ import { LinearProgress } from '@material-ui/core'; import { FieldValidation, FormValidation, IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; import React, { useCallback, useState } from 'react'; -import { generatePath, useNavigate, Navigate } from 'react-router'; +import { generatePath, Navigate, useNavigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; +import { FieldExtensionOptions } from '../../extensions'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; -import { JsonObject, JsonValue } from '@backstage/config'; -import { FieldExtensionOptions } from '../../extensions'; const useTemplateParameterSchema = (templateName: string) => { const scaffolderApi = useApi(scaffolderApiRef); diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx new file mode 100644 index 0000000000..4c6a45d94c --- /dev/null +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx @@ -0,0 +1,138 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { FieldProps } from '@rjsf/core'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { EntityPicker } from './EntityPicker'; + +const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ + 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[]; defaultKind?: 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 all entities', async () => { + await renderInTestApp( + + + , + ); + + expect(catalogApi.getEntities).toHaveBeenCalledWith(undefined); + }); + + it('updates even if there is not an exact match', async () => { + const { getByLabelText } = await renderInTestApp( + + + , + ); + const input = getByLabelText('Entity'); + + 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/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx new file mode 100644 index 0000000000..db9f326413 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -0,0 +1,81 @@ +/* + * 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 { useApi } from '@backstage/core'; +import { + catalogApiRef, + formatEntityRefTitle, +} from '@backstage/plugin-catalog-react'; +import { TextField } from '@material-ui/core'; +import FormControl from '@material-ui/core/FormControl'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import { Field } from '@rjsf/core'; +import React from 'react'; +import { useAsync } from 'react-use'; + +export const EntityPicker: Field = ({ + onChange, + schema: { title = 'Entity', description = 'An entity from the catalog' }, + required, + uiSchema, + rawErrors, + formData, +}) => { + const allowedKinds = uiSchema['ui:options']?.allowedKinds as string[]; + const defaultKind = uiSchema['ui:options']?.defaultKind as string | undefined; + const catalogApi = useApi(catalogApiRef); + + const { value: entities, loading } = useAsync(() => + catalogApi.getEntities( + allowedKinds ? { filter: { kind: allowedKinds } } : undefined, + ), + ); + + const entityRefs = entities?.items.map(e => + formatEntityRefTitle(e, { defaultKind }), + ); + + const onSelect = (_: any, value: string | null) => { + onChange(value || ''); + }; + + return ( + 0 && !formData} + > + ( + + )} + /> + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/index.ts b/plugins/scaffolder/src/components/fields/EntityPicker/index.ts new file mode 100644 index 0000000000..d307940e2f --- /dev/null +++ b/plugins/scaffolder/src/components/fields/EntityPicker/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 './EntityPicker'; diff --git a/plugins/scaffolder/src/components/fields/index.ts b/plugins/scaffolder/src/components/fields/index.ts index d40d6a711c..14319864eb 100644 --- a/plugins/scaffolder/src/components/fields/index.ts +++ b/plugins/scaffolder/src/components/fields/index.ts @@ -13,5 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './RepoUrlPicker'; +export * from './EntityPicker'; export * from './OwnerPicker'; +export * from './RepoUrlPicker'; From d9f7774ec4ae6ff559be1d4870da1abb58047b3c Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Wed, 19 May 2021 17:41:01 +0200 Subject: [PATCH 2/3] Fix warning about missing key in looped component Signed-off-by: Oliver Sand --- .../scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index bc1bcefe1e..df537600c4 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -176,6 +176,7 @@ export const ScaffolderPageContents = () => { matchingEntities?.length > 0 && matchingEntities.map(template => ( From 221ddf1d90348dccacbb12e011aa2796fdc896c9 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Wed, 19 May 2021 17:47:09 +0200 Subject: [PATCH 3/3] Reuse the EntityPicker inside the OwnerPicker Signed-off-by: Oliver Sand --- .../ScaffolderPage/ScaffolderPage.tsx | 4 +- .../fields/EntityPicker/EntityPicker.tsx | 6 +- .../components/fields/EntityPicker/index.ts | 2 +- .../fields/OwnerPicker/OwnerPicker.test.tsx | 20 +---- .../fields/OwnerPicker/OwnerPicker.tsx | 84 ++++--------------- .../components/fields/OwnerPicker/index.ts | 2 +- plugins/scaffolder/src/extensions/default.ts | 7 +- plugins/scaffolder/src/index.ts | 13 +-- plugins/scaffolder/src/plugin.ts | 20 +++-- 9 files changed, 53 insertions(+), 105 deletions(-) diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index df537600c4..10468b07e0 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -35,12 +35,12 @@ import StarIcon from '@material-ui/icons/Star'; import React, { useEffect, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; +import { registerComponentRouteRef } from '../../routes'; import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; import { ScaffolderFilter } from '../ScaffolderFilter'; import { ButtonGroup } from '../ScaffolderFilter/ScaffolderFilter'; import SearchToolbar from '../SearchToolbar/SearchToolbar'; import { TemplateCard } from '../TemplateCard'; -import { registerComponentRouteRef } from '../../routes'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -174,7 +174,7 @@ export const ScaffolderPageContents = () => { {matchingEntities && matchingEntities?.length > 0 && - matchingEntities.map(template => ( + matchingEntities.map((template, i) => ( { +}: FieldProps) => { const allowedKinds = uiSchema['ui:options']?.allowedKinds as string[]; const defaultKind = uiSchema['ui:options']?.defaultKind as string | undefined; const catalogApi = useApi(catalogApiRef); diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/index.ts b/plugins/scaffolder/src/components/fields/EntityPicker/index.ts index d307940e2f..4f7d543afb 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/EntityPicker/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './EntityPicker'; +export { EntityPicker } from './EntityPicker'; diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx index 5d98fcdd9f..d618b66504 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx @@ -14,14 +14,12 @@ * limitations under the License. */ -import React from 'react'; +import { Entity } from '@backstage/catalog-model'; 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 React from 'react'; import { OwnerPicker } from './OwnerPicker'; const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ @@ -93,20 +91,6 @@ describe('', () => { }, }); }); - - 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', () => { diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx index 39183a856f..bbcb141b05 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx @@ -13,81 +13,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; import { FieldProps } 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}`; -}; +import React from 'react'; +import { EntityPicker } from '../EntityPicker'; export const OwnerPicker = ({ - onChange, schema: { title = 'Owner', description = 'The owner of the component' }, - required, uiSchema, - rawErrors, - formData, + ...props }: FieldProps) => { - 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 || ''); + const ownerUiSchema = { + ...uiSchema, + 'ui:options': { + allowedKinds: (uiSchema['ui:options']?.allowedKinds || [ + 'Group', + 'User', + ]) as string[], + defaultKind: 'Group', + }, }; return ( - 0 && !formData} - > - ( - - )} - /> - + ); }; diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts b/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts index 5202e5cd64..d8e793b964 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './OwnerPicker'; +export { OwnerPicker } from './OwnerPicker'; diff --git a/plugins/scaffolder/src/extensions/default.ts b/plugins/scaffolder/src/extensions/default.ts index 2da0dc57ba..da8160e4df 100644 --- a/plugins/scaffolder/src/extensions/default.ts +++ b/plugins/scaffolder/src/extensions/default.ts @@ -13,14 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { EntityPicker } from '../components/fields/EntityPicker'; import { OwnerPicker } from '../components/fields/OwnerPicker'; import { - RepoUrlPicker, repoPickerValidation, + RepoUrlPicker, } from '../components/fields/RepoUrlPicker'; import { FieldExtensionOptions } from './types'; export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS: FieldExtensionOptions[] = [ + { + component: EntityPicker, + name: 'EntityPicker', + }, { component: RepoUrlPicker, name: 'RepoUrlPicker', diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index c22597f373..2e0fd96f08 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -14,13 +14,14 @@ * limitations under the License. */ +export { scaffolderApiRef, ScaffolderClient } from './api'; +export type { ScaffolderApi } from './api'; +export { ScaffolderFieldExtensions } from './extensions'; export { - scaffolderPlugin, - scaffolderPlugin as plugin, - ScaffolderPage, + EntityPickerFieldExtension, OwnerPickerFieldExtension, RepoUrlPickerFieldExtension, + ScaffolderPage, + scaffolderPlugin as plugin, + scaffolderPlugin, } from './plugin'; -export { ScaffolderFieldExtensions } from './extensions'; -export type { ScaffolderApi } from './api'; -export { ScaffolderClient, scaffolderApiRef } from './api'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 5991ae4bc3..9e751e1bb6 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -21,15 +21,16 @@ import { discoveryApiRef, identityApiRef, } from '@backstage/core'; -import { OwnerPicker } from './components/fields/OwnerPicker'; -import { - RepoUrlPicker, - repoPickerValidation, -} from './components/fields/RepoUrlPicker'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef, ScaffolderClient } from './api'; +import { EntityPicker } from './components/fields/EntityPicker'; +import { OwnerPicker } from './components/fields/OwnerPicker'; +import { + repoPickerValidation, + RepoUrlPicker, +} from './components/fields/RepoUrlPicker'; import { createScaffolderFieldExtension } from './extensions'; -import { rootRouteRef, registerComponentRouteRef } from './routes'; +import { registerComponentRouteRef, rootRouteRef } from './routes'; export const scaffolderPlugin = createPlugin({ id: 'scaffolder', @@ -53,6 +54,13 @@ export const scaffolderPlugin = createPlugin({ }, }); +export const EntityPickerFieldExtension = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + component: EntityPicker, + name: 'EntityPicker', + }), +); + export const RepoUrlPickerFieldExtension = scaffolderPlugin.provide( createScaffolderFieldExtension({ component: RepoUrlPicker,