diff --git a/.changeset/violet-breads-obey.md b/.changeset/violet-breads-obey.md new file mode 100644 index 0000000000..f892e91ee2 --- /dev/null +++ b/.changeset/violet-breads-obey.md @@ -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. diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index de088224b8..69fcec11e6 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -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'; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index b929d7be5b..b44889cded 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -71,7 +71,7 @@ export type CustomFieldValidator = // @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; // @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 }; diff --git a/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts index 27e33befb5..fe4765b8b8 100644 --- a/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts @@ -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; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx index 2098e3e07d..97c90b2aa5 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx @@ -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('', () => { 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 {children}; + }; + }); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + expect(queryByText(description.default!)).toBe(null); + expect(queryByText(description.fromSchema)).toBe(null); + expect(getByText(description.fromUiSchema)).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx index 26f88ea85d..d2197d4426 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -185,7 +185,7 @@ export const EntityPicker = (props: EntityPickerProps) => { return ( 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 >; diff --git a/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.test.tsx b/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.test.tsx new file mode 100644 index 0000000000..98091f5098 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.test.tsx @@ -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('', () => { + 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; + + const catalogApi = catalogApiMock.mock({ + getEntities: jest.fn(async () => ({ items: entities })), + }); + + let Wrapper: ComponentType>; + + beforeEach(() => { + Wrapper = ({ children }: { children?: ReactNode }) => ( + + {children} + + ); + }); + + 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 {children}; + }; + }); + it('presents default description', async () => { + props = { + onChange, + schema, + required: true, + uiSchema, + rawErrors, + formData, + } as unknown as FieldProps; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + expect(queryByText(description.default!)).toBe(null); + expect(queryByText(description.fromSchema)).toBe(null); + expect(getByText(description.fromUiSchema)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx b/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx index 3cfa16de23..7d63526fe4 100644 --- a/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx @@ -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([]); 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 ( - + { renderInput={params => ( setInputValue(e.target.value)} error={inputError} - helperText={helperText ?? t('fields.entityTagsPicker.description')} FormHelperTextProps={{ margin: 'dense', style: { marginLeft: 0 } }} /> )} /> - + ); }; diff --git a/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts index 6677d16e98..d1a300f5c8 100644 --- a/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts @@ -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'] +>; diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx index eb80d47e78..acae3418b9 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx @@ -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('', () => { 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('', () => { ]); }); }); + + 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 {children}; + }; + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + expect(queryByText(description.default!)).toBe(null); + expect(queryByText(description.fromSchema)).toBe(null); + expect(getByText(description.fromUiSchema)).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx index e6a535af41..e7dcf0f996 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx @@ -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 ( - 0 && !formData} + disabled={isDisabled} + errors={errors} > { 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} /> - + ); }; diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts index 2621157d02..667b4638b8 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts @@ -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 >; diff --git a/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx index 6e0a3b81bc..dcba013093 100644 --- a/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx @@ -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('', () => { onChange, schema, required, + uiSchema: {}, } as unknown as FieldProps; await renderInTestApp( @@ -175,6 +179,7 @@ describe('', () => { onChange, schema, required, + uiSchema: {}, } as unknown as FieldProps; const { queryByText, getByRole } = await renderInTestApp( @@ -236,6 +241,7 @@ describe('', () => { onChange, schema, required, + uiSchema: {}, } as unknown as FieldProps; const { getByRole } = await renderInTestApp( @@ -299,6 +305,7 @@ describe('', () => { onChange, schema, required, + uiSchema: {}, formData: 'group:default/group1', } as unknown as FieldProps; @@ -327,4 +334,99 @@ describe('', () => { 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>; + + beforeEach(() => { + Wrapper = ({ children }: { children?: ReactNode }) => { + const { t } = useTranslationRef(scaffolderTranslationRef); + description.default = t('fields.myGroupsPicker.description'); + return ( + + {children} + + ); + }; + }); + it('presents default description', async () => { + const props = { + onChange, + schema, + required: true, + uiSchema: {}, + formData: 'group:default/group1', + } as unknown as FieldProps; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + , + ); + expect(queryByText(description.default!)).toBe(null); + expect(queryByText(description.fromSchema)).toBe(null); + expect(getByText(description.fromUiSchema)).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.tsx b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.tsx index b49a1a1531..e92057c713 100644 --- a/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.tsx +++ b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.tsx @@ -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 ( - 0} + disabled={isDisabled} + errors={errors} > { {...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} /> - + ); }; diff --git a/plugins/scaffolder/src/components/fields/MyGroupsPicker/schema.ts b/plugins/scaffolder/src/components/fields/MyGroupsPicker/schema.ts index d5f8af7c56..6ccda91afb 100644 --- a/plugins/scaffolder/src/components/fields/MyGroupsPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/MyGroupsPicker/schema.ts @@ -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; diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx index a7c29c081e..0651066cfb 100644 --- a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx @@ -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 ( - ( - - )} - options={[]} - /> + + ( + + )} + options={[]} + /> + ); const entityPickerUISchema = buildEntityPickerUISchema( diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts index 2bb75db263..ad5ee34ce1 100644 --- a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts @@ -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; diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts b/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts index 0dbe2fa74f..3bcb63c91f 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts @@ -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; diff --git a/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.test.tsx index ba1b9642fd..2d479d346a 100644 --- a/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.test.tsx @@ -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 = { @@ -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>; + + beforeEach(() => { + Wrapper = ({ children }: { children?: ReactNode }) => { + return ( + + {children} + + ); + }; + }); + it('omits description', async () => { + const props = { + validator, + schema: { type: 'string' }, + uiSchema: { 'ui:field': 'RepoBranchPicker' }, + fields: { + RepoBranchPicker: RepoBranchPicker as ScaffolderRJSFField, + }, + formContext: { + formData: {}, + }, + } as unknown as FormProps; + + const { container } = await renderInTestApp( + +
+ , + ); + 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, + }, + formContext: { + formData: {}, + }, + } as unknown as FormProps; + + const { container, getByText, queryByText } = await renderInTestApp( + + + , + ); + 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, + }, + formContext: { + formData: {}, + }, + } as unknown as FormProps; + + const { container, getByText, queryByText } = await renderInTestApp( + + + , + ); + expect( + container.getElementsByClassName('MuiTypography-body1'), + ).toHaveLength(1); + expect(queryByText(description.fromSchema)).toBe(null); + expect(getByText(description.fromUiSchema)).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.tsx b/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.tsx index f81091f115..34f968d0de 100644 --- a/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.tsx @@ -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) => { )} - {schema.description && ( - {schema.description} + {description && ( + + + )} {renderRepoBranchPicker()} diff --git a/plugins/scaffolder/src/components/fields/RepoBranchPicker/schema.ts b/plugins/scaffolder/src/components/fields/RepoBranchPicker/schema.ts index fae5e6435e..62a3b66e14 100644 --- a/plugins/scaffolder/src/components/fields/RepoBranchPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/RepoBranchPicker/schema.ts @@ -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 diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index c5d88275f6..f23d8356ac 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -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 = { @@ -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>; + + beforeEach(() => { + Wrapper = ({ children }: { children?: ReactNode }) => { + return ( + + {children} + + ); + }; + }); + it('omits description', async () => { + const props = { + validator, + schema: { type: 'string' }, + uiSchema: { 'ui:field': 'RepoUrlPicker' }, + fields: { + RepoUrlPicker: RepoUrlPicker as ScaffolderRJSFField, + }, + formContext: { + formData: {}, + }, + } as unknown as FormProps; + + const { container } = await renderInTestApp( + + + , + ); + 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, + }, + formContext: { + formData: {}, + }, + } as unknown as FormProps; + + const { container, getByText, queryByText } = await renderInTestApp( + + + , + ); + 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, + }, + formContext: { + formData: {}, + }, + } as unknown as FormProps; + + const { container, getByText, queryByText } = await renderInTestApp( + + + , + ); + expect( + container.getElementsByClassName('MuiTypography-body1'), + ).toHaveLength(1); + expect(queryByText(description.fromSchema)).toBe(null); + expect(getByText(description.fromUiSchema)).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 953b607b07..8f1e3a67b7 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -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 = ( )} - {schema.description && ( - {schema.description} + {description && ( + + + )} ; -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 diff --git a/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.test.tsx b/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.test.tsx index f1619aacba..d4ddaea537 100644 --- a/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.test.tsx +++ b/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.test.tsx @@ -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('', () => { { 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; + + const { queryByText } = await renderInTestApp( + + + + , + ); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + + , + ); + 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; + + const { getByText, queryByText } = await renderInTestApp( + + + + , + ); + expect(queryByText(description.fromSchema)).toBe(null); + expect(getByText(description.fromUiSchema)).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.tsx b/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.tsx index d3bfd09502..b21356d6a8 100644 --- a/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.tsx +++ b/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.tsx @@ -26,12 +26,13 @@ export const SecretInput = (props: ScaffolderRJSFFieldProps) => { disabled, errors, required, + uiSchema, } = props; return (