diff --git a/.changeset/late-bags-cover.md b/.changeset/late-bags-cover.md new file mode 100644 index 0000000000..8fd5bd35ca --- /dev/null +++ b/.changeset/late-bags-cover.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Added `MyGroupsPicker` field extension that will display a dropdown of groups a user is part of. diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index e50395be05..48f24868de 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -207,6 +207,31 @@ export function makeFieldSchemaFromZod< : never >; +// @public +export const MyGroupsPickerFieldExtension: FieldExtensionComponent_2< + string, + { + title?: string | undefined; + description?: string | undefined; + } +>; + +// @public +export const MyGroupsPickerFieldSchema: FieldSchema< + string, + { + title?: string | undefined; + description?: string | undefined; + } +>; + +// @public +export const MyGroupsPickerSchema: CustomFieldExtensionSchema_2; + +// @public +export type MyGroupsPickerUiOptions = + typeof MyGroupsPickerFieldSchema.uiOptionsType; + // @public export const OwnedEntityPickerFieldExtension: FieldExtensionComponent_2< string, diff --git a/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx new file mode 100644 index 0000000000..0bdfe5f153 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx @@ -0,0 +1,277 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { render, waitFor } from '@testing-library/react'; +import { CatalogApi } from '@backstage/catalog-client'; +import { FieldProps } from '@rjsf/core'; +import { MyGroupsPicker } from './MyGroupsPicker'; +import { TestApiProvider } from '@backstage/test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; +import { + ErrorApi, + IdentityApi, + errorApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import userEvent from '@testing-library/user-event'; + +// Create a mock IdentityApi +const mockIdentityApi: IdentityApi = { + getProfileInfo: () => + Promise.resolve({ + displayName: 'Bob', + email: 'bob@example.com', + picture: 'https://example.com/picture.jpg', + }), + getBackstageIdentity: () => + Promise.resolve({ + id: 'Bob', + idToken: 'token', + type: 'user', + userEntityRef: 'user:default/bob', + ownershipEntityRefs: ['group:default/group1', 'group:default/group2'], + }), + getCredentials: () => Promise.resolve({ token: 'token' }), + signOut: () => Promise.resolve(), +}; + +describe('', () => { + let entities: Entity[]; + const onChange = jest.fn(); + const schema = {}; + const required = false; + + const catalogApi: jest.Mocked = { + getEntities: jest.fn(async () => ({ items: entities })), + } as any; + + const mockErrorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), + }; + + beforeEach(() => { + entities = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'group1' }, + spec: { members: ['Bob'] }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'group2' }, + spec: { members: ['Bob'] }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'group3' }, + spec: { members: ['Alice'] }, + }, + ]; + + onChange.mockClear(); + catalogApi.getEntities.mockClear(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should only return the groups a user is part of and not the groups a user is not part of', async () => { + const userGroups = entities.filter( + entity => + entity.spec && + Array.isArray(entity.spec.members) && + entity.spec.members.includes('Bob'), + ); + + catalogApi.getEntities.mockResolvedValue({ items: userGroups }); + + const props = { + onChange, + schema, + required, + } as unknown as FieldProps; + + render( + + + , + ); + + await waitFor(() => + expect(catalogApi.getEntities).toHaveBeenCalledTimes(1), + ); + + expect(catalogApi.getEntities).toHaveBeenCalledWith({ + filter: { + type: 'Group', + 'relations.hasMember': ['user:default/bob'], + }, + }); + + // Check that getEntities was set up to return the correct data + await expect(catalogApi.getEntities.mock.results[0].value).resolves.toEqual( + { + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'group1' }, + spec: { members: ['Bob'] }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'group2' }, + spec: { members: ['Bob'] }, + }, + ], + }, + ); + + await expect( + catalogApi.getEntities.mock.results[0].value, + ).resolves.not.toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + metadata: { name: 'group3' }, + }), + ]), + }), + ); + }); + + it('should display the groups a user is part of and not display the groups a user is not part of', async () => { + const userGroups = entities.filter( + entity => + entity.spec && + Array.isArray(entity.spec.members) && + entity.spec.members.includes('Bob'), + ); + + catalogApi.getEntities.mockResolvedValue({ items: userGroups }); + + const props = { + onChange, + schema, + required, + } as unknown as FieldProps; + + const { queryByText, getByRole } = render( + + + , + ); + + await waitFor(() => + expect(catalogApi.getEntities).toHaveBeenCalledTimes(1), + ); + + // Simulate user input + const inputField = getByRole('combobox'); + userEvent.click(inputField); + userEvent.type(inputField, 'group'); + + // Wait for the dropdown elements to appear + await waitFor(() => { + const group1Element = queryByText('group1'); + const group2Element = queryByText('group2'); + expect(group1Element).toBeInTheDocument(); + expect(group2Element).toBeInTheDocument(); + }); + + // Assert that 'group3' is not rendered in the component + expect(queryByText('group3')).not.toBeInTheDocument(); + }); + + it('should call the onChange handler with the correct entityRef and and use a nice display name', async () => { + const userGroups = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'group1', title: 'My First Group' }, + spec: { members: ['Bob'] }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'group2', title: 'My Second Group' }, + spec: { members: ['Bob'] }, + }, + ]; + + catalogApi.getEntities.mockResolvedValue({ items: userGroups }); + + const props = { + onChange, + schema, + required, + } as unknown as FieldProps; + + const { getByRole } = render( + + + , + ); + + await waitFor(() => + expect(catalogApi.getEntities).toHaveBeenCalledTimes(1), + ); + + const inputField = getByRole('combobox'); + userEvent.click(inputField); + userEvent.type(inputField, 'group'); + + await waitFor(() => { + expect( + getByRole('option', { name: 'My First Group' }), + ).toBeInTheDocument(); + }); + + const option = getByRole('option', { name: 'My First Group' }); + userEvent.click(option); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('group:default/group1'); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.tsx b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.tsx new file mode 100644 index 0000000000..28bc2b93fa --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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, { useState } from 'react'; +import { + errorApiRef, + identityApiRef, + useApi, +} from '@backstage/core-plugin-api'; +import { TextField, FormControl } from '@material-ui/core'; +import { MyGroupsPickerProps, MyGroupsPickerSchema } from './schema'; +import { Autocomplete } from '@material-ui/lab'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { NotFoundError } from '@backstage/errors'; +import useAsync from 'react-use/lib/useAsync'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; + +export { MyGroupsPickerSchema }; + +export const MyGroupsPicker = (props: MyGroupsPickerProps) => { + const { + schema: { title, description }, + required, + rawErrors, + onChange, + } = props; + + const identityApi = useApi(identityApiRef); + const catalogApi = useApi(catalogApiRef); + const errorApi = useApi(errorApiRef); + const [groups, setGroups] = useState< + { + label: string; + ref: string; + }[] + >([]); + const [selectedGroup, setSelectedGroup] = useState(null); + + useAsync(async () => { + const { userEntityRef } = await identityApi.getBackstageIdentity(); + + if (!userEntityRef) { + errorApi.post(new NotFoundError('No user entity ref found')); + return; + } + + const { items } = await catalogApi.getEntities({ + filter: { + type: 'Group', + ['relations.hasMember']: [userEntityRef], + }, + }); + + const groupValues = items + .filter((e): e is Entity => Boolean(e)) + .map(item => ({ + label: item.metadata.title ?? item.metadata.name, + ref: stringifyEntityRef(item), + })); + + setGroups(groupValues); + }); + + const updateChange = ( + _: React.ChangeEvent<{}>, + value: { label: string; ref: string } | null, + ) => { + setSelectedGroup(value); + onChange(value?.ref ?? ''); + }; + + return ( + 0} + > + group.label} + renderInput={params => ( + + )} + /> + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/MyGroupsPicker/index.ts b/plugins/scaffolder/src/components/fields/MyGroupsPicker/index.ts new file mode 100644 index 0000000000..00be19cf96 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MyGroupsPicker/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + MyGroupsPickerSchema, + type MyGroupsPickerUiOptions, + MyGroupsPickerFieldSchema, +} from './schema'; diff --git a/plugins/scaffolder/src/components/fields/MyGroupsPicker/schema.ts b/plugins/scaffolder/src/components/fields/MyGroupsPicker/schema.ts new file mode 100644 index 0000000000..d5f8af7c56 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MyGroupsPicker/schema.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * Field schema for the MyGroupsPicker. + * @public + */ + +export const MyGroupsPickerFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + title: z.string().default('Group').describe('Group'), + description: z + .string() + .default('A group you are part of') + .describe('The group to which the entity belongs'), + }), +); + +/** + * UI options for the MyGroupsPicker. + * @public + */ + +export type MyGroupsPickerUiOptions = + typeof MyGroupsPickerFieldSchema.uiOptionsType; +/** + * Props for the MyGroupsPicker. + * @public + */ + +export type MyGroupsPickerProps = typeof MyGroupsPickerFieldSchema.type; + +/** + * Schema for the MyGroupsPicker. + * @public + */ + +export const MyGroupsPickerSchema = MyGroupsPickerFieldSchema.schema; diff --git a/plugins/scaffolder/src/components/fields/index.ts b/plugins/scaffolder/src/components/fields/index.ts index 8d86f601ce..7ee4448f6b 100644 --- a/plugins/scaffolder/src/components/fields/index.ts +++ b/plugins/scaffolder/src/components/fields/index.ts @@ -18,4 +18,5 @@ export * from './OwnerPicker'; export * from './RepoUrlPicker'; export * from './OwnedEntityPicker'; export * from './EntityTagsPicker'; +export * from './MyGroupsPicker'; export { type FieldSchema, makeFieldSchemaFromZod } from './utils'; diff --git a/plugins/scaffolder/src/extensions/default.ts b/plugins/scaffolder/src/extensions/default.ts index c6ab5276d0..013f242a12 100644 --- a/plugins/scaffolder/src/extensions/default.ts +++ b/plugins/scaffolder/src/extensions/default.ts @@ -39,6 +39,10 @@ import { OwnedEntityPicker, OwnedEntityPickerSchema, } from '../components/fields/OwnedEntityPicker/OwnedEntityPicker'; +import { + MyGroupsPicker, + MyGroupsPickerSchema, +} from '../components/fields/MyGroupsPicker/MyGroupsPicker'; export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [ { @@ -73,4 +77,9 @@ export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [ name: 'OwnedEntityPicker', schema: OwnedEntityPickerSchema, }, + { + component: MyGroupsPicker, + name: 'MyGroupsPicker', + schema: MyGroupsPickerSchema, + }, ]; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index bc56629a2c..ee91d2988c 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -27,6 +27,7 @@ export { EntityTagsPickerFieldExtension, OwnerPickerFieldExtension, OwnedEntityPickerFieldExtension, + MyGroupsPickerFieldExtension, RepoUrlPickerFieldExtension, ScaffolderPage, scaffolderPlugin, diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx index dfe87a3780..ad9e70d843 100644 --- a/plugins/scaffolder/src/plugin.tsx +++ b/plugins/scaffolder/src/plugin.tsx @@ -64,6 +64,10 @@ import { actionsRouteRef, editRouteRef, } from './routes'; +import { + MyGroupsPicker, + MyGroupsPickerSchema, +} from './components/fields/MyGroupsPicker/MyGroupsPicker'; /** * The main plugin export for the scaffolder. @@ -158,6 +162,19 @@ export const OwnerPickerFieldExtension = scaffolderPlugin.provide( }), ); +/** + * A field extension for picking groups a user belongs to out of the catalog. + * + * @public + */ +export const MyGroupsPickerFieldExtension = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + component: MyGroupsPicker, + name: 'MyGroupsPicker', + schema: MyGroupsPickerSchema, + }), +); + /** * The Router and main entrypoint to the Scaffolder plugin. *