feat(scaffolder): add BUI theme for scaffolder forms (#33053)

* feat(scaffolder): add BUI theme for scaffolder forms

Add a Backstage UI (BUI) form theme as an alternative to the Material
UI theme. Toggled via formProps.theme or enableBackstageUi page config.

Includes BUI widgets, templates, field extension variants, and a ported
React Aria Autocomplete component.

Signed-off-by: benjdlambert <ben@blam.sh>

* refactor(scaffolder): use BUI Combobox and CheckboxGroup for form widgets

Signed-off-by: benjdlambert <ben@blam.sh>

* chore(scaffolder): enable BUI form flag and add kitchen sink demo template

Signed-off-by: benjdlambert <ben@blam.sh>

* fix(scaffolder): use outlined input style for BUI form widgets

Signed-off-by: benjdlambert <ben@blam.sh>

* fix(scaffolder): address BUI form PR feedback

Signed-off-by: benjdlambert <ben@blam.sh>

* fix(scaffolder): format CSS and regen API reports

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-05-12 10:35:21 +02:00
committed by GitHub
parent 728629cf64
commit dbeb7aab3e
86 changed files with 4780 additions and 231 deletions
@@ -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.
@@ -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);
}
}
@@ -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<T, S, F> {
return {
templates: generateBuiTemplates<T, S, F>(),
widgets: generateBuiWidgets<T, S, F>(),
};
}
@@ -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<T, S, F>) {
const { description } = props;
if (!description) {
return null;
}
if (isValidElement(description)) {
return <>{description}</>;
}
return <MarkdownContent content={description as string} linkTarget="_blank" />;
}
@@ -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<T, S, F>) {
const [isOpen, setIsOpen] = useState(true);
const itemSchema = schema?.items as S | undefined;
const itemTitle = itemSchema?.title
? `${itemSchema.title} ${index + 1}`
: `Item ${index + 1}`;
return (
<Box mb="4">
<Card>
<Disclosure isExpanded={isOpen} onExpandedChange={setIsOpen}>
{({ isExpanded }) => (
<>
<CardHeader
style={{ backgroundColor: 'var(--bui-bg-neutral-2)' }}
>
<Box py="3" px="4">
<Flex align="center" justify="between">
<Flex align="center" gap="2">
<Button
slot="trigger"
style={{
padding: '2px',
background: 'none',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
color: 'var(--bui-fg-secondary)',
}}
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<RiArrowDownSLine size={18} />
) : (
<RiArrowRightSLine size={18} />
)}
</Button>
<Text variant="body-medium" weight="bold">
{itemTitle}
</Text>
</Flex>
{hasToolbar && (
<Flex align="center" gap="1">
{(hasMoveUp || hasMoveDown) && (
<>
<ButtonIcon
variant="tertiary"
size="small"
isDisabled={disabled || readonly || !hasMoveUp}
onClick={onReorderClick(index, index - 1)}
aria-label="Move up"
icon={<RiArrowUpSLine size={18} />}
/>
<ButtonIcon
variant="tertiary"
size="small"
isDisabled={disabled || readonly || !hasMoveDown}
onClick={onReorderClick(index, index + 1)}
aria-label="Move down"
icon={<RiArrowDownSLine size={18} />}
/>
</>
)}
{hasCopy && (
<ButtonIcon
variant="tertiary"
size="small"
isDisabled={disabled || readonly}
onClick={onCopyIndexClick(index)}
aria-label="Duplicate"
icon={<RiAddLine size={18} />}
/>
)}
{hasRemove && (
<ButtonIcon
variant="tertiary"
size="small"
isDisabled={disabled || readonly}
onClick={onDropIndexClick(index)}
aria-label="Remove"
icon={<RiDeleteBinLine size={18} />}
/>
)}
</Flex>
)}
</Flex>
</Box>
</CardHeader>
<DisclosurePanel>
<CardBody>
<Box p="4">{children}</Box>
</CardBody>
</DisclosurePanel>
</>
)}
</Disclosure>
</Card>
</Box>
);
}
@@ -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<T, S, F>) {
const uiOptions = getUiOptions<T, S, F>(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 (
<Box mb="4">
{hasTitle && (
<Box mb="2">
<ArrayFieldTitleTemplate
idSchema={idSchema}
title={uiOptions.title || title}
required={required}
schema={schema}
uiSchema={uiSchema}
registry={registry}
/>
</Box>
)}
{hasDescription && (
<Box mb="2">
<ArrayFieldDescriptionTemplate
idSchema={idSchema}
description={uiOptions.description || schema.description}
schema={schema}
uiSchema={uiSchema}
registry={registry}
/>
</Box>
)}
{items && items.length > 0 ? (
items.map(
({ key, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>) => (
<ArrayFieldItemTemplate key={key} {...itemProps} />
),
)
) : (
<Box
p="3"
mb="4"
style={{
backgroundColor: 'var(--bui-bg-neutral-2)',
borderRadius: 'var(--bui-radius-2)',
}}
>
<Text
variant="body-medium"
color="secondary"
style={{ textAlign: 'center' }}
>
No items added yet
</Text>
</Box>
)}
{canAdd && (
<Box>
<AddButton
onClick={onAddClick}
disabled={disabled || readonly}
uiSchema={uiSchema}
registry={registry}
/>
</Box>
)}
</Box>
);
}
@@ -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<T, S, F>) {
if (!title) {
return null;
}
const id = titleId<T>(idSchema);
return (
<Text id={id} as="h3" variant="title-medium" weight="bold">
{title}
{required && (
<Text as="span" color="danger">
{' '}
*
</Text>
)}
</Text>
);
}
@@ -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<T, S, F>) {
const {
submitText,
norender,
props: submitButtonProps = {},
} = getSubmitButtonOptions<T, S, F>(uiSchema);
if (norender) {
return null;
}
return (
<Button type="submit" variant="primary" {...submitButtonProps}>
{submitText}
</Button>
);
}
export function AddButton<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({ registry, disabled, onClick, className }: IconButtonProps<T, S, F>) {
const { translateString } = registry;
const handleClick = onClick ? (e: any) => onClick(e) : undefined;
return (
<Button
isDisabled={disabled}
onClick={handleClick}
className={className}
variant="secondary"
size="small"
iconStart={<RiAddLine />}
>
{translateString(TranslatableString.AddItemButton)}
</Button>
);
}
export function IconButton<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: IconButtonProps<T, S, F>) {
const { icon, registry, disabled, onClick, className } = props;
const { translateString } = registry;
let buttonIcon;
let buttonLabel;
switch (icon) {
case 'arrow-up':
buttonIcon = <RiArrowUpLine />;
buttonLabel = translateString(TranslatableString.MoveUpButton);
break;
case 'arrow-down':
buttonIcon = <RiArrowDownLine />;
buttonLabel = translateString(TranslatableString.MoveDownButton);
break;
case 'remove':
buttonIcon = <RiDeleteBinLine />;
buttonLabel = translateString(TranslatableString.RemoveButton);
break;
case 'copy':
buttonIcon = <RiFileCopyLine />;
buttonLabel = translateString(TranslatableString.CopyButton);
break;
default:
buttonIcon = null;
buttonLabel = '';
}
const handleClick = onClick ? () => onClick(undefined as any) : undefined;
return (
<Button
isDisabled={disabled}
onClick={handleClick}
className={className}
variant="tertiary"
size="small"
iconStart={buttonIcon || undefined}
>
{buttonLabel}
</Button>
);
}
const ButtonTemplates = {
SubmitButton,
AddButton,
RemoveButton: IconButton,
MoveDownButton: IconButton,
MoveUpButton: IconButton,
CopyButton: IconButton,
};
export default ButtonTemplates;
@@ -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<T, S, F>) {
if (!description) {
return null;
}
return (
<div id={id}>
{isValidElement(description) ? (
description
) : (
<MarkdownContent
content={description as string}
linkTarget="_blank"
/>
)}
</div>
);
}
@@ -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<T, S, F>) {
const { translateString } = registry;
if (errors.length === 0) {
return null;
}
return (
<Box
p="4"
mb="8"
style={{
backgroundColor: 'var(--bui-bg-danger)',
border: '1px solid var(--bui-border-danger)',
borderRadius: 'var(--bui-radius-2)',
}}
>
<Text as="h4" variant="title-small" weight="bold" color="danger">
{translateString(TranslatableString.ErrorsLabel)}
</Text>
<Box mt="2" pl="8">
{errors.map((error, i) => (
<Box
key={i}
style={{
display: 'list-item',
listStyleType: 'disc',
marginLeft: '20px',
}}
>
<Text variant="body-small" color="danger">
{error.stack}
</Text>
</Box>
))}
</Box>
</Box>
);
}
@@ -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<T, S, F>) {
if (errors.length === 0) {
return null;
}
const id = errorId<T>(idSchema);
return (
<div id={id}>
{errors.map((error, i) => (
<Text key={i} as="div" variant="body-small" color="danger">
{error}
</Text>
))}
</div>
);
}
@@ -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<T, S, F>) {
if (!help) {
return null;
}
const id = helpId<T>(idSchema);
return (
<div id={id} style={{ marginTop: 'var(--bui-space-1)' }}>
{isValidElement(help) ? (
help
) : (
<Text variant="body-small" color="secondary">
{help}
</Text>
)}
</div>
);
}
@@ -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(
<Form
EXPERIMENTAL_theme="bui"
validator={validator}
schema={{
type: 'object',
properties: {
name: { type: 'string', title: 'Full Name' },
},
}}
/>,
);
expect(getByText('Full Name')).toBeInTheDocument();
});
it('should show Required when field is required', () => {
const { getByText } = render(
<Form
EXPERIMENTAL_theme="bui"
validator={validator}
schema={{
type: 'object',
required: ['name'],
properties: {
name: { type: 'string', title: 'Full Name' },
},
}}
/>,
);
expect(getByText(/Required/)).toBeInTheDocument();
});
it('should render the description as markdown', () => {
const { container } = render(
<Form
EXPERIMENTAL_theme="bui"
validator={validator}
schema={{
type: 'object',
properties: {
name: {
type: 'string',
title: 'Full Name',
description: 'Enter your **full** name',
},
},
}}
/>,
);
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(
<Form
EXPERIMENTAL_theme="bui"
validator={validator}
schema={{
type: 'object',
properties: {
secret: { type: 'string', title: 'Secret Field' },
},
}}
uiSchema={{
secret: { 'ui:widget': 'hidden' },
}}
/>,
);
// 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(
<Form
EXPERIMENTAL_theme="bui"
validator={validator}
schema={{
type: 'object',
properties: {
name: { type: 'string', minLength: 3 },
},
}}
formData={{ name: 'a' }}
liveValidate
/>,
);
// 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);
});
});
@@ -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<T, S, F>) {
const {
id,
children,
classNames,
style,
disabled,
displayLabel,
hidden,
label,
onDropPropertyClick,
onKeyChange,
readonly,
registry,
required,
rawErrors = [],
errors,
help,
rawDescription,
schema,
uiSchema,
} = props;
const uiOptions = getUiOptions<T, S, F>(uiSchema);
const WrapIfAdditionalTemplate = getTemplate<
'WrapIfAdditionalTemplate',
T,
S,
F
>('WrapIfAdditionalTemplate', registry, uiOptions);
if (hidden) {
return <Box display="none">{children}</Box>;
}
return (
<WrapIfAdditionalTemplate
classNames={classNames}
style={style}
disabled={disabled}
id={id}
label={label}
onDropPropertyClick={onDropPropertyClick}
onKeyChange={onKeyChange}
readonly={readonly}
required={required}
schema={schema}
uiSchema={uiSchema}
registry={registry}
>
<ScaffolderField
displayLabel={displayLabel}
rawErrors={rawErrors}
help={help}
disabled={disabled}
rawDescription={rawDescription}
errors={errors}
required={required}
>
{children}
</ScaffolderField>
</WrapIfAdditionalTemplate>
);
}
@@ -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<T, S, F>) {
const uiOptions = getUiOptions<T, S, F>(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 (
<Box>
{properties.map(element => element.content)}
{canExpand<T, S, F>(schema, uiSchema, formData) && (
<Box mt="3">
<AddButton
onClick={onAddClick(schema)}
disabled={disabled || readonly}
className="object-property-expand"
uiSchema={uiSchema}
registry={registry}
/>
</Box>
)}
</Box>
);
}
return (
<Box>
{hasTitle && (
<TitleFieldTemplate
id={`${idSchema.$id}__title`}
title={(title || uiOptions.title) as string}
required={required}
schema={schema}
uiSchema={uiSchema}
registry={registry}
/>
)}
{hasDescription && (
<DescriptionFieldTemplate
id={`${idSchema.$id}__description`}
description={uiOptions.description || description!}
schema={schema}
uiSchema={uiSchema}
registry={registry}
/>
)}
{properties.map(element => element.content)}
{canExpand<T, S, F>(schema, uiSchema, formData) && (
<Box mt="3">
<AddButton
onClick={onAddClick(schema)}
disabled={disabled || readonly}
className="object-property-expand"
uiSchema={uiSchema}
registry={registry}
/>
</Box>
)}
</Box>
);
}
@@ -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<T, S, F>) {
return (
<Box mb="3">
<Text id={id} as="h3" variant="title-small" weight="bold">
{title}
{required && (
<Text as="span" color="danger">
{' '}
*
</Text>
)}
</Text>
</Box>
);
}
@@ -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<T, S, F>) {
const { schema, reason } = props;
return (
<Box p="3" style={{ backgroundColor: 'var(--bui-bg-neutral-3)' }}>
<Text variant="body-small" color="danger">
Unsupported field schema
{schema && schema.type && ` for type: ${JSON.stringify(schema.type)}`}
{reason && `: ${reason}`}
</Text>
</Box>
);
}
@@ -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<T, S, F>) {
const keyLabel = `${label} Key`;
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
if (!additional) {
return (
<Box className={classNames} style={style}>
{children}
</Box>
);
}
const handleChange = (value: string) => {
onKeyChange(value);
};
return (
<Box className={classNames} style={style}>
<Flex align="start" gap="4">
<Box style={{ flex: 1 }}>
<Box mb="4">
<TextField
id={`${id}-key`}
label={keyLabel}
secondaryLabel={required ? 'Required' : undefined}
defaultValue={label}
isDisabled={disabled || readonly}
onChange={handleChange}
/>
</Box>
{children}
</Box>
<Button
variant="tertiary"
size="small"
isDisabled={disabled || readonly}
onClick={onDropPropertyClick(label)}
iconStart={<RiDeleteBinLine />}
aria-label="Remove"
/>
</Flex>
</Box>
);
}
@@ -0,0 +1,71 @@
/*
* 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,
RJSFSchema,
StrictRJSFSchema,
TemplatesType,
} from '@rjsf/utils';
import ArrayFieldDescriptionTemplate from './ArrayFieldDescriptionTemplate';
import ArrayFieldItemTemplate from './ArrayFieldItemTemplate';
import ArrayFieldTemplate from './ArrayFieldTemplate';
import ArrayFieldTitleTemplate from './ArrayFieldTitleTemplate';
import ButtonTemplates, {
SubmitButton,
AddButton,
IconButton,
} from './ButtonTemplates';
import DescriptionFieldTemplate from './DescriptionFieldTemplate';
import ErrorListTemplate from './ErrorListTemplate';
import FieldErrorTemplate from './FieldErrorTemplate';
import FieldHelpTemplate from './FieldHelpTemplate';
import FieldTemplate from './FieldTemplate';
import ObjectFieldTemplate from './ObjectFieldTemplate';
import TitleFieldTemplate from './TitleFieldTemplate';
import UnsupportedFieldTemplate from './UnsupportedFieldTemplate';
import WrapIfAdditionalTemplate from './WrapIfAdditionalTemplate';
export function generateBuiTemplates<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(): Partial<TemplatesType<T, S, F>> {
return {
ArrayFieldDescriptionTemplate,
ArrayFieldItemTemplate,
ArrayFieldTemplate,
ArrayFieldTitleTemplate,
ButtonTemplates: {
...ButtonTemplates,
SubmitButton,
AddButton,
RemoveButton: IconButton,
MoveDownButton: IconButton,
MoveUpButton: IconButton,
CopyButton: IconButton,
},
DescriptionFieldTemplate,
ErrorListTemplate,
FieldErrorTemplate,
FieldHelpTemplate,
FieldTemplate,
ObjectFieldTemplate,
TitleFieldTemplate,
UnsupportedFieldTemplate,
WrapIfAdditionalTemplate,
};
}
@@ -0,0 +1,107 @@
/*
* 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, fireEvent, act } from '@testing-library/react';
import validator from '@rjsf/validator-ajv8';
import { Form } from '../../Form';
describe('BUI BaseInputTemplate', () => {
it('should render a text input', () => {
const { container } = render(
<Form
EXPERIMENTAL_theme="bui"
validator={validator}
schema={{
type: 'object',
properties: {
name: { type: 'string', title: 'Name' },
},
}}
/>,
);
const input = container.querySelector('input');
expect(input).toBeInTheDocument();
});
it('should handle onChange', async () => {
const onChange = jest.fn();
const { container } = render(
<Form
EXPERIMENTAL_theme="bui"
validator={validator}
schema={{
type: 'object',
properties: {
name: { type: 'string', title: 'Name' },
},
}}
onChange={onChange}
/>,
);
const input = container.querySelector('input')!;
await act(async () => {
fireEvent.change(input, { target: { value: 'hello' } });
});
expect(onChange).toHaveBeenCalled();
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(lastCall.formData).toEqual({ name: 'hello' });
});
it('should show invalid state when there are validation errors', async () => {
const { container } = render(
<Form
EXPERIMENTAL_theme="bui"
validator={validator}
schema={{
type: 'object',
properties: {
name: { type: 'string', minLength: 3 },
},
}}
formData={{ name: 'a' }}
liveValidate
/>,
);
const input = container.querySelector('input')!;
expect(input).toHaveAttribute('aria-invalid', 'true');
});
it('should render label via FieldTemplate, not the widget itself', () => {
const { queryByText } = render(
<Form
EXPERIMENTAL_theme="bui"
validator={validator}
schema={{
type: 'object',
properties: {
name: {
type: 'string',
title: 'My Field Title',
description: 'A helpful description',
},
},
}}
/>,
);
expect(queryByText('My Field Title')).toBeInTheDocument();
});
});
@@ -0,0 +1,111 @@
/*
* 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 {
ariaDescribedByIds,
BaseInputTemplateProps,
examplesId,
FormContextType,
getInputProps,
RJSFSchema,
StrictRJSFSchema,
} from '@rjsf/utils';
import { TextField } from '@backstage/ui';
export default function BaseInputTemplate<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
id,
placeholder,
required,
readonly,
disabled,
type,
label,
value,
onChange: onFieldChange,
onBlur: onFieldBlur,
onFocus: onFieldFocus,
autofocus,
options,
schema,
rawErrors = [],
children,
extraProps,
}: BaseInputTemplateProps<T, S, F>) {
const { type: inputType, ...restInputProps } = {
...extraProps,
...getInputProps<T, S, F>(schema, type, options),
};
const list = schema.examples ? examplesId<T>(id) : undefined;
const handleChange = (val: string) =>
onFieldChange(val === '' ? options.emptyValue : val);
const handleBlur = () => onFieldBlur(id, value);
const handleFocus = () => onFieldFocus(id, value);
const hasError = rawErrors.length > 0;
const isNumeric = inputType === 'number' || inputType === 'integer';
// Normalize value to string for the input
let inputValue: string;
if (isNumeric) {
inputValue = value || value === 0 ? String(value) : '';
} else {
inputValue = value === null || value === undefined ? '' : String(value);
}
return (
<>
<TextField
id={id}
name={id}
label={label || schema.title}
secondaryLabel={required ? 'Required' : undefined}
placeholder={placeholder}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autofocus}
isRequired={required}
isDisabled={disabled}
isReadOnly={readonly}
isInvalid={hasError}
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
aria-describedby={ariaDescribedByIds<T>(id, !!schema.examples)}
list={list}
{...restInputProps}
/>
{children}
{Array.isArray(schema.examples) ? (
<datalist id={examplesId<T>(id)}>
{(schema.examples as string[])
.concat(
schema.default && !schema.examples.includes(schema.default)
? ([schema.default] as string[])
: [],
)
.map((example: any) => {
return <option key={example} value={example} />;
})}
</datalist>
) : null}
</>
);
}
@@ -0,0 +1,59 @@
/*
* 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,
labelValue,
RJSFSchema,
schemaRequiresTrueValue,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import { Checkbox } from '@backstage/ui';
export default function CheckboxWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: WidgetProps<T, S, F>) {
const {
id,
value,
disabled,
readonly,
label,
hideLabel,
schema,
onChange: onFieldChange,
} = props;
const required = schemaRequiresTrueValue<S>(schema);
const handleChange = (checked: boolean) => {
onFieldChange(checked);
};
return (
<Checkbox
name={id}
isSelected={typeof value === 'undefined' ? false : Boolean(value)}
isRequired={required}
isDisabled={disabled || readonly}
onChange={handleChange}
>
{labelValue(label, hideLabel || !label)}
</Checkbox>
);
}
@@ -0,0 +1,80 @@
/*
* 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 {
ariaDescribedByIds,
FormContextType,
optionId,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import { Checkbox, CheckboxGroup } from '@backstage/ui';
export default function CheckboxesWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
id,
label,
required,
disabled,
readonly,
options,
value,
onChange: onFieldChange,
rawErrors = [],
}: WidgetProps<T, S, F>) {
const { enumOptions, enumDisabled } = options;
const selectedKeys = Array.isArray(value) ? value.map(String) : [];
const hasError = rawErrors.length > 0;
const handleChange = (keys: string[]) => {
const next =
enumOptions
?.filter(option => keys.includes(String(option.value)))
.map(option => option.value) ?? [];
onFieldChange(next as T);
};
return (
<CheckboxGroup
label={label}
isRequired={required}
isInvalid={hasError}
isDisabled={disabled || readonly}
value={selectedKeys}
onChange={handleChange}
aria-describedby={ariaDescribedByIds<T>(id)}
>
{enumOptions?.map((option, index) => {
const itemDisabled =
Array.isArray(enumDisabled) &&
enumDisabled.indexOf(option.value) !== -1;
return (
<Checkbox
key={optionId(id, index)}
value={String(option.value)}
isDisabled={itemDisabled}
>
{option.label}
</Checkbox>
);
})}
</CheckboxGroup>
);
}
@@ -0,0 +1,30 @@
/*
* 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,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import BaseInputTemplate from './BaseInputTemplate';
export default function ColorWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: WidgetProps<T, S, F>) {
return <BaseInputTemplate {...props} type="color" />;
}
@@ -0,0 +1,123 @@
/*
* 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 { CSSProperties, ChangeEvent } from 'react';
import {
ariaDescribedByIds,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import { FieldLabel } from '@backstage/ui';
// Native fallback until BUI ships DatePicker/TimePicker components.
const baseStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
height: '2rem',
padding: '0 var(--bui-space-3)',
borderRadius: 'var(--bui-radius-2)',
border: '1px solid var(--bui-border-2)',
backgroundColor: 'var(--bui-bg-neutral-1)',
fontSize: 'var(--bui-font-size-3)',
fontFamily: 'var(--bui-font-regular)',
fontWeight: 400,
color: 'var(--bui-fg-primary)',
transition: 'border-color 0.2s ease-in-out',
width: '100%',
boxSizing: 'border-box' as const,
outline: 'none',
WebkitAppearance: 'none' as const,
MozAppearance: 'none' as const,
appearance: 'none' as const,
};
interface DateInputProps<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
> extends WidgetProps<T, S, F> {
inputType: 'date' | 'datetime-local' | 'time';
}
export default function DateInput<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
id,
label,
required,
disabled,
readonly,
value,
autofocus,
schema,
options = {},
onChange: onFieldChange,
onBlur: onFieldBlur,
onFocus: onFieldFocus,
rawErrors = [],
inputType,
}: DateInputProps<T, S, F>) {
const handleChange = (event: ChangeEvent<HTMLInputElement>) =>
onFieldChange(
event.target.value === '' ? options.emptyValue : event.target.value,
);
const handleBlur = () => onFieldBlur(id, value);
const handleFocus = () => onFieldFocus(id, value);
const hasError = rawErrors.length > 0;
const stringValue = String(value ?? '');
return (
<>
{(label || schema.title) && (
<FieldLabel
label={label || schema.title}
secondaryLabel={required ? 'Required' : undefined}
htmlFor={id}
/>
)}
<input
id={id}
name={id}
type={inputType}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autofocus}
disabled={disabled}
readOnly={readonly}
value={stringValue}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
aria-describedby={ariaDescribedByIds<T>(id)}
aria-invalid={hasError}
style={{
...baseStyle,
...(disabled && {
opacity: 0.5,
cursor: 'not-allowed',
borderColor: 'var(--bui-border-disabled)',
}),
...(hasError && {
borderColor: 'var(--bui-fg-danger)',
}),
}}
/>
</>
);
}
@@ -0,0 +1,30 @@
/*
* 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,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import DateInput from './DateInput';
export default function DateTimeWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: WidgetProps<T, S, F>) {
return <DateInput {...props} inputType="datetime-local" />;
}
@@ -0,0 +1,30 @@
/*
* 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,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import DateInput from './DateInput';
export default function DateWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: WidgetProps<T, S, F>) {
return <DateInput {...props} inputType="date" />;
}
@@ -0,0 +1,30 @@
/*
* 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,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import BaseInputTemplate from './BaseInputTemplate';
export default function EmailWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: WidgetProps<T, S, F>) {
return <BaseInputTemplate {...props} type="email" />;
}
@@ -0,0 +1,88 @@
/*
* 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 { ChangeEvent, useCallback } from 'react';
import {
ariaDescribedByIds,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
function processFile(
file: File,
): Promise<{ dataURL: string; name: string; size: number; type: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve({
dataURL: reader.result as string,
name: file.name,
size: file.size,
type: file.type,
});
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function processFiles(files: FileList): Promise<any> {
return Promise.all(Array.from(files).map(processFile));
}
export default function FileWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
id,
readonly,
disabled,
required,
multiple,
onChange,
options,
}: WidgetProps<T, S, F>) {
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files || readonly || disabled) {
return;
}
processFiles(event.target.files).then(filesInfo => {
onChange(multiple ? filesInfo : filesInfo[0]);
});
},
[multiple, readonly, disabled, onChange],
);
return (
<input
id={id}
name={id}
type="file"
disabled={readonly || disabled}
onChange={handleChange}
defaultValue=""
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={options.autofocus as boolean}
multiple={multiple}
required={required}
accept={options.accept as string | undefined}
aria-describedby={ariaDescribedByIds<T>(id)}
/>
);
}
@@ -0,0 +1,36 @@
/*
* 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,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
export default function HiddenWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({ id, value }: WidgetProps<T, S, F>) {
return (
<input
type="hidden"
id={id}
name={id}
value={typeof value === 'undefined' ? '' : value}
/>
);
}
@@ -0,0 +1,72 @@
/*
* 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 {
ariaDescribedByIds,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import { PasswordField } from '@backstage/ui';
export default function PasswordWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
id,
placeholder,
required,
readonly,
disabled,
label,
value,
onChange: onFieldChange,
onBlur: onFieldBlur,
onFocus: onFieldFocus,
autofocus,
options,
schema,
rawErrors = [],
}: WidgetProps<T, S, F>) {
const handleChange = (val: string) =>
onFieldChange(val === '' ? options.emptyValue : val);
const handleBlur = () => onFieldBlur(id, value);
const handleFocus = () => onFieldFocus(id, value);
const hasError = rawErrors.length > 0;
return (
<PasswordField
id={id}
name={id}
label={label || schema.title}
secondaryLabel={required ? 'Required' : undefined}
placeholder={placeholder}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autofocus}
isRequired={required}
isDisabled={disabled}
isReadOnly={readonly}
isInvalid={hasError}
value={value || ''}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
aria-describedby={ariaDescribedByIds<T>(id)}
/>
);
}
@@ -0,0 +1,82 @@
/*
* 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 {
ariaDescribedByIds,
enumOptionsIndexForValue,
enumOptionsValueForIndex,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import { RadioGroup, Radio } from '@backstage/ui';
export default function RadioWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
id,
options,
required,
value,
disabled,
readonly,
label,
onChange: onFieldChange,
rawErrors = [],
}: WidgetProps<T, S, F>) {
const { enumOptions, enumDisabled, emptyValue } = options;
const handleChange = (newValue: string) => {
const actualValue = enumOptionsValueForIndex<S>(
newValue,
enumOptions,
emptyValue,
);
onFieldChange(actualValue);
};
const selectedIndex =
enumOptionsIndexForValue<S>(value, enumOptions) ?? undefined;
const hasError = rawErrors.length > 0;
return (
<RadioGroup
name={id}
label={label}
secondaryLabel={required ? 'Required' : undefined}
value={selectedIndex !== undefined ? String(selectedIndex) : ''}
onChange={handleChange}
isRequired={required}
isDisabled={disabled || readonly}
isInvalid={hasError}
aria-describedby={ariaDescribedByIds<T>(id)}
>
{enumOptions?.map(({ value: optionValue, label: optionLabel }, index) => {
const itemDisabled =
Array.isArray(enumDisabled) &&
enumDisabled.indexOf(optionValue) !== -1;
return (
<Radio key={index} value={String(index)} isDisabled={itemDisabled}>
{optionLabel}
</Radio>
);
})}
</RadioGroup>
);
}
@@ -0,0 +1,78 @@
/*
* 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 { ChangeEvent } from 'react';
import {
ariaDescribedByIds,
FormContextType,
rangeSpec,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import { Flex, Text } from '@backstage/ui';
export default function RangeWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
id,
value,
readonly,
disabled,
schema,
label,
required,
onChange: onFieldChange,
onBlur: onFieldBlur,
onFocus: onFieldFocus,
}: WidgetProps<T, S, F>) {
const { min, max, step } = rangeSpec<S>(schema);
const handleChange = ({
target: { value: inputValue },
}: ChangeEvent<HTMLInputElement>) => onFieldChange(parseFloat(inputValue));
const handleBlur = () => onFieldBlur(id, value);
const handleFocus = () => onFieldFocus(id, value);
return (
<Flex align="center" gap="4">
<input
id={id}
name={id}
type="range"
disabled={disabled || readonly}
min={min}
max={max}
step={step}
value={value ?? min}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
aria-label={label}
aria-describedby={ariaDescribedByIds<T>(id)}
required={required}
style={{ flex: 1 }}
/>
<Text
variant="body-medium"
style={{ minWidth: '3rem', textAlign: 'right' }}
>
{value ?? min}
</Text>
</Flex>
);
}
@@ -0,0 +1,104 @@
/*
* 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 {
ariaDescribedByIds,
enumOptionsIndexForValue,
enumOptionsValueForIndex,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import { Select } from '@backstage/ui';
import overrides from './selectOverrides.module.css';
export default function SelectWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
id,
options,
required,
disabled,
readonly,
value,
multiple,
label,
schema,
onChange: onFieldChange,
onBlur: onFieldBlur,
onFocus: onFieldFocus,
rawErrors = [],
}: WidgetProps<T, S, F>) {
const { enumOptions, emptyValue } = options;
const handleChange = (newValue: string) => {
const actualIndex = newValue === '' ? '' : newValue;
onFieldChange(
enumOptionsValueForIndex<S>(actualIndex, enumOptions, emptyValue),
);
};
const handleBlur = () =>
onFieldBlur(
id,
enumOptionsValueForIndex<S>(value, enumOptions, emptyValue),
);
const handleFocus = () =>
onFieldFocus(
id,
enumOptionsValueForIndex<S>(value, enumOptions, emptyValue),
);
const selectedIndex = enumOptionsIndexForValue<S>(value, enumOptions);
const hasError = rawErrors.length > 0;
const selectOptions =
enumOptions?.map(({ label: optionLabel }, index) => ({
value: String(index),
label: optionLabel,
})) || [];
if (!multiple && schema.default === undefined) {
selectOptions.unshift({
value: '',
label: (options.placeholder as string) || 'Select an option',
});
}
return (
<Select
className={overrides.select}
id={id}
name={id}
label={label || schema.title}
secondaryLabel={required ? 'Required' : undefined}
options={selectOptions}
selectedKey={selectedIndex !== undefined ? String(selectedIndex) : ''}
onSelectionChange={key => {
const selected = key ? String(key) : '';
handleChange(selected);
}}
isRequired={required}
isDisabled={disabled || readonly}
isInvalid={hasError}
onBlur={handleBlur}
onFocus={handleFocus}
aria-describedby={ariaDescribedByIds<T>(id)}
/>
);
}
@@ -0,0 +1,30 @@
/*
* 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,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import BaseInputTemplate from './BaseInputTemplate';
export default function TextWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: WidgetProps<T, S, F>) {
return <BaseInputTemplate {...props} />;
}
@@ -0,0 +1,117 @@
/*
* 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 { CSSProperties, ChangeEvent } from 'react';
import {
ariaDescribedByIds,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import { FieldLabel } from '@backstage/ui';
// Matches the outlined style used by BUI PasswordField: sits on the same
// neutral-1 surface as the form and uses a visible border to read as an input.
const baseStyle: CSSProperties = {
display: 'flex',
alignItems: 'flex-start',
padding: 'var(--bui-space-3)',
borderRadius: 'var(--bui-radius-3)',
border: '1px solid var(--bui-border-2)',
backgroundColor: 'var(--bui-bg-neutral-1)',
fontSize: 'var(--bui-font-size-3)',
fontFamily: 'var(--bui-font-regular)',
fontWeight: 400,
lineHeight: '1.5',
color: 'var(--bui-fg-primary)',
transition: 'border-color 0.2s ease-in-out',
width: '100%',
resize: 'vertical',
boxSizing: 'border-box' as const,
outline: 'none',
WebkitAppearance: 'none' as const,
MozAppearance: 'none' as const,
appearance: 'none' as const,
};
export default function TextareaWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
id,
options = {},
placeholder,
required,
value,
disabled,
readonly,
label,
autofocus,
onChange: onFieldChange,
onBlur: onFieldBlur,
onFocus: onFieldFocus,
schema,
rawErrors = [],
}: WidgetProps<T, S, F>) {
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) =>
onFieldChange(
event.target.value === '' ? options.emptyValue : event.target.value,
);
const handleBlur = () => onFieldBlur(id, value);
const handleFocus = () => onFieldFocus(id, value);
const hasError = rawErrors.length > 0;
return (
<>
{(label || schema.title) && (
<FieldLabel
label={label || schema.title}
secondaryLabel={required ? 'Required' : undefined}
htmlFor={id}
/>
)}
<textarea
id={id}
name={id}
placeholder={placeholder}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autofocus}
disabled={disabled}
readOnly={readonly}
value={value || ''}
rows={(options.rows as number) || 3}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
aria-describedby={ariaDescribedByIds<T>(id)}
aria-invalid={hasError}
style={{
...baseStyle,
...(disabled && {
opacity: 0.5,
cursor: 'not-allowed',
borderColor: 'var(--bui-border-disabled)',
}),
...(hasError && {
borderColor: 'var(--bui-fg-danger)',
}),
}}
/>
</>
);
}
@@ -0,0 +1,30 @@
/*
* 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,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import DateInput from './DateInput';
export default function TimeWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: WidgetProps<T, S, F>) {
return <DateInput {...props} inputType="time" />;
}
@@ -0,0 +1,30 @@
/*
* 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,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import BaseInputTemplate from './BaseInputTemplate';
export default function URLWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: WidgetProps<T, S, F>) {
return <BaseInputTemplate {...props} type="url" />;
}
@@ -0,0 +1,65 @@
/*
* 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,
RegistryWidgetsType,
RJSFSchema,
StrictRJSFSchema,
} from '@rjsf/utils';
import BaseInputTemplate from './BaseInputTemplate';
import CheckboxWidget from './CheckboxWidget';
import CheckboxesWidget from './CheckboxesWidget';
import ColorWidget from './ColorWidget';
import DateWidget from './DateWidget';
import DateTimeWidget from './DateTimeWidget';
import EmailWidget from './EmailWidget';
import FileWidget from './FileWidget';
import HiddenWidget from './HiddenWidget';
import PasswordWidget from './PasswordWidget';
import RadioWidget from './RadioWidget';
import RangeWidget from './RangeWidget';
import SelectWidget from './SelectWidget';
import TextareaWidget from './TextareaWidget';
import TextWidget from './TextWidget';
import TimeWidget from './TimeWidget';
import URLWidget from './URLWidget';
export function generateBuiWidgets<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(): RegistryWidgetsType<T, S, F> {
return {
BaseInput: BaseInputTemplate,
CheckboxWidget,
CheckboxesWidget,
ColorWidget,
DateWidget,
DateTimeWidget,
EmailWidget,
FileWidget,
HiddenWidget,
PasswordWidget,
RadioWidget,
RangeWidget,
SelectWidget,
TextareaWidget,
TextWidget,
TimeWidget,
URLWidget,
};
}
@@ -0,0 +1,9 @@
/* Duplicated in plugins/scaffolder/src/components/fields/scaffolderFieldOverrides.module.css
Keep in sync if changes are made. */
/* Override the BUI Select trigger border-radius to match BUI TextField
for visual consistency in scaffolder forms. Only applies to Select
instances that explicitly use className={overrides.select}. */
.select :global([class*='bui-SelectTrigger']) {
border-radius: var(--bui-radius-2);
}
@@ -0,0 +1,77 @@
/*
* 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, fireEvent, act } from '@testing-library/react';
import validator from '@rjsf/validator-ajv8';
import { Form } from './Form';
const schema = {
type: 'object' as const,
properties: {
name: { type: 'string' as const, title: 'Name' },
},
};
describe('Form with BUI theme', () => {
it('should render without crashing when theme is bui', () => {
const { container } = render(
<Form validator={validator} schema={schema} EXPERIMENTAL_theme="bui" />,
);
expect(container.querySelector('input')).toBeInTheDocument();
});
it('should not render MUI-specific elements when theme is bui', () => {
const { container } = render(
<Form validator={validator} schema={schema} EXPERIMENTAL_theme="bui" />,
);
const muiElements = container.querySelectorAll('[class*="MuiTextField"]');
expect(muiElements.length).toBe(0);
});
it('should accept user input in BUI form fields', async () => {
const onChange = jest.fn();
const { container } = render(
<Form
validator={validator}
schema={schema}
EXPERIMENTAL_theme="bui"
onChange={onChange}
/>,
);
const input = container.querySelector('input')!;
expect(input).toBeInTheDocument();
await act(async () => {
fireEvent.change(input, { target: { value: 'test value' } });
});
expect(onChange).toHaveBeenCalled();
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(lastCall.formData).toEqual({ name: 'test value' });
});
it('should render MUI elements when theme is mui', () => {
const { container } = render(
<Form validator={validator} schema={schema} EXPERIMENTAL_theme="mui" />,
);
const muiElements = container.querySelectorAll('[class*="MuiTextField"]');
expect(muiElements.length).toBeGreaterThan(0);
});
});
@@ -19,22 +19,40 @@ import { useMemo, PropsWithChildren } from 'react';
import { FieldTemplate } from './FieldTemplate';
import { DescriptionFieldTemplate } from './DescriptionFieldTemplate';
import { FieldProps } from '@rjsf/utils';
import { ScaffolderRJSFFormProps } from '@backstage/plugin-scaffolder-react';
import {
ScaffolderRJSFFormProps,
type FormProps,
} from '@backstage/plugin-scaffolder-react';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import { generateBuiTheme } from './BuiTheme';
import {
ScaffolderThemeProvider,
type ScaffolderTheme,
} from './ScaffolderThemeContext';
import styles from './BuiTheme/Form.module.css';
const WrappedForm = withTheme(MuiTheme);
const MuiForm = withTheme(MuiTheme);
const BuiForm = withTheme(generateBuiTheme());
/**
* The Form component
* @alpha
*/
export const Form = (props: PropsWithChildren<ScaffolderRJSFFormProps>) => {
export const Form = (
props: PropsWithChildren<
ScaffolderRJSFFormProps & Pick<FormProps, 'EXPERIMENTAL_theme'>
>,
) => {
const { EXPERIMENTAL_theme: themeProp, ...formProps } = props;
const theme: ScaffolderTheme = themeProp ?? 'mui';
const WrappedForm = theme === 'bui' ? BuiForm : MuiForm;
// This is where we unbreak the changes from RJSF, and make it work with our custom fields so we don't pass on this
// breaking change to our users. We will look more into a better API for this in scaffolderv2.
const wrappedFields = useMemo(
() =>
Object.fromEntries(
Object.entries(props.fields ?? {}).map(([key, Component]) => [
Object.entries(formProps.fields ?? {}).map(([key, Component]) => [
key,
(wrapperProps: FieldProps) => {
return (
@@ -50,19 +68,28 @@ export const Form = (props: PropsWithChildren<ScaffolderRJSFFormProps>) => {
},
]),
),
[props.fields],
[formProps.fields],
);
const templates = useMemo(
() => ({
FieldTemplate,
DescriptionFieldTemplate,
...props.templates,
}),
[props.templates],
() =>
theme === 'bui'
? formProps.templates ?? {}
: {
FieldTemplate,
DescriptionFieldTemplate,
...formProps.templates,
},
[formProps.templates, theme],
);
const form = (
<WrappedForm {...formProps} templates={templates} fields={wrappedFields} />
);
return (
<WrappedForm {...props} templates={templates} fields={wrappedFields} />
<ScaffolderThemeProvider value={theme}>
{theme === 'bui' ? <div className={styles.form}>{form}</div> : form}
</ScaffolderThemeProvider>
);
};
@@ -0,0 +1,38 @@
/*
* 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 { renderHook } from '@testing-library/react';
import {
useScaffolderTheme,
ScaffolderThemeProvider,
} from './ScaffolderThemeContext';
describe('ScaffolderThemeContext', () => {
it('should return mui by default when no provider is present', () => {
const { result } = renderHook(() => useScaffolderTheme());
expect(result.current).toBe('mui');
});
it('should return bui when wrapped in ScaffolderThemeProvider with bui', () => {
const { result } = renderHook(() => useScaffolderTheme(), {
wrapper: ({ children }) => (
<ScaffolderThemeProvider value="bui">
{children}
</ScaffolderThemeProvider>
),
});
expect(result.current).toBe('bui');
});
});
@@ -0,0 +1,28 @@
/*
* 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 { createContext, useContext } from 'react';
/** @alpha */
export type ScaffolderTheme = 'mui' | 'bui';
const ScaffolderThemeContext = createContext<ScaffolderTheme>('mui');
/** @alpha */
export const ScaffolderThemeProvider = ScaffolderThemeContext.Provider;
/** @alpha */
export const useScaffolderTheme = (): ScaffolderTheme =>
useContext(ScaffolderThemeContext);
@@ -15,3 +15,5 @@
*/
export { Form } from './Form';
export { useScaffolderTheme } from './ScaffolderThemeContext';
export type { ScaffolderTheme } from './ScaffolderThemeContext';
@@ -13,11 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PropsWithChildren, ReactElement } from 'react';
import {
type CSSProperties,
type PropsWithChildren,
type ReactElement,
} from 'react';
import { MarkdownContent } from '@backstage/core-components';
import FormControl from '@material-ui/core/FormControl';
import { makeStyles } from '@material-ui/core/styles';
import { Box } from '@backstage/ui';
import { useScaffolderTheme } from '../Form/ScaffolderThemeContext';
const useStyles = makeStyles(theme => ({
markdownDescription: {
@@ -31,6 +38,12 @@ const useStyles = makeStyles(theme => ({
},
}));
const buiDescriptionStyle: CSSProperties = {
fontSize: 'var(--bui-font-size-2)',
color: 'var(--bui-fg-secondary)',
margin: 0,
};
/**
* Props for the {@link ScaffolderField} component
* @alpha
@@ -46,14 +59,7 @@ export interface ScaffolderFieldProps {
displayLabel?: boolean;
}
/**
* A component to wrap up a input field which helps with formatting and supporting markdown
* on the field types
* @alpha
*/
export const ScaffolderField = (
props: PropsWithChildren<ScaffolderFieldProps>,
) => {
const MuiScaffolderField = (props: PropsWithChildren<ScaffolderFieldProps>) => {
const {
children,
displayLabel = true,
@@ -85,3 +91,37 @@ export const ScaffolderField = (
</FormControl>
);
};
const BuiScaffolderField = (props: PropsWithChildren<ScaffolderFieldProps>) => {
const { children, displayLabel = true, errors, help, rawDescription } = props;
return (
<Box mb="3">
{children}
{displayLabel && rawDescription ? (
<div style={buiDescriptionStyle}>
<MarkdownContent content={rawDescription} linkTarget="_blank" />
</div>
) : null}
{errors}
{help}
</Box>
);
};
/**
* A component to wrap up a input field which helps with formatting and supporting markdown
* on the field types
* @alpha
*/
export const ScaffolderField = (
props: PropsWithChildren<ScaffolderFieldProps>,
) => {
const theme = useScaffolderTheme();
if (theme === 'bui') {
return <BuiScaffolderField {...props} />;
}
return <MuiScaffolderField {...props} />;
};
@@ -23,7 +23,7 @@ import {
TemplateParameterSchema,
} from '@backstage/plugin-scaffolder-react';
import { JsonValue } from '@backstage/types';
import Button from '@material-ui/core/Button';
import { Button } from '@backstage/ui';
import LinearProgress from '@material-ui/core/LinearProgress';
import MuiStep from '@material-ui/core/Step';
import MuiStepLabel from '@material-ui/core/StepLabel';
@@ -293,18 +293,14 @@ export const Stepper = (stepperProps: StepperProps) => {
>
<div className={styles.footer}>
<Button
onClick={handleBack}
variant="tertiary"
onPress={handleBack}
className={styles.backButton}
disabled={activeStep < 1 || isValidating}
isDisabled={activeStep < 1 || isValidating}
>
{backLabel}
</Button>
<Button
variant="contained"
color="primary"
type="submit"
disabled={isValidating}
>
<Button variant="primary" type="submit" isDisabled={isValidating}>
{activeStep === steps.length - 1
? reviewLabel
: t('stepper.nextButtonText')}
@@ -326,17 +322,17 @@ export const Stepper = (stepperProps: StepperProps) => {
<ReviewStateComponent formState={stepsState} schemas={steps} />
<div className={styles.footer}>
<Button
onClick={handleBack}
variant="tertiary"
onPress={handleBack}
className={styles.backButton}
disabled={activeStep < 1}
isDisabled={activeStep < 1}
>
{backLabel}
</Button>
<Button
disabled={isCreating}
variant="contained"
color="primary"
onClick={handleCreate}
variant="primary"
isDisabled={isCreating}
onPress={handleCreate}
>
{createLabel}
</Button>