Merge pull request #17608 from Parsifal-M/Owned-Groups-Field-Extension

Owned Groups Scaffolder Field Extension
This commit is contained in:
Ben Lambert
2023-06-13 17:51:13 +02:00
committed by GitHub
10 changed files with 525 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Added `MyGroupsPicker` field extension that will display a dropdown of groups a user is part of.
+25
View File
@@ -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,
@@ -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('<MyGroupsPicker />', () => {
let entities: Entity[];
const onChange = jest.fn();
const schema = {};
const required = false;
const catalogApi: jest.Mocked<CatalogApi> = {
getEntities: jest.fn(async () => ({ items: entities })),
} as any;
const mockErrorApi: jest.Mocked<ErrorApi> = {
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<any>;
render(
<TestApiProvider
apis={[
[identityApiRef, mockIdentityApi],
[catalogApiRef, catalogApi],
[errorApiRef, mockErrorApi],
]}
>
<MyGroupsPicker {...props} />
</TestApiProvider>,
);
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<any>;
const { queryByText, getByRole } = render(
<TestApiProvider
apis={[
[identityApiRef, mockIdentityApi],
[catalogApiRef, catalogApi],
[errorApiRef, mockErrorApi],
]}
>
<MyGroupsPicker {...props} />
</TestApiProvider>,
);
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<any>;
const { getByRole } = render(
<TestApiProvider
apis={[
[identityApiRef, mockIdentityApi],
[catalogApiRef, catalogApi],
[errorApiRef, mockErrorApi],
]}
>
<MyGroupsPicker {...props} />
</TestApiProvider>,
);
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');
});
});
});
@@ -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 | {
label: string;
ref: string;
}>(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 (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0}
>
<Autocomplete
id="OwnershipEntityRefPicker-dropdown"
options={groups || []}
value={selectedGroup}
onChange={updateChange}
getOptionLabel={group => group.label}
renderInput={params => (
<TextField
{...params}
label={title}
margin="dense"
helperText={description}
FormHelperTextProps={{ margin: 'dense', style: { marginLeft: 0 } }}
variant="outlined"
required={required}
/>
)}
/>
</FormControl>
);
};
@@ -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';
@@ -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;
@@ -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';
@@ -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,
},
];
+1
View File
@@ -27,6 +27,7 @@ export {
EntityTagsPickerFieldExtension,
OwnerPickerFieldExtension,
OwnedEntityPickerFieldExtension,
MyGroupsPickerFieldExtension,
RepoUrlPickerFieldExtension,
ScaffolderPage,
scaffolderPlugin,
+17
View File
@@ -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.
*