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.
*