Migrate custom fields to new schema factory function;

standardize field descriptions to prefer ui:description and present consistently,
utilizing ScaffolderField component where possible.

Signed-off-by: Matt Benson <gudnabrsam@gmail.com>
This commit is contained in:
Matt Benson
2025-04-29 11:07:24 -05:00
parent 41380bdcc4
commit a274e0a362
28 changed files with 1154 additions and 316 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-scaffolder': patch
---
Migrate custom fields to new schema factory function;
standardize field descriptions to prefer ui:description and present consistently,
utilizing ScaffolderField component where possible.
+2
View File
@@ -289,6 +289,8 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'fields.entityPicker.description': 'An entity from the catalog';
readonly 'fields.entityTagsPicker.title': 'Tags';
readonly 'fields.entityTagsPicker.description': "Add any relevant tags, hit 'Enter' to add new tags. Valid format: [a-z0-9+#] separated by [-], at most 63 characters";
readonly 'fields.multiEntityPicker.title': 'Entity';
readonly 'fields.multiEntityPicker.description': 'An entity from the catalog';
readonly 'fields.myGroupsPicker.title': 'Entity';
readonly 'fields.myGroupsPicker.description': 'An entity from the catalog';
readonly 'fields.ownedEntityPicker.title': 'Entity';
+25 -27
View File
@@ -71,7 +71,7 @@ export type CustomFieldValidator<TReturnFieldData> =
// @public
export const EntityNamePickerFieldExtension: FieldExtensionComponent_2<
string,
{}
any
>;
// @public
@@ -104,7 +104,7 @@ export const EntityPickerFieldExtension: FieldExtensionComponent_2<
>;
// @public (undocumented)
export const EntityPickerFieldSchema: FieldSchema<
export const EntityPickerFieldSchema: FieldSchema_2<
string,
{
defaultKind?: string | undefined;
@@ -133,8 +133,9 @@ export const EntityPickerFieldSchema: FieldSchema<
>;
// @public
export type EntityPickerUiOptions =
typeof EntityPickerFieldSchema.uiOptionsType;
export type EntityPickerUiOptions = NonNullable<
(typeof EntityPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
// @public
export const EntityTagsPickerFieldExtension: FieldExtensionComponent_2<
@@ -147,7 +148,7 @@ export const EntityTagsPickerFieldExtension: FieldExtensionComponent_2<
>;
// @public (undocumented)
export const EntityTagsPickerFieldSchema: FieldSchema<
export const EntityTagsPickerFieldSchema: FieldSchema_2<
string[],
{
helperText?: string | undefined;
@@ -157,8 +158,9 @@ export const EntityTagsPickerFieldSchema: FieldSchema<
>;
// @public
export type EntityTagsPickerUiOptions =
typeof EntityTagsPickerFieldSchema.uiOptionsType;
export type EntityTagsPickerUiOptions = NonNullable<
(typeof EntityTagsPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
// @public @deprecated (undocumented)
export type FieldExtensionComponent<_TReturnValue, _TInputProps> =
@@ -233,27 +235,19 @@ export const MultiEntityPickerFieldExtension: FieldExtensionComponent_2<
// @public
export const MyGroupsPickerFieldExtension: FieldExtensionComponent_2<
string,
{
title?: string | undefined;
description?: string | undefined;
}
any
>;
// @public
export const MyGroupsPickerFieldSchema: FieldSchema<
string,
{
title?: string | undefined;
description?: string | undefined;
}
>;
export const MyGroupsPickerFieldSchema: FieldSchema_2<string, any>;
// @public
export const MyGroupsPickerSchema: CustomFieldExtensionSchema_2;
// @public
export type MyGroupsPickerUiOptions =
typeof MyGroupsPickerFieldSchema.uiOptionsType;
export type MyGroupsPickerUiOptions = NonNullable<
(typeof MyGroupsPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
// @public
export const OwnedEntityPickerFieldExtension: FieldExtensionComponent_2<
@@ -285,7 +279,7 @@ export const OwnedEntityPickerFieldExtension: FieldExtensionComponent_2<
>;
// @public (undocumented)
export const OwnedEntityPickerFieldSchema: FieldSchema<
export const OwnedEntityPickerFieldSchema: FieldSchema_2<
string,
{
defaultKind?: string | undefined;
@@ -314,8 +308,9 @@ export const OwnedEntityPickerFieldSchema: FieldSchema<
>;
// @public
export type OwnedEntityPickerUiOptions =
typeof OwnedEntityPickerFieldSchema.uiOptionsType;
export type OwnedEntityPickerUiOptions = NonNullable<
(typeof OwnedEntityPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
// @public
export const OwnerPickerFieldExtension: FieldExtensionComponent_2<
@@ -346,7 +341,7 @@ export const OwnerPickerFieldExtension: FieldExtensionComponent_2<
>;
// @public (undocumented)
export const OwnerPickerFieldSchema: FieldSchema<
export const OwnerPickerFieldSchema: FieldSchema_2<
string,
{
defaultNamespace?: string | false | undefined;
@@ -374,7 +369,9 @@ export const OwnerPickerFieldSchema: FieldSchema<
>;
// @public
export type OwnerPickerUiOptions = typeof OwnerPickerFieldSchema.uiOptionsType;
export type OwnerPickerUiOptions = NonNullable<
(typeof OwnerPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
// @public
export const RepoBranchPickerFieldExtension: FieldExtensionComponent_2<
@@ -462,8 +459,9 @@ export const RepoUrlPickerFieldSchema: FieldSchema_2<
>;
// @public @deprecated
export type RepoUrlPickerUiOptions =
typeof RepoUrlPickerFieldSchema.uiOptionsType;
export type RepoUrlPickerUiOptions = NonNullable<
(typeof RepoUrlPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
export { ReviewStepProps };
@@ -13,11 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { makeFieldSchemaFromZod } from '../utils';
import { makeFieldSchema } from '@backstage/plugin-scaffolder-react';
const EntityNamePickerFieldSchema = makeFieldSchemaFromZod(z.string());
const EntityNamePickerFieldSchema = makeFieldSchema({
output: z => z.string(),
});
export const EntityNamePickerSchema = EntityNamePickerFieldSchema.schema;
export type EntityNamePickerProps = typeof EntityNamePickerFieldSchema.type;
export type EntityNamePickerProps = typeof EntityNamePickerFieldSchema.TProps;
@@ -28,6 +28,8 @@ import { EntityPickerProps } from './schema';
import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react';
import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { scaffolderTranslationRef } from '../../../translation';
const makeEntity = (kind: string, namespace: string, name: string): Entity => ({
apiVersion: 'scaffolder.backstage.io/v1beta3',
@@ -891,4 +893,128 @@ describe('<EntityPicker />', () => {
expect(onChange).toHaveBeenCalledWith(undefined);
});
});
describe('EntityPicker description', () => {
const description = {
fromSchema: 'EntityPicker description from schema',
fromUiSchema: 'EntityPicker description from uiSchema',
} as { fromSchema: string; fromUiSchema: string; default?: string };
beforeEach(() => {
const RealWrapper = Wrapper;
Wrapper = ({ children }: { children?: ReactNode }) => {
const { t } = useTranslationRef(scaffolderTranslationRef);
description.default = t('fields.entityPicker.description');
return <RealWrapper>{children}</RealWrapper>;
};
});
it('presents default description', async () => {
uiSchema = {
'ui:options': {
catalogFilter: [
{
kind: ['Group'],
'metadata.name': 'test-entity',
},
{
kind: ['User'],
'metadata.name': 'test-entity',
},
],
},
};
props = {
onChange,
schema,
required: true,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<EntityPicker {...props} />
</Wrapper>,
);
expect(getByText(description.default!)).toBeInTheDocument();
expect(queryByText(description.fromSchema)).toBe(null);
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents schema description', async () => {
uiSchema = {
'ui:options': {
catalogFilter: [
{
kind: ['Group'],
'metadata.name': 'test-entity',
},
{
kind: ['User'],
'metadata.name': 'test-entity',
},
],
},
};
props = {
onChange,
schema: {
...schema,
description: description.fromSchema,
},
required: true,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<EntityPicker {...props} />
</Wrapper>,
);
expect(queryByText(description.default!)).toBe(null);
expect(getByText(description.fromSchema)).toBeInTheDocument();
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents uiSchema description', async () => {
uiSchema = {
'ui:options': {
catalogFilter: [
{
kind: ['Group'],
'metadata.name': 'test-entity',
},
{
kind: ['User'],
'metadata.name': 'test-entity',
},
],
},
'ui:description': description.fromUiSchema,
};
props = {
onChange,
schema: {
...schema,
description: description.fromSchema,
},
required: true,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<EntityPicker {...props} />
</Wrapper>,
);
expect(queryByText(description.default!)).toBe(null);
expect(queryByText(description.fromSchema)).toBe(null);
expect(getByText(description.fromUiSchema)).toBeInTheDocument();
});
});
});
@@ -185,7 +185,7 @@ export const EntityPicker = (props: EntityPickerProps) => {
return (
<ScaffolderField
rawErrors={rawErrors}
rawDescription={description}
rawDescription={uiSchema['ui:description'] ?? description}
required={required}
disabled={isDisabled}
errors={errors}
@@ -13,57 +13,58 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { makeFieldSchemaFromZod } from '../utils';
import { z as zod } from 'zod';
import { makeFieldSchema } from '@backstage/plugin-scaffolder-react';
/**
* @public
*/
export const entityQueryFilterExpressionSchema = z.record(
z
export const entityQueryFilterExpressionSchema = zod.record(
zod
.string()
.or(z.object({ exists: z.boolean().optional() }))
.or(z.array(z.string())),
.or(zod.object({ exists: zod.boolean().optional() }))
.or(zod.array(zod.string())),
);
/**
* @public
*/
export const EntityPickerFieldSchema = makeFieldSchemaFromZod(
z.string(),
z.object({
/**
* @deprecated Use `catalogFilter` instead.
*/
allowedKinds: z
.array(z.string())
.optional()
.describe(
'DEPRECATED: Use `catalogFilter` instead. List of kinds of entities to derive options from',
),
defaultKind: z
.string()
.optional()
.describe(
'The default entity kind. Options of this kind will not be prefixed.',
),
allowArbitraryValues: z
.boolean()
.optional()
.describe('Whether to allow arbitrary user input. Defaults to true'),
defaultNamespace: z
.union([z.string(), z.literal(false)])
.optional()
.describe(
'The default namespace. Options with this namespace will not be prefixed.',
),
catalogFilter: z
.array(entityQueryFilterExpressionSchema)
.or(entityQueryFilterExpressionSchema)
.optional()
.describe('List of key-value filter expression for entities'),
}),
);
export const EntityPickerFieldSchema = makeFieldSchema({
output: z => z.string(),
uiOptions: z =>
z.object({
/**
* @deprecated Use `catalogFilter` instead.
*/
allowedKinds: z
.array(z.string())
.optional()
.describe(
'DEPRECATED: Use `catalogFilter` instead. List of kinds of entities to derive options from',
),
defaultKind: z
.string()
.optional()
.describe(
'The default entity kind. Options of this kind will not be prefixed.',
),
allowArbitraryValues: z
.boolean()
.optional()
.describe('Whether to allow arbitrary user input. Defaults to true'),
defaultNamespace: z
.union([z.string(), z.literal(false)])
.optional()
.describe(
'The default namespace. Options with this namespace will not be prefixed.',
),
catalogFilter: z
.array(entityQueryFilterExpressionSchema)
.or(entityQueryFilterExpressionSchema)
.optional()
.describe('List of key-value filter expression for entities'),
}),
});
/**
* The input props that can be specified under `ui:options` for the
@@ -71,14 +72,15 @@ export const EntityPickerFieldSchema = makeFieldSchemaFromZod(
*
* @public
*/
export type EntityPickerUiOptions =
typeof EntityPickerFieldSchema.uiOptionsType;
export type EntityPickerUiOptions = NonNullable<
(typeof EntityPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
export type EntityPickerProps = typeof EntityPickerFieldSchema.type;
export type EntityPickerProps = typeof EntityPickerFieldSchema.TProps;
export const EntityPickerSchema = EntityPickerFieldSchema.schema;
export type EntityPickerFilterQuery = z.TypeOf<
export type EntityPickerFilterQuery = zod.TypeOf<
typeof entityQueryFilterExpressionSchema
>;
@@ -0,0 +1,148 @@
/*
* Copyright 2025 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 { Entity } from '@backstage/catalog-model';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { ComponentType, PropsWithChildren, ReactNode } from 'react';
import { EntityTagsPicker } from './EntityTagsPicker';
import { EntityTagsPickerProps } from './schema';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { scaffolderTranslationRef } from '../../../translation';
const makeEntity = (kind: string, namespace: string, name: string): Entity => ({
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind,
metadata: { namespace, name },
});
describe('<EntityTagsPicker />', () => {
const entities: Entity[] = [
makeEntity('Group', 'default', 'team-a'),
makeEntity('Group', 'default', 'squad-b'),
];
const onChange = jest.fn();
const schema = { type: 'array', items: { type: 'string' } };
const uiSchema: EntityTagsPickerProps['uiSchema'] = {
'ui:options': {},
};
const rawErrors: string[] = [];
const formData = undefined;
let props: FieldProps<string[]>;
const catalogApi = catalogApiMock.mock({
getEntities: jest.fn(async () => ({ items: entities })),
});
let Wrapper: ComponentType<PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
{children}
</TestApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
describe('EntityTagsPicker description', () => {
const description = {
fromSchema: 'EntityTagsPicker description from schema',
fromUiSchema: 'EntityTagsPicker description from uiSchema',
} as { fromSchema: string; fromUiSchema: string; default?: string };
beforeEach(() => {
const RealWrapper = Wrapper;
Wrapper = ({ children }: { children?: ReactNode }) => {
const { t } = useTranslationRef(scaffolderTranslationRef);
description.default = t('fields.entityTagsPicker.description');
return <RealWrapper>{children}</RealWrapper>;
};
});
it('presents default description', async () => {
props = {
onChange,
schema,
required: true,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<EntityTagsPicker {...props} />
</Wrapper>,
);
expect(getByText(description.default!)).toBeInTheDocument();
expect(queryByText(description.fromSchema)).toBe(null);
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents schema description', async () => {
props = {
onChange,
schema: {
...schema,
description: description.fromSchema,
},
required: true,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<EntityTagsPicker {...props} />
</Wrapper>,
);
expect(queryByText(description.default!)).toBe(null);
expect(getByText(description.fromSchema)).toBeInTheDocument();
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents uiSchema description', async () => {
props = {
onChange,
schema: {
...schema,
description: description.fromSchema,
},
required: true,
uiSchema: {
...uiSchema,
'ui:description': description.fromUiSchema,
},
rawErrors,
formData,
} as unknown as FieldProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<EntityTagsPicker {...props} />
</Wrapper>,
);
expect(queryByText(description.default!)).toBe(null);
expect(queryByText(description.fromSchema)).toBe(null);
expect(getByText(description.fromUiSchema)).toBeInTheDocument();
});
});
});
@@ -20,12 +20,12 @@ import { GetEntityFacetsRequest } from '@backstage/catalog-client';
import { makeValidator } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import FormControl from '@material-ui/core/FormControl';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { EntityTagsPickerProps } from './schema';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha';
export { EntityTagsPickerSchema } from './schema';
@@ -36,7 +36,19 @@ export { EntityTagsPickerSchema } from './schema';
* @public
*/
export const EntityTagsPicker = (props: EntityTagsPickerProps) => {
const { formData, onChange, uiSchema } = props;
const { t } = useTranslationRef(scaffolderTranslationRef);
const {
formData,
onChange,
schema: {
title = t('fields.entityTagsPicker.title'),
description = t('fields.entityTagsPicker.description'),
},
uiSchema,
rawErrors,
required,
errors,
} = props;
const catalogApi = useApi(catalogApiRef);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
@@ -47,8 +59,6 @@ export const EntityTagsPicker = (props: EntityTagsPickerProps) => {
const helperText = uiSchema['ui:options']?.helperText;
const isDisabled = uiSchema?.['ui:disabled'] ?? false;
const { t } = useTranslationRef(scaffolderTranslationRef);
const { loading, value: existingTags } = useAsync(async () => {
const facet = 'metadata.tags';
const tagsRequest: GetEntityFacetsRequest = { facets: [facet] };
@@ -97,7 +107,13 @@ export const EntityTagsPicker = (props: EntityTagsPickerProps) => {
useEffectOnce(() => onChange(formData || []));
return (
<FormControl margin="normal">
<ScaffolderField
rawErrors={rawErrors}
rawDescription={helperText ?? uiSchema['ui:description'] ?? description}
required={required}
disabled={isDisabled}
errors={errors}
>
<Autocomplete
multiple
freeSolo
@@ -115,15 +131,14 @@ export const EntityTagsPicker = (props: EntityTagsPickerProps) => {
renderInput={params => (
<TextField
{...params}
label={t('fields.entityTagsPicker.title')}
label={title}
disabled={isDisabled}
onChange={e => setInputValue(e.target.value)}
error={inputError}
helperText={helperText ?? t('fields.entityTagsPicker.description')}
FormHelperTextProps={{ margin: 'dense', style: { marginLeft: 0 } }}
/>
)}
/>
</FormControl>
</ScaffolderField>
);
};
@@ -13,30 +13,35 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { makeFieldSchemaFromZod } from '../utils';
import { makeFieldSchema } from '@backstage/plugin-scaffolder-react';
/**
* @public
*/
export const EntityTagsPickerFieldSchema = makeFieldSchemaFromZod(
z.array(z.string()),
z.object({
kinds: z
.array(z.string())
.optional()
.describe('List of kinds of entities to derive tags from'),
showCounts: z
.boolean()
.optional()
.describe('Whether to show usage counts per tag'),
helperText: z.string().optional().describe('Helper text to display'),
}),
);
export const EntityTagsPickerFieldSchema = makeFieldSchema({
output: z => z.array(z.string()),
uiOptions: z =>
z.object({
kinds: z
.array(z.string())
.optional()
.describe('List of kinds of entities to derive tags from'),
showCounts: z
.boolean()
.optional()
.describe('Whether to show usage counts per tag'),
helperText: z
.string()
.optional()
.describe(
'Helper text to display; DEPRECATED, simply use ui:description',
),
}),
});
export const EntityTagsPickerSchema = EntityTagsPickerFieldSchema.schema;
export type EntityTagsPickerProps = typeof EntityTagsPickerFieldSchema.type;
export type EntityTagsPickerProps = typeof EntityTagsPickerFieldSchema.TProps;
/**
* The input props that can be specified under `ui:options` for the
@@ -44,5 +49,6 @@ export type EntityTagsPickerProps = typeof EntityTagsPickerFieldSchema.type;
*
* @public
*/
export type EntityTagsPickerUiOptions =
typeof EntityTagsPickerFieldSchema.uiOptionsType;
export type EntityTagsPickerUiOptions = NonNullable<
(typeof EntityTagsPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
@@ -30,6 +30,8 @@ import { MultiEntityPickerProps } from './schema';
import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react';
import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { scaffolderTranslationRef } from '../../../translation';
const makeEntity = (kind: string, namespace: string, name: string): Entity => ({
apiVersion: 'scaffolder.backstage.io/v1beta3',
@@ -43,7 +45,7 @@ describe('<MultiEntityPicker />', () => {
makeEntity('Group', 'default', 'squad-b'),
];
const onChange = jest.fn();
const schema = {};
const schema = { type: 'array', items: { type: 'string' } };
const required = false;
let uiSchema: MultiEntityPickerProps['uiSchema'];
const rawErrors: string[] = [];
@@ -847,4 +849,102 @@ describe('<MultiEntityPicker />', () => {
]);
});
});
describe('MultiEntityPicker description', () => {
const description = {
fromSchema: 'MultiEntityPicker description from schema',
fromUiSchema: 'MultiEntityPicker description from uiSchema',
} as { fromSchema: string; fromUiSchema: string; default?: string };
beforeEach(() => {
const RealWrapper = Wrapper;
Wrapper = ({ children }: { children?: ReactNode }) => {
const { t } = useTranslationRef(scaffolderTranslationRef);
description.default = t('fields.multiEntityPicker.description');
return <RealWrapper>{children}</RealWrapper>;
};
uiSchema = {
'ui:options': {
catalogFilter: [
{
kind: ['Group'],
'metadata.name': 'test-entity',
},
{
kind: ['User'],
'metadata.name': 'test-entity',
},
],
},
};
});
it('presents default description', async () => {
props = {
onChange,
schema,
required: true,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
expect(getByText(description.default!)).toBeInTheDocument();
expect(queryByText(description.fromSchema)).toBe(null);
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents schema description', async () => {
props = {
onChange,
schema: {
...schema,
description: description.fromSchema,
},
required: true,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
expect(queryByText(description.default!)).toBe(null);
expect(getByText(description.fromSchema)).toBeInTheDocument();
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents uiSchema description', async () => {
props = {
onChange,
schema: {
...schema,
description: description.fromSchema,
},
required: true,
uiSchema: {
...uiSchema,
'ui:description': description.fromUiSchema,
},
rawErrors,
formData,
} as unknown as FieldProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
expect(queryByText(description.default!)).toBe(null);
expect(queryByText(description.fromSchema)).toBe(null);
expect(getByText(description.fromUiSchema)).toBeInTheDocument();
});
});
});
@@ -30,7 +30,6 @@ import {
EntityRefPresentationSnapshot,
} from '@backstage/plugin-catalog-react';
import TextField from '@material-ui/core/TextField';
import FormControl from '@material-ui/core/FormControl';
import Autocomplete, {
AutocompleteChangeReason,
} from '@material-ui/lab/Autocomplete';
@@ -44,6 +43,9 @@ import {
MultiEntityPickerFilterQuery,
} from './schema';
import { VirtualizedListbox } from '../VirtualizedListbox';
import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { scaffolderTranslationRef } from '../../../translation';
export { MultiEntityPickerSchema } from './schema';
@@ -52,14 +54,19 @@ export { MultiEntityPickerSchema } from './schema';
* field extension.
*/
export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
const { t } = useTranslationRef(scaffolderTranslationRef);
const {
onChange,
schema: { title = 'Entity', description = 'An entity from the catalog' },
schema: {
title = t('fields.multiEntityPicker.title'),
description = t('fields.multiEntityPicker.description'),
},
required,
uiSchema,
rawErrors,
formData,
idSchema,
errors,
} = props;
const catalogFilter = buildCatalogFilter(uiSchema);
@@ -144,10 +151,12 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
}, [entities, onChange, required, allowArbitraryValues]);
return (
<FormControl
margin="normal"
<ScaffolderField
rawErrors={rawErrors}
rawDescription={uiSchema['ui:description'] ?? description}
required={required}
error={rawErrors?.length > 0 && !formData}
disabled={isDisabled}
errors={errors}
>
<Autocomplete
multiple
@@ -182,7 +191,6 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
label={title}
disabled={isDisabled}
margin="dense"
helperText={description}
FormHelperTextProps={{
margin: 'dense',
style: { marginLeft: 0 },
@@ -197,7 +205,7 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
)}
ListboxComponent={VirtualizedListbox}
/>
</FormControl>
</ScaffolderField>
);
};
@@ -13,55 +13,57 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { makeFieldSchemaFromZod } from '../utils';
import { z as zod } from 'zod';
import { makeFieldSchema } from '@backstage/plugin-scaffolder-react';
export const entityQueryFilterExpressionSchema = z.record(
z
export const entityQueryFilterExpressionSchema = zod.record(
zod
.string()
.or(z.object({ exists: z.boolean().optional() }))
.or(z.array(z.string())),
.or(zod.object({ exists: zod.boolean().optional() }))
.or(zod.array(zod.string())),
);
export const MultiEntityPickerFieldSchema = makeFieldSchemaFromZod(
z.array(z.string()),
z.object({
defaultKind: z
.string()
.optional()
.describe(
'The default entity kind. Options of this kind will not be prefixed.',
),
allowArbitraryValues: z
.boolean()
.optional()
.describe('Whether to allow arbitrary user input. Defaults to true'),
defaultNamespace: z
.union([z.string(), z.literal(false)])
.optional()
.describe(
'The default namespace. Options with this namespace will not be prefixed.',
),
catalogFilter: z
.array(entityQueryFilterExpressionSchema)
.or(entityQueryFilterExpressionSchema)
.optional()
.describe('List of key-value filter expression for entities'),
}),
);
export const MultiEntityPickerFieldSchema = makeFieldSchema({
output: z => z.array(z.string()),
uiOptions: z =>
z.object({
defaultKind: z
.string()
.optional()
.describe(
'The default entity kind. Options of this kind will not be prefixed.',
),
allowArbitraryValues: z
.boolean()
.optional()
.describe('Whether to allow arbitrary user input. Defaults to true'),
defaultNamespace: z
.union([z.string(), z.literal(false)])
.optional()
.describe(
'The default namespace. Options with this namespace will not be prefixed.',
),
catalogFilter: z
.array(entityQueryFilterExpressionSchema)
.or(entityQueryFilterExpressionSchema)
.optional()
.describe('List of key-value filter expression for entities'),
}),
});
/**
* The input props that can be specified under `ui:options` for the
* `EntityPicker` field extension.
*/
export type MultiEntityPickerUiOptions =
typeof MultiEntityPickerFieldSchema.uiOptionsType;
export type MultiEntityPickerUiOptions = NonNullable<
(typeof MultiEntityPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
export type MultiEntityPickerProps = typeof MultiEntityPickerFieldSchema.type;
export type MultiEntityPickerProps = typeof MultiEntityPickerFieldSchema.TProps;
export const MultiEntityPickerSchema = MultiEntityPickerFieldSchema.schema;
export type MultiEntityPickerFilterQuery = z.TypeOf<
export type MultiEntityPickerFilterQuery = zod.TypeOf<
typeof entityQueryFilterExpressionSchema
>;
@@ -35,6 +35,9 @@ import {
import userEvent from '@testing-library/user-event';
import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react';
import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog';
import { ComponentType, PropsWithChildren, ReactNode } from 'react';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { scaffolderTranslationRef } from '../../../translation';
const mockIdentityApi = mockApis.identity({
userEntityRef: 'user:default/bob',
@@ -99,6 +102,7 @@ describe('<MyGroupsPicker />', () => {
onChange,
schema,
required,
uiSchema: {},
} as unknown as FieldProps<string>;
await renderInTestApp(
@@ -175,6 +179,7 @@ describe('<MyGroupsPicker />', () => {
onChange,
schema,
required,
uiSchema: {},
} as unknown as FieldProps<string>;
const { queryByText, getByRole } = await renderInTestApp(
@@ -236,6 +241,7 @@ describe('<MyGroupsPicker />', () => {
onChange,
schema,
required,
uiSchema: {},
} as unknown as FieldProps<string>;
const { getByRole } = await renderInTestApp(
@@ -299,6 +305,7 @@ describe('<MyGroupsPicker />', () => {
onChange,
schema,
required,
uiSchema: {},
formData: 'group:default/group1',
} as unknown as FieldProps<string>;
@@ -327,4 +334,99 @@ describe('<MyGroupsPicker />', () => {
expect(inputFieldValue).toEqual(userGroups[0].metadata.title);
});
describe('MyGroupsPicker description', () => {
const description = {
fromSchema: 'MyGroupsPicker description from schema',
fromUiSchema: 'MyGroupsPicker description from uiSchema',
} as { fromSchema: string; fromUiSchema: string; default?: string };
let Wrapper: ComponentType<PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: ReactNode }) => {
const { t } = useTranslationRef(scaffolderTranslationRef);
description.default = t('fields.myGroupsPicker.description');
return (
<TestApiProvider
apis={[
[identityApiRef, mockIdentityApi],
[catalogApiRef, catalogApi],
[errorApiRef, mockErrorApi],
[
entityPresentationApiRef,
DefaultEntityPresentationApi.create({ catalogApi }),
],
]}
>
{children}
</TestApiProvider>
);
};
});
it('presents default description', async () => {
const props = {
onChange,
schema,
required: true,
uiSchema: {},
formData: 'group:default/group1',
} as unknown as FieldProps<string>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<MyGroupsPicker {...props} />
</Wrapper>,
);
expect(getByText(description.default!)).toBeInTheDocument();
expect(queryByText(description.fromSchema)).toBe(null);
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents schema description', async () => {
const props = {
onChange,
schema: {
...schema,
description: description.fromSchema,
},
required: true,
uiSchema: {},
formData: 'group:default/group1',
} as unknown as FieldProps<string>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<MyGroupsPicker {...props} />
</Wrapper>,
);
expect(queryByText(description.default!)).toBe(null);
expect(getByText(description.fromSchema)).toBeInTheDocument();
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents uiSchema description', async () => {
const props = {
onChange,
schema: {
...schema,
description: description.fromSchema,
},
required: true,
uiSchema: {
'ui:description': description.fromUiSchema,
},
formData: 'group:default/group1',
} as unknown as FieldProps<string>;
const { getByText, queryByText } = await renderInTestApp(
<Wrapper>
<MyGroupsPicker {...props} />
</Wrapper>,
);
expect(queryByText(description.default!)).toBe(null);
expect(queryByText(description.fromSchema)).toBe(null);
expect(getByText(description.fromUiSchema)).toBeInTheDocument();
});
});
});
@@ -21,7 +21,6 @@ import {
useApi,
} from '@backstage/core-plugin-api';
import TextField from '@material-ui/core/TextField';
import FormControl from '@material-ui/core/FormControl';
import { MyGroupsPickerProps, MyGroupsPickerSchema } from './schema';
import Autocomplete, {
createFilterOptions,
@@ -38,6 +37,7 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { VirtualizedListbox } from '../VirtualizedListbox';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha';
export { MyGroupsPickerSchema };
@@ -52,12 +52,15 @@ export const MyGroupsPicker = (props: MyGroupsPickerProps) => {
rawErrors,
onChange,
formData,
uiSchema,
errors,
} = props;
const identityApi = useApi(identityApiRef);
const catalogApi = useApi(catalogApiRef);
const errorApi = useApi(errorApiRef);
const entityPresentationApi = useApi(entityPresentationApiRef);
const isDisabled = uiSchema?.['ui:disabled'] ?? false;
const { value: groups, loading } = useAsync(async () => {
const { userEntityRef } = await identityApi.getBackstageIdentity();
@@ -108,10 +111,12 @@ export const MyGroupsPicker = (props: MyGroupsPickerProps) => {
}, [groups, onChange, selectedEntity, required]);
return (
<FormControl
margin="normal"
<ScaffolderField
rawErrors={rawErrors}
rawDescription={uiSchema['ui:description'] ?? description}
required={required}
error={rawErrors?.length > 0}
disabled={isDisabled}
errors={errors}
>
<Autocomplete
disabled={required && groups?.catalogEntities.length === 1}
@@ -130,7 +135,6 @@ export const MyGroupsPicker = (props: MyGroupsPickerProps) => {
{...params}
label={title}
margin="dense"
helperText={description}
FormHelperTextProps={{ margin: 'dense', style: { marginLeft: 0 } }}
variant="outlined"
required={required}
@@ -145,6 +149,6 @@ export const MyGroupsPicker = (props: MyGroupsPickerProps) => {
})}
ListboxComponent={VirtualizedListbox}
/>
</FormControl>
</ScaffolderField>
);
};
@@ -14,42 +14,34 @@
* limitations under the License.
*/
import { z } from 'zod';
import { makeFieldSchemaFromZod } from '../utils';
import { makeFieldSchema } from '@backstage/plugin-scaffolder-react';
/**
* 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'),
}),
);
export const MyGroupsPickerFieldSchema = makeFieldSchema({
output: z => z.string(),
});
/**
* UI options for the MyGroupsPicker.
* @public
*/
export type MyGroupsPickerUiOptions =
typeof MyGroupsPickerFieldSchema.uiOptionsType;
export type MyGroupsPickerUiOptions = NonNullable<
(typeof MyGroupsPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
/**
* Props for the MyGroupsPicker.
* @public
*/
export type MyGroupsPickerProps = typeof MyGroupsPickerFieldSchema.type;
export type MyGroupsPickerProps = typeof MyGroupsPickerFieldSchema.TProps;
/**
* Schema for the MyGroupsPicker.
* @public
*/
export const MyGroupsPickerSchema = MyGroupsPickerFieldSchema.schema;
@@ -24,6 +24,7 @@ import { OwnedEntityPickerProps } from './schema';
import { EntityPickerProps } from '../EntityPicker/schema';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha';
export { OwnedEntityPickerSchema } from './schema';
@@ -52,22 +53,30 @@ export const OwnedEntityPicker = (props: OwnedEntityPickerProps) => {
if (loading)
return (
<Autocomplete
loading={loading}
renderInput={params => (
<TextField
{...params}
label={title}
margin="dense"
helperText={description}
FormHelperTextProps={{ margin: 'dense', style: { marginLeft: 0 } }}
variant="outlined"
required={required}
InputProps={params.InputProps}
/>
)}
options={[]}
/>
<ScaffolderField
rawDescription={uiSchema['ui:description'] ?? description}
required={required}
disabled={uiSchema['ui:disabled']}
>
<Autocomplete
loading={loading}
renderInput={params => (
<TextField
{...params}
label={title}
margin="dense"
FormHelperTextProps={{
margin: 'dense',
style: { marginLeft: 0 },
}}
variant="outlined"
required={required}
InputProps={params.InputProps}
/>
)}
options={[]}
/>
</ScaffolderField>
);
const entityPickerUISchema = buildEntityPickerUISchema(
@@ -13,45 +13,45 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { makeFieldSchemaFromZod } from '../utils';
import { entityQueryFilterExpressionSchema } from '../EntityPicker/schema';
import { makeFieldSchema } from '@backstage/plugin-scaffolder-react';
/**
* @public
*/
export const OwnedEntityPickerFieldSchema = makeFieldSchemaFromZod(
z.string(),
z.object({
allowedKinds: z
.array(z.string())
.optional()
.describe(
'DEPRECATED: Use `catalogFilter` instead. List of kinds of entities to derive options from',
),
defaultKind: z
.string()
.optional()
.describe(
'The default entity kind. Options of this kind will not be prefixed.',
),
allowArbitraryValues: z
.boolean()
.optional()
.describe('Whether to allow arbitrary user input. Defaults to true'),
defaultNamespace: z
.union([z.string(), z.literal(false)])
.optional()
.describe(
'The default namespace. Options with this namespace will not be prefixed.',
),
catalogFilter: z
.array(entityQueryFilterExpressionSchema)
.or(entityQueryFilterExpressionSchema)
.optional()
.describe('List of key-value filter expression for entities'),
}),
);
export const OwnedEntityPickerFieldSchema = makeFieldSchema({
output: z => z.string(),
uiOptions: z =>
z.object({
allowedKinds: z
.array(z.string())
.optional()
.describe(
'DEPRECATED: Use `catalogFilter` instead. List of kinds of entities to derive options from',
),
defaultKind: z
.string()
.optional()
.describe(
'The default entity kind. Options of this kind will not be prefixed.',
),
allowArbitraryValues: z
.boolean()
.optional()
.describe('Whether to allow arbitrary user input. Defaults to true'),
defaultNamespace: z
.union([z.string(), z.literal(false)])
.optional()
.describe(
'The default namespace. Options with this namespace will not be prefixed.',
),
catalogFilter: z
.array(entityQueryFilterExpressionSchema)
.or(entityQueryFilterExpressionSchema)
.optional()
.describe('List of key-value filter expression for entities'),
}),
});
/**
* The input props that can be specified under `ui:options` for the
@@ -59,9 +59,10 @@ export const OwnedEntityPickerFieldSchema = makeFieldSchemaFromZod(
*
* @public
*/
export type OwnedEntityPickerUiOptions =
typeof OwnedEntityPickerFieldSchema.uiOptionsType;
export type OwnedEntityPickerUiOptions = NonNullable<
(typeof OwnedEntityPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
export type OwnedEntityPickerProps = typeof OwnedEntityPickerFieldSchema.type;
export type OwnedEntityPickerProps = typeof OwnedEntityPickerFieldSchema.TProps;
export const OwnedEntityPickerSchema = OwnedEntityPickerFieldSchema.schema;
@@ -13,43 +13,43 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { makeFieldSchemaFromZod } from '../utils';
import { entityQueryFilterExpressionSchema } from '../EntityPicker/schema';
import { makeFieldSchema } from '@backstage/plugin-scaffolder-react';
/**
* @public
*/
export const OwnerPickerFieldSchema = makeFieldSchemaFromZod(
z.string(),
z.object({
/**
* @deprecated Use `catalogFilter` instead.
*/
allowedKinds: z
.array(z.string())
.default(['Group', 'User'])
.optional()
.describe(
'DEPRECATED: Use `catalogFilter` instead. List of kinds of entities to derive options from. Defaults to Group and User',
),
allowArbitraryValues: z
.boolean()
.optional()
.describe('Whether to allow arbitrary user input. Defaults to true'),
defaultNamespace: z
.union([z.string(), z.literal(false)])
.optional()
.describe(
'The default namespace. Options with this namespace will not be prefixed.',
),
catalogFilter: z
.array(entityQueryFilterExpressionSchema)
.or(entityQueryFilterExpressionSchema)
.optional()
.describe('List of key-value filter expression for entities'),
}),
);
export const OwnerPickerFieldSchema = makeFieldSchema({
output: z => z.string(),
uiOptions: z =>
z.object({
/**
* @deprecated Use `catalogFilter` instead.
*/
allowedKinds: z
.array(z.string())
.default(['Group', 'User'])
.optional()
.describe(
'DEPRECATED: Use `catalogFilter` instead. List of kinds of entities to derive options from. Defaults to Group and User',
),
allowArbitraryValues: z
.boolean()
.optional()
.describe('Whether to allow arbitrary user input. Defaults to true'),
defaultNamespace: z
.union([z.string(), z.literal(false)])
.optional()
.describe(
'The default namespace. Options with this namespace will not be prefixed.',
),
catalogFilter: z
.array(entityQueryFilterExpressionSchema)
.or(entityQueryFilterExpressionSchema)
.optional()
.describe('List of key-value filter expression for entities'),
}),
});
/**
* The input props that can be specified under `ui:options` for the
@@ -57,7 +57,9 @@ export const OwnerPickerFieldSchema = makeFieldSchemaFromZod(
*
* @public
*/
export type OwnerPickerUiOptions = typeof OwnerPickerFieldSchema.uiOptionsType;
export type OwnerPickerUiOptions = NonNullable<
(typeof OwnerPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
export type OwnerPickerProps = typeof OwnerPickerFieldSchema.type;
@@ -29,9 +29,11 @@ import {
scaffolderApiRef,
useTemplateSecrets,
ScaffolderRJSFField,
ScaffolderRJSFFormProps as FormProps,
} from '@backstage/plugin-scaffolder-react';
import { act, fireEvent, screen } from '@testing-library/react';
import { RepoBranchPicker } from './RepoBranchPicker';
import { ComponentType, PropsWithChildren, ReactNode } from 'react';
describe('RepoBranchPicker', () => {
const mockIntegrationsApi: Partial<ScmIntegrationsApi> = {
@@ -333,4 +335,104 @@ describe('RepoBranchPicker', () => {
expect(getByText('abc123')).toBeInTheDocument();
});
});
describe('RepoBranchPicker description', () => {
const description = {
fromSchema: 'RepoBranchPicker description from schema',
fromUiSchema: 'RepoBranchPicker description from uiSchema',
} as { fromSchema: string; fromUiSchema: string };
let Wrapper: ComponentType<PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: ReactNode }) => {
return (
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, {}],
[scaffolderApiRef, {}],
]}
>
<SecretsContextProvider>{children}</SecretsContextProvider>
</TestApiProvider>
);
};
});
it('omits description', async () => {
const props = {
validator,
schema: { type: 'string' },
uiSchema: { 'ui:field': 'RepoBranchPicker' },
fields: {
RepoBranchPicker: RepoBranchPicker as ScaffolderRJSFField<string>,
},
formContext: {
formData: {},
},
} as unknown as FormProps<any>;
const { container } = await renderInTestApp(
<Wrapper>
<Form {...props} />
</Wrapper>,
);
expect(
container.getElementsByClassName('MuiTypography-body1'),
).toHaveLength(0);
});
it('presents schema description', async () => {
const props = {
validator,
schema: { type: 'string', description: description.fromSchema },
uiSchema: { 'ui:field': 'RepoBranchPicker' },
fields: {
RepoBranchPicker: RepoBranchPicker as ScaffolderRJSFField<string>,
},
formContext: {
formData: {},
},
} as unknown as FormProps<any>;
const { container, getByText, queryByText } = await renderInTestApp(
<Wrapper>
<Form {...props} />
</Wrapper>,
);
expect(
container.getElementsByClassName('MuiTypography-body1'),
).toHaveLength(1);
expect(getByText(description.fromSchema)).toBeInTheDocument();
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents uiSchema description', async () => {
const props = {
validator,
schema: { type: 'string', description: description.fromSchema },
uiSchema: {
'ui:field': 'RepoBranchPicker',
'ui:description': description.fromUiSchema,
},
fields: {
RepoBranchPicker: RepoBranchPicker as ScaffolderRJSFField<string>,
},
formContext: {
formData: {},
},
} as unknown as FormProps<any>;
const { container, getByText, queryByText } = await renderInTestApp(
<Wrapper>
<Form {...props} />
</Wrapper>,
);
expect(
container.getElementsByClassName('MuiTypography-body1'),
).toHaveLength(1);
expect(queryByText(description.fromSchema)).toBe(null);
expect(getByText(description.fromUiSchema)).toBeInTheDocument();
});
});
});
@@ -31,6 +31,7 @@ import { RepoBranchPickerState } from './types';
import { BitbucketRepoBranchPicker } from './BitbucketRepoBranchPicker';
import { DefaultRepoBranchPicker } from './DefaultRepoBranchPicker';
import { GitHubRepoBranchPicker } from './GitHubRepoBranchPicker';
import { MarkdownContent } from '@backstage/core-components';
/**
* The underlying component that is rendered in the form for the `RepoBranchPicker`
@@ -164,6 +165,8 @@ export const RepoBranchPicker = (props: RepoBranchPickerProps) => {
}
};
const description = uiSchema['ui:description'] ?? schema.description;
return (
<>
{schema.title && (
@@ -172,8 +175,10 @@ export const RepoBranchPicker = (props: RepoBranchPickerProps) => {
<Divider />
</Box>
)}
{schema.description && (
<Typography variant="body1">{schema.description}</Typography>
{description && (
<Typography variant="body1">
<MarkdownContent content={description} />
</Typography>
)}
{renderRepoBranchPicker()}
</>
@@ -13,58 +13,58 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { makeFieldSchemaFromZod } from '../utils';
import { makeFieldSchema } from '@backstage/plugin-scaffolder-react';
/**
* @public
*/
export const RepoBranchPickerFieldSchema = makeFieldSchemaFromZod(
z.string(),
z.object({
requestUserCredentials: z
.object({
secretsKey: z
.string()
.describe(
'Key used within the template secrets context to store the credential',
),
additionalScopes: z
.object({
gitea: z
.array(z.string())
.optional()
.describe('Additional Gitea scopes to request'),
gerrit: z
.array(z.string())
.optional()
.describe('Additional Gerrit scopes to request'),
github: z
.array(z.string())
.optional()
.describe('Additional GitHub scopes to request'),
gitlab: z
.array(z.string())
.optional()
.describe('Additional GitLab scopes to request'),
bitbucket: z
.array(z.string())
.optional()
.describe('Additional BitBucket scopes to request'),
azure: z
.array(z.string())
.optional()
.describe('Additional Azure scopes to request'),
})
.optional()
.describe('Additional permission scopes to request'),
})
.optional()
.describe(
'If defined will request user credentials to auth against the given SCM platform',
),
}),
);
export const RepoBranchPickerFieldSchema = makeFieldSchema({
output: z => z.string(),
uiOptions: z =>
z.object({
requestUserCredentials: z
.object({
secretsKey: z
.string()
.describe(
'Key used within the template secrets context to store the credential',
),
additionalScopes: z
.object({
gitea: z
.array(z.string())
.optional()
.describe('Additional Gitea scopes to request'),
gerrit: z
.array(z.string())
.optional()
.describe('Additional Gerrit scopes to request'),
github: z
.array(z.string())
.optional()
.describe('Additional GitHub scopes to request'),
gitlab: z
.array(z.string())
.optional()
.describe('Additional GitLab scopes to request'),
bitbucket: z
.array(z.string())
.optional()
.describe('Additional BitBucket scopes to request'),
azure: z
.array(z.string())
.optional()
.describe('Additional Azure scopes to request'),
})
.optional()
.describe('Additional permission scopes to request'),
})
.optional()
.describe(
'If defined will request user credentials to auth against the given SCM platform',
),
}),
});
/**
* The input props that can be specified under `ui:options` for the
@@ -72,10 +72,11 @@ export const RepoBranchPickerFieldSchema = makeFieldSchemaFromZod(
*
* @public
*/
export type RepoBranchPickerUiOptions =
typeof RepoBranchPickerFieldSchema.uiOptionsType;
export type RepoBranchPickerUiOptions = NonNullable<
(typeof RepoBranchPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
export type RepoBranchPickerProps = typeof RepoBranchPickerFieldSchema.type;
export type RepoBranchPickerProps = typeof RepoBranchPickerFieldSchema.TProps;
// This has been duplicated from /plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts
// NOTE: There is a bug with this failing validation in the custom field explorer due
@@ -30,9 +30,11 @@ import {
ScaffolderApi,
useTemplateSecrets,
ScaffolderRJSFField,
ScaffolderRJSFFormProps as FormProps,
} from '@backstage/plugin-scaffolder-react';
import { act, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComponentType, PropsWithChildren, ReactNode } from 'react';
describe('RepoUrlPicker', () => {
const mockScaffolderApi: Partial<ScaffolderApi> = {
@@ -417,4 +419,106 @@ describe('RepoUrlPicker', () => {
});
});
});
describe('RepoUrlPicker description', () => {
const description = {
fromSchema: 'RepoUrlPicker description from schema',
fromUiSchema: 'RepoUrlPicker description from uiSchema',
} as { fromSchema: string; fromUiSchema: string };
let Wrapper: ComponentType<PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: ReactNode }) => {
return (
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, {}],
[scaffolderApiRef, mockScaffolderApi],
]}
>
<SecretsContextProvider>{children}</SecretsContextProvider>
</TestApiProvider>
);
};
});
it('omits description', async () => {
const props = {
validator,
schema: { type: 'string' },
uiSchema: { 'ui:field': 'RepoUrlPicker' },
fields: {
RepoUrlPicker: RepoUrlPicker as ScaffolderRJSFField<string>,
},
formContext: {
formData: {},
},
} as unknown as FormProps<any>;
const { container } = await renderInTestApp(
<Wrapper>
<Form {...props} />
</Wrapper>,
);
expect(
container.getElementsByClassName('MuiTypography-body1'),
).toHaveLength(0);
});
it('presents schema description', async () => {
const props = {
validator,
schema: { type: 'string', description: description.fromSchema },
uiSchema: {
'ui:field': 'RepoUrlPicker',
},
fields: {
RepoUrlPicker: RepoUrlPicker as ScaffolderRJSFField<string>,
},
formContext: {
formData: {},
},
} as unknown as FormProps<any>;
const { container, getByText, queryByText } = await renderInTestApp(
<Wrapper>
<Form {...props} />
</Wrapper>,
);
expect(
container.getElementsByClassName('MuiTypography-body1'),
).toHaveLength(1);
expect(getByText(description.fromSchema)).toBeInTheDocument();
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents uiSchema description', async () => {
const props = {
validator,
schema: { type: 'string', description: description.fromSchema },
uiSchema: {
'ui:field': 'RepoUrlPicker',
'ui:description': description.fromUiSchema,
},
fields: {
RepoUrlPicker: RepoUrlPicker as ScaffolderRJSFField<string>,
},
formContext: {
formData: {},
},
} as unknown as FormProps<any>;
const { container, getByText, queryByText } = await renderInTestApp(
<Wrapper>
<Form {...props} />
</Wrapper>,
);
expect(
container.getElementsByClassName('MuiTypography-body1'),
).toHaveLength(1);
expect(queryByText(description.fromSchema)).toBe(null);
expect(getByText(description.fromUiSchema)).toBeInTheDocument();
});
});
});
@@ -35,6 +35,7 @@ import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName';
import { RepoUrlPickerFieldSchema } from './schema';
import { RepoUrlPickerState } from './types';
import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils';
import { MarkdownContent } from '@backstage/core-components';
export { RepoUrlPickerSchema } from './schema';
@@ -166,6 +167,9 @@ export const RepoUrlPicker = (
const hostType =
(state.host && integrationApi.byHost(state.host)?.type) ?? null;
const description = uiSchema['ui:description'] ?? schema.description;
return (
<>
{schema.title && (
@@ -174,8 +178,10 @@ export const RepoUrlPicker = (
<Divider />
</Box>
)}
{schema.description && (
<Typography variant="body1">{schema.description}</Typography>
{description && (
<Typography variant="body1">
<MarkdownContent content={description} />
</Typography>
)}
<RepoUrlPickerHost
host={state.host}
@@ -94,10 +94,11 @@ export const RepoUrlPickerFieldSchema = makeFieldSchema({
* @public
* @deprecated this will be removed as it's no longer used
*/
export type RepoUrlPickerUiOptions =
typeof RepoUrlPickerFieldSchema.uiOptionsType;
export type RepoUrlPickerUiOptions = NonNullable<
(typeof RepoUrlPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
export type RepoUrlPickerProps = typeof RepoUrlPickerFieldSchema.type;
export type RepoUrlPickerProps = typeof RepoUrlPickerFieldSchema.TProps;
// This has been duplicated to /plugins/scaffolder/src/components/fields/RepoBranchPicker/schema.ts
// NOTE: There is a bug with this failing validation in the custom field explorer due
@@ -19,6 +19,7 @@ import {
} from '@backstage/plugin-scaffolder-react';
import { SecretInput } from './SecretInput';
import { renderInTestApp } from '@backstage/test-utils';
import { ScaffolderRJSFFormProps as FormProps } from '@backstage/plugin-scaffolder-react';
import { Form } from '@backstage/plugin-scaffolder-react/alpha';
import validator from '@rjsf/validator-ajv8';
import { fireEvent, act, waitFor } from '@testing-library/react';
@@ -73,4 +74,92 @@ describe('<SecretInput />', () => {
{ timeout: 500 },
);
});
describe('SecretInput description', () => {
const description = {
fromSchema: 'MyGroupsPicker description from schema',
fromUiSchema: 'MyGroupsPicker description from uiSchema',
} as { fromSchema: string; fromUiSchema: string };
it('omits description', async () => {
const props = {
validator,
schema: {
properties: { myKey: { type: 'string', title: 'secret' } },
},
uiSchema: {
myKey: {
'ui:field': 'Secret',
},
},
fields: {
Secret: SecretInput,
},
} as unknown as FormProps<any>;
const { queryByText } = await renderInTestApp(
<SecretsContextProvider>
<Form {...props} />
<SecretsComponent />
</SecretsContextProvider>,
);
expect(queryByText(description.fromSchema)).toBe(null);
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents schema description', async () => {
const props = {
validator,
schema: {
properties: { myKey: { type: 'string', title: 'secret' } },
description: description.fromSchema,
},
uiSchema: {
myKey: {
'ui:field': 'Secret',
},
},
fields: {
Secret: SecretInput,
},
} as unknown as FormProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<SecretsContextProvider>
<Form {...props} />
<SecretsComponent />
</SecretsContextProvider>,
);
expect(getByText(description.fromSchema)).toBeInTheDocument();
expect(queryByText(description.fromUiSchema)).toBe(null);
});
it('presents uiSchema description', async () => {
const props = {
validator,
schema: {
properties: { myKey: { type: 'string', title: 'secret' } },
description: description.fromSchema,
},
uiSchema: {
myKey: {
'ui:field': 'Secret',
},
'ui:description': description.fromUiSchema,
},
fields: {
Secret: SecretInput,
},
} as unknown as FormProps<any>;
const { getByText, queryByText } = await renderInTestApp(
<SecretsContextProvider>
<Form {...props} />
<SecretsComponent />
</SecretsContextProvider>,
);
expect(queryByText(description.fromSchema)).toBe(null);
expect(getByText(description.fromUiSchema)).toBeInTheDocument();
});
});
});
@@ -26,12 +26,13 @@ export const SecretInput = (props: ScaffolderRJSFFieldProps) => {
disabled,
errors,
required,
uiSchema,
} = props;
return (
<ScaffolderField
rawErrors={rawErrors}
rawDescription={description}
rawDescription={uiSchema['ui:description'] ?? description}
disabled={disabled}
errors={errors}
required={required}
+4
View File
@@ -51,6 +51,10 @@ export const scaffolderTranslationRef = createTranslationRef({
description:
"Add any relevant tags, hit 'Enter' to add new tags. Valid format: [a-z0-9+#] separated by [-], at most 63 characters",
},
multiEntityPicker: {
title: 'Entity',
description: 'An entity from the catalog',
},
myGroupsPicker: {
title: 'Entity',
description: 'An entity from the catalog',