Merge pull request #17608 from Parsifal-M/Owned-Groups-Field-Extension
Owned Groups Scaffolder Field Extension
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
Added `MyGroupsPicker` field extension that will display a dropdown of groups a user is part of.
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -27,6 +27,7 @@ export {
|
||||
EntityTagsPickerFieldExtension,
|
||||
OwnerPickerFieldExtension,
|
||||
OwnedEntityPickerFieldExtension,
|
||||
MyGroupsPickerFieldExtension,
|
||||
RepoUrlPickerFieldExtension,
|
||||
ScaffolderPage,
|
||||
scaffolderPlugin,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user