diff --git a/.changeset/brave-foxes-dance.md b/.changeset/brave-foxes-dance.md new file mode 100644 index 0000000000..b395f0e935 --- /dev/null +++ b/.changeset/brave-foxes-dance.md @@ -0,0 +1,22 @@ +--- +'@backstage/plugin-scaffolder-react': minor +'@backstage/plugin-scaffolder': minor +--- + +Added experimental BUI (Backstage UI) form theme for scaffolder forms. All default field extensions render BUI variants when enabled. + +**Extension config:** + +```yaml +app: + extensions: + - sub-page:scaffolder/templates: + config: + enableBackstageUi: true +``` + +**JSX props:** + +```tsx + +``` diff --git a/app-config.yaml b/app-config.yaml index 8c93e85f68..e321615ccf 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -70,6 +70,11 @@ app: - nav-item:scaffolder - nav-item:app-visualizer + # Opt in to the experimental BUI scaffolder form theme + - sub-page:scaffolder/templates: + config: + enableBackstageUi: true + # Pages - page:catalog/entity: config: diff --git a/plugins/scaffolder-backend/sample-templates/all-templates.yaml b/plugins/scaffolder-backend/sample-templates/all-templates.yaml index 859bd717aa..3eef3b0a51 100644 --- a/plugins/scaffolder-backend/sample-templates/all-templates.yaml +++ b/plugins/scaffolder-backend/sample-templates/all-templates.yaml @@ -8,6 +8,7 @@ spec: - ./remote-templates.yaml - ./notifications-demo/template.yaml - ./conditional-output-demo/template.yaml + - ./bui-kitchen-sink-demo/template.yaml # For local development of a template, you can reference your local templates here. # Examples: # diff --git a/plugins/scaffolder-backend/sample-templates/bui-kitchen-sink-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/bui-kitchen-sink-demo/template.yaml new file mode 100644 index 0000000000..3acedd2b8e --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/bui-kitchen-sink-demo/template.yaml @@ -0,0 +1,273 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: bui-kitchen-sink-demo + title: BUI Kitchen Sink Demo + description: Exercises every widget and field extension in the BUI scaffolder form theme +spec: + owner: backstage/techdocs-core + type: service + + parameters: + - title: Basic widgets + required: + - name + - email + properties: + name: + title: Name + type: string + description: Plain text input via BaseInputTemplate + ui:autofocus: true + description: + title: Description + type: string + description: Multiline textarea + ui:widget: textarea + ui:options: + rows: 3 + email: + title: Email + type: string + format: email + description: Uses the email widget + website: + title: Website + type: string + format: uri + description: Uses the url widget + password: + title: Password + type: string + ui:widget: password + description: Uses the password widget + favouriteColour: + title: Favourite colour + type: string + ui:widget: color + description: Uses the color widget + secret: + title: Hidden token + type: string + ui:widget: hidden + default: hidden-default-value + + - title: Numbers, ranges and toggles + properties: + replicas: + title: Replicas + type: integer + minimum: 1 + maximum: 10 + default: 3 + description: Plain integer input + confidence: + title: Confidence + type: number + minimum: 0 + maximum: 100 + default: 50 + ui:widget: range + description: Uses the range widget + enabled: + title: Enabled + type: boolean + default: true + description: Single boolean checkbox + consent: + title: Consent + type: boolean + default: false + ui:widget: radio + oneOf: + - const: true + title: I agree + - const: false + title: I disagree + + - title: Selects and groups + properties: + environment: + title: Environment + type: string + enum: + - development + - staging + - production + default: development + description: Single-select dropdown + region: + title: Region + type: string + enum: + - eu-west-1 + - us-east-1 + - ap-southeast-2 + ui:widget: radio + description: Radio group via oneOf + features: + title: Features + type: array + uniqueItems: true + items: + type: string + enum: + - logging + - metrics + - tracing + - profiling + ui:widget: checkboxes + description: Checkbox group via checkboxes widget + tags: + title: Free-form tags + type: array + items: + type: string + description: Default array input + + - title: Dates and files + properties: + startDate: + title: Start date + type: string + format: date + description: Uses the date widget + startDateTime: + title: Start time + type: string + format: date-time + description: Uses the date-time widget + cutoffTime: + title: Cutoff time + type: string + format: time + description: Uses the time widget + readme: + title: Readme upload + type: string + format: data-url + description: Uses the file widget + + - title: Nested objects and arrays + properties: + owner: + title: Owner profile + type: object + required: + - displayName + properties: + displayName: + title: Display name + type: string + slack: + title: Slack handle + type: string + contacts: + title: Contacts + type: array + items: + type: object + required: + - name + properties: + name: + title: Name + type: string + role: + title: Role + type: string + enum: + - PM + - Engineer + - Designer + primary: + title: Primary contact + type: boolean + + - title: Catalog and repo pickers + properties: + componentName: + title: Component name + type: string + ui:field: EntityNamePicker + ownerRef: + title: Owner + type: string + ui:field: OwnerPicker + ui:options: + catalogFilter: + kind: [User, Group] + relatedEntity: + title: Related entity + type: string + ui:field: EntityPicker + ui:options: + catalogFilter: + kind: [Component] + relatedEntities: + title: Related entities (multi) + type: array + ui:field: MultiEntityPicker + ui:options: + catalogFilter: + kind: [Component] + myGroups: + title: My groups + type: string + ui:field: MyGroupsPicker + ownedEntity: + title: An entity I own + type: string + ui:field: OwnedEntityPicker + ui:options: + catalogFilter: + kind: [Component] + entityTags: + title: Entity tags + type: array + ui:field: EntityTagsPicker + repoUrl: + title: Repository URL + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com + - gitlab.com + repoOwner: + title: Repo owner + type: string + ui:field: RepoOwnerPicker + ui:options: + allowedOwners: + - backstage + - example-org + repoBranch: + title: Repo branch + type: string + ui:field: RepoBranchPicker + deployToken: + title: Deploy token + type: string + ui:field: Secret + description: Captured to the scaffolder secret store + + steps: + - id: log + name: Log + action: debug:log + input: + message: | + Submitted kitchen sink for ${{ parameters.name }}. + Environment: ${{ parameters.environment }} + Owner: ${{ parameters.ownerRef }} + Repo: ${{ parameters.repoUrl }} + + output: + text: + - title: Summary + content: | + **Name:** ${{ parameters.name }} + **Environment:** ${{ parameters.environment }} + **Region:** ${{ parameters.region }} + **Features:** ${{ parameters.features }} diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index eb351d007a..f24bed8c73 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -72,11 +72,13 @@ "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", + "@backstage/ui": "workspace:^", "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@react-hookz/web": "^24.0.0", + "@remixicon/react": "^4.6.0", "@rjsf/core": "5.24.13", "@rjsf/material-ui": "5.24.13", "@rjsf/utils": "5.24.13", @@ -93,6 +95,7 @@ "lodash": "^4.17.21", "luxon": "^3.0.0", "qs": "^6.9.4", + "react-aria-components": "^1.14.0", "react-use": "^17.2.4", "use-immer": "^0.11.0", "zen-observable": "^0.10.0", diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 23b978ead8..11c1a2a0e6 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -125,7 +125,9 @@ export const extractSchemaFromStep: (inputStep: JsonObject) => { // @alpha export const Form: ( - props: PropsWithChildren, + props: PropsWithChildren< + ScaffolderRJSFFormProps & Pick + >, ) => JSX_2.Element; // @public @@ -332,6 +334,9 @@ export const scaffolderReactTranslationRef: TranslationRef< } >; +// @alpha (undocumented) +export type ScaffolderTheme = 'mui' | 'bui'; + // @alpha export const SecretWidget: ( props: Pick< @@ -462,6 +467,9 @@ export const useFormDataFromQuery: ( initialState?: Record, ) => [Record, Dispatch>>]; +// @alpha (undocumented) +export const useScaffolderTheme: () => ScaffolderTheme; + // @alpha (undocumented) export const useTemplateParameterSchema: (templateRef: string) => { manifest?: TemplateParameterSchema; diff --git a/plugins/scaffolder-react/report.api.md b/plugins/scaffolder-react/report.api.md index 9b9b00ad02..4d19f18616 100644 --- a/plugins/scaffolder-react/report.api.md +++ b/plugins/scaffolder-react/report.api.md @@ -154,7 +154,9 @@ export type FormProps = Pick< | 'formContext' | 'omitExtraData' | 'liveOmit' ->; +> & { + EXPERIMENTAL_theme?: 'mui' | 'bui'; +}; // @public export type LayoutComponent<_TInputProps> = () => null; diff --git a/plugins/scaffolder-react/src/components/types.ts b/plugins/scaffolder-react/src/components/types.ts index 3df6209cad..e866459e7d 100644 --- a/plugins/scaffolder-react/src/components/types.ts +++ b/plugins/scaffolder-react/src/components/types.ts @@ -38,7 +38,9 @@ export type FormProps = Pick< | 'formContext' | 'omitExtraData' | 'liveOmit' ->; +> & { + EXPERIMENTAL_theme?: 'mui' | 'bui'; +}; /** * The props for the Last Step in scaffolder template form. diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/Form.module.css b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/Form.module.css new file mode 100644 index 0000000000..eb7683bb88 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/Form.module.css @@ -0,0 +1,34 @@ +/* + * Copyright 2026 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. + */ + +/* BUI inputs ship in a "filled" style that picks a neutral one step above the + * surface they sit on. Scaffolder forms live on the same neutral-1 surface as + * their inputs, so the bg step-up either reads as disabled or disappears + * entirely. Override to the outlined style PasswordField already uses: rest + * border, focus/invalid borders preserved from BUI defaults. */ + +@layer components { + .form :global(.bui-Input:not([data-focused]):not([data-invalid])), + .form + :global(.bui-ComboboxInput:not([data-focus-within]):not([data-invalid])) { + box-shadow: inset 0 0 0 1px var(--bui-border-2); + } + + .form + :global(.bui-SelectTrigger:not([data-focus-visible]):not([data-invalid])) { + box-shadow: inset 0 0 0 1px var(--bui-border-2); + } +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/index.ts b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/index.ts new file mode 100644 index 0000000000..32131e4977 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2022 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 { FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils'; +import { ThemeProps } from '@rjsf/core'; + +import { generateBuiTemplates } from './templates'; +import { generateBuiWidgets } from './widgets'; + +export function generateBuiTheme< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(): ThemeProps { + return { + templates: generateBuiTemplates(), + widgets: generateBuiWidgets(), + }; +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldDescriptionTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldDescriptionTemplate.tsx new file mode 100644 index 0000000000..9751ffb0f8 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldDescriptionTemplate.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2026 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 { isValidElement } from 'react'; +import { + ArrayFieldDescriptionProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import { MarkdownContent } from '@backstage/core-components'; + +export default function ArrayFieldDescriptionTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: ArrayFieldDescriptionProps) { + const { description } = props; + if (!description) { + return null; + } + + if (isValidElement(description)) { + return <>{description}; + } + + return ; +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldItemTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldItemTemplate.tsx new file mode 100644 index 0000000000..157c5e64ee --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldItemTemplate.tsx @@ -0,0 +1,161 @@ +/* + * Copyright 2026 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 { + ArrayFieldTemplateItemType, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import { useState } from 'react'; +import { + Card, + CardHeader, + CardBody, + Flex, + Text, + ButtonIcon, + Box, + Button, +} from '@backstage/ui'; +import { Disclosure, DisclosurePanel } from 'react-aria-components'; +import { + RiArrowDownSLine, + RiArrowUpSLine, + RiDeleteBinLine, + RiAddLine, + RiArrowRightSLine, +} from '@remixicon/react'; + +export default function ArrayFieldItemTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + children, + disabled, + hasToolbar, + hasCopy, + hasMoveDown, + hasMoveUp, + hasRemove, + index, + onCopyIndexClick, + onDropIndexClick, + onReorderClick, + readonly, + schema, +}: ArrayFieldTemplateItemType) { + const [isOpen, setIsOpen] = useState(true); + + const itemSchema = schema?.items as S | undefined; + const itemTitle = itemSchema?.title + ? `${itemSchema.title} ${index + 1}` + : `Item ${index + 1}`; + + return ( + + + + {({ isExpanded }) => ( + <> + + + + + + + {itemTitle} + + + {hasToolbar && ( + + {(hasMoveUp || hasMoveDown) && ( + <> + } + /> + } + /> + + )} + {hasCopy && ( + } + /> + )} + {hasRemove && ( + } + /> + )} + + )} + + + + + + {children} + + + + )} + + + + ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldTemplate.tsx new file mode 100644 index 0000000000..7406a57048 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldTemplate.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 2026 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 { + ArrayFieldTemplateProps, + ArrayFieldTemplateItemType, + FormContextType, + getTemplate, + getUiOptions, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import { Box, Text } from '@backstage/ui'; + +export default function ArrayFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + canAdd, + disabled, + idSchema, + uiSchema, + items, + onAddClick, + readonly, + registry, + required, + schema, + title, +}: ArrayFieldTemplateProps) { + const uiOptions = getUiOptions(uiSchema); + const ArrayFieldDescriptionTemplate = getTemplate< + 'ArrayFieldDescriptionTemplate', + T, + S, + F + >('ArrayFieldDescriptionTemplate', registry, uiOptions); + const ArrayFieldItemTemplate = getTemplate< + 'ArrayFieldItemTemplate', + T, + S, + F + >('ArrayFieldItemTemplate', registry, uiOptions); + const ArrayFieldTitleTemplate = getTemplate< + 'ArrayFieldTitleTemplate', + T, + S, + F + >('ArrayFieldTitleTemplate', registry, uiOptions); + const { + ButtonTemplates: { AddButton }, + } = registry.templates; + + const hasTitle = uiOptions.title || title; + const hasDescription = uiOptions.description || schema.description; + + return ( + + {hasTitle && ( + + + + )} + {hasDescription && ( + + + + )} + {items && items.length > 0 ? ( + items.map( + ({ key, ...itemProps }: ArrayFieldTemplateItemType) => ( + + ), + ) + ) : ( + + + No items added yet + + + )} + {canAdd && ( + + + + )} + + ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldTitleTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldTitleTemplate.tsx new file mode 100644 index 0000000000..ce0242f7b0 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ArrayFieldTitleTemplate.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2026 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 { + ArrayFieldTitleProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, + titleId, +} from '@rjsf/utils'; +import { Text } from '@backstage/ui'; + +export default function ArrayFieldTitleTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ idSchema, title, required }: ArrayFieldTitleProps) { + if (!title) { + return null; + } + + const id = titleId(idSchema); + + return ( + + {title} + {required && ( + + {' '} + * + + )} + + ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ButtonTemplates.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ButtonTemplates.tsx new file mode 100644 index 0000000000..1a5ae35a0a --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ButtonTemplates.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 2026 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 { + FormContextType, + IconButtonProps, + RJSFSchema, + StrictRJSFSchema, + SubmitButtonProps, + TranslatableString, + getSubmitButtonOptions, +} from '@rjsf/utils'; +import { Button } from '@backstage/ui'; +import { + RiAddLine, + RiArrowUpLine, + RiArrowDownLine, + RiDeleteBinLine, + RiFileCopyLine, +} from '@remixicon/react'; + +export function SubmitButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ uiSchema }: SubmitButtonProps) { + const { + submitText, + norender, + props: submitButtonProps = {}, + } = getSubmitButtonOptions(uiSchema); + + if (norender) { + return null; + } + + return ( + + ); +} + +export function AddButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ registry, disabled, onClick, className }: IconButtonProps) { + const { translateString } = registry; + + const handleClick = onClick ? (e: any) => onClick(e) : undefined; + + return ( + + ); +} + +export function IconButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: IconButtonProps) { + const { icon, registry, disabled, onClick, className } = props; + const { translateString } = registry; + + let buttonIcon; + let buttonLabel; + + switch (icon) { + case 'arrow-up': + buttonIcon = ; + buttonLabel = translateString(TranslatableString.MoveUpButton); + break; + case 'arrow-down': + buttonIcon = ; + buttonLabel = translateString(TranslatableString.MoveDownButton); + break; + case 'remove': + buttonIcon = ; + buttonLabel = translateString(TranslatableString.RemoveButton); + break; + case 'copy': + buttonIcon = ; + buttonLabel = translateString(TranslatableString.CopyButton); + break; + default: + buttonIcon = null; + buttonLabel = ''; + } + + const handleClick = onClick ? () => onClick(undefined as any) : undefined; + + return ( + + ); +} + +const ButtonTemplates = { + SubmitButton, + AddButton, + RemoveButton: IconButton, + MoveDownButton: IconButton, + MoveUpButton: IconButton, + CopyButton: IconButton, +}; + +export default ButtonTemplates; diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/DescriptionFieldTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/DescriptionFieldTemplate.tsx new file mode 100644 index 0000000000..0628d50914 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/DescriptionFieldTemplate.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2022 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 { isValidElement } from 'react'; +import { + DescriptionFieldProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import { MarkdownContent } from '@backstage/core-components'; + +export default function DescriptionFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ id, description }: DescriptionFieldProps) { + if (!description) { + return null; + } + + return ( +
+ {isValidElement(description) ? ( + description + ) : ( + + )} +
+ ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ErrorListTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ErrorListTemplate.tsx new file mode 100644 index 0000000000..9f9d10a66a --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ErrorListTemplate.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2026 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 { + ErrorListProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, + TranslatableString, +} from '@rjsf/utils'; +import { Box, Text } from '@backstage/ui'; + +export default function ErrorListTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ errors, registry }: ErrorListProps) { + const { translateString } = registry; + + if (errors.length === 0) { + return null; + } + + return ( + + + {translateString(TranslatableString.ErrorsLabel)} + + + {errors.map((error, i) => ( + + + {error.stack} + + + ))} + + + ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldErrorTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldErrorTemplate.tsx new file mode 100644 index 0000000000..fcce513dac --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldErrorTemplate.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2026 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 { + errorId, + FieldErrorProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import { Text } from '@backstage/ui'; + +export default function FieldErrorTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ errors = [], idSchema }: FieldErrorProps) { + if (errors.length === 0) { + return null; + } + + const id = errorId(idSchema); + + return ( +
+ {errors.map((error, i) => ( + + {error} + + ))} +
+ ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldHelpTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldHelpTemplate.tsx new file mode 100644 index 0000000000..66c2343dfd --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldHelpTemplate.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2026 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 { isValidElement } from 'react'; +import { + FieldHelpProps, + FormContextType, + helpId, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import { Text } from '@backstage/ui'; + +export default function FieldHelpTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ idSchema, help }: FieldHelpProps) { + if (!help) { + return null; + } + + const id = helpId(idSchema); + + return ( +
+ {isValidElement(help) ? ( + help + ) : ( + + {help} + + )} +
+ ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldTemplate.test.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldTemplate.test.tsx new file mode 100644 index 0000000000..1beac36bb4 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldTemplate.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright 2022 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 { render } from '@testing-library/react'; +import validator from '@rjsf/validator-ajv8'; +import { Form } from '../../Form'; + + +describe('BUI FieldTemplate', () => { + it('should render the field label', () => { + const { getByText } = render( +
, + ); + + expect(getByText('Full Name')).toBeInTheDocument(); + }); + + it('should show Required when field is required', () => { + const { getByText } = render( + , + ); + + expect(getByText(/Required/)).toBeInTheDocument(); + }); + + it('should render the description as markdown', () => { + const { container } = render( + , + ); + + const strong = container.querySelector('strong'); + expect(strong).toBeInTheDocument(); + expect(strong?.textContent).toBe('full'); + }); + + it('should hide the field when ui:widget is hidden', () => { + const { container } = render( + , + ); + + // Hidden fields should not have a visible input + const inputs = container.querySelectorAll('input[type="text"]'); + expect(inputs.length).toBe(0); + }); + + it('should render validation errors', () => { + const { getAllByText } = render( + , + ); + + // The error shows up both in the top-level ErrorListTemplate and inline via FieldErrorTemplate + const errorMessages = getAllByText( + /must NOT have fewer than 3 characters/i, + ); + expect(errorMessages.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldTemplate.tsx new file mode 100644 index 0000000000..ac9452ef46 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/FieldTemplate.tsx @@ -0,0 +1,94 @@ +/* + * Copyright 2022 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 { + FieldTemplateProps, + FormContextType, + getTemplate, + getUiOptions, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import { Box } from '@backstage/ui'; +import { ScaffolderField } from '../../../ScaffolderField'; + +export default function FieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: FieldTemplateProps) { + const { + id, + children, + classNames, + style, + disabled, + displayLabel, + hidden, + label, + onDropPropertyClick, + onKeyChange, + readonly, + registry, + required, + rawErrors = [], + errors, + help, + rawDescription, + schema, + uiSchema, + } = props; + + const uiOptions = getUiOptions(uiSchema); + const WrapIfAdditionalTemplate = getTemplate< + 'WrapIfAdditionalTemplate', + T, + S, + F + >('WrapIfAdditionalTemplate', registry, uiOptions); + + if (hidden) { + return {children}; + } + + return ( + + + {children} + + + ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ObjectFieldTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ObjectFieldTemplate.tsx new file mode 100644 index 0000000000..214d405118 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/ObjectFieldTemplate.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2022 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 { + canExpand, + FormContextType, + getTemplate, + getUiOptions, + ObjectFieldTemplateProps, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import { Box } from '@backstage/ui'; + +export default function ObjectFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + description, + title, + properties, + required, + disabled, + readonly, + uiSchema, + idSchema, + schema, + formData, + onAddClick, + registry, +}: ObjectFieldTemplateProps) { + const uiOptions = getUiOptions(uiSchema); + const TitleFieldTemplate = getTemplate<'TitleFieldTemplate', T, S, F>( + 'TitleFieldTemplate', + registry, + uiOptions, + ); + const DescriptionFieldTemplate = getTemplate< + 'DescriptionFieldTemplate', + T, + S, + F + >('DescriptionFieldTemplate', registry, uiOptions); + const { + ButtonTemplates: { AddButton }, + } = registry.templates; + + const isNested = + idSchema.$id !== 'root' && + (idSchema.$id.includes('.') || idSchema.$id.includes('_')); + + const isRoot = idSchema.$id === 'root'; + const hasTitle = !isRoot && (uiOptions.title || title); + const hasDescription = !isRoot && (uiOptions.description || description); + + const isAutoGeneratedTitle = title && /^.+-\d+$/.test(title); + + if (isNested || isAutoGeneratedTitle) { + return ( + + {properties.map(element => element.content)} + {canExpand(schema, uiSchema, formData) && ( + + + + )} + + ); + } + + return ( + + {hasTitle && ( + + )} + {hasDescription && ( + + )} + {properties.map(element => element.content)} + {canExpand(schema, uiSchema, formData) && ( + + + + )} + + ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/TitleFieldTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/TitleFieldTemplate.tsx new file mode 100644 index 0000000000..7b9d60ead0 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/TitleFieldTemplate.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2022 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 { + FormContextType, + TitleFieldProps, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import { Box, Text } from '@backstage/ui'; + +export default function TitleFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ id, title, required }: TitleFieldProps) { + return ( + + + {title} + {required && ( + + {' '} + * + + )} + + + ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/UnsupportedFieldTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/UnsupportedFieldTemplate.tsx new file mode 100644 index 0000000000..8515832dc0 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/UnsupportedFieldTemplate.tsx @@ -0,0 +1,39 @@ +/* + * Copyright 2026 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 { + UnsupportedFieldProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils'; +import { Box, Text } from '@backstage/ui'; + +export default function UnsupportedFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: UnsupportedFieldProps) { + const { schema, reason } = props; + return ( + + + Unsupported field schema + {schema && schema.type && ` for type: ${JSON.stringify(schema.type)}`} + {reason && `: ${reason}`} + + + ); +} diff --git a/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/WrapIfAdditionalTemplate.tsx b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/WrapIfAdditionalTemplate.tsx new file mode 100644 index 0000000000..c5241fcf05 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/BuiTheme/templates/WrapIfAdditionalTemplate.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2026 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 { + ADDITIONAL_PROPERTY_FLAG, + FormContextType, + RJSFSchema, + StrictRJSFSchema, + WrapIfAdditionalTemplateProps, +} from '@rjsf/utils'; +import { Button, Flex, Box, TextField } from '@backstage/ui'; +import { RiDeleteBinLine } from '@remixicon/react'; + +export default function WrapIfAdditionalTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + children, + classNames, + style, + disabled, + id, + label, + onDropPropertyClick, + onKeyChange, + readonly, + required, + schema, +}: WrapIfAdditionalTemplateProps) { + const keyLabel = `${label} Key`; + const additional = ADDITIONAL_PROPERTY_FLAG in schema; + + if (!additional) { + return ( + + {children} + + ); + } + + const handleChange = (value: string) => { + onKeyChange(value); + }; + + return ( + + + + + + + {children} + +