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:
@@ -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>(),
|
||||
};
|
||||
}
|
||||
+40
@@ -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" />;
|
||||
}
|
||||
+161
@@ -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>
|
||||
);
|
||||
}
|
||||
+131
@@ -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>
|
||||
);
|
||||
}
|
||||
+47
@@ -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>
|
||||
);
|
||||
}
|
||||
+137
@@ -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;
|
||||
+46
@@ -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>
|
||||
);
|
||||
}
|
||||
+67
@@ -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>
|
||||
);
|
||||
}
|
||||
+45
@@ -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>
|
||||
);
|
||||
}
|
||||
+48
@@ -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>
|
||||
);
|
||||
}
|
||||
+124
@@ -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);
|
||||
});
|
||||
});
|
||||
+94
@@ -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>
|
||||
);
|
||||
}
|
||||
+125
@@ -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>
|
||||
);
|
||||
}
|
||||
+42
@@ -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>
|
||||
);
|
||||
}
|
||||
+39
@@ -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>
|
||||
);
|
||||
}
|
||||
+85
@@ -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,
|
||||
};
|
||||
}
|
||||
+107
@@ -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();
|
||||
});
|
||||
});
|
||||
+111
@@ -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>
|
||||
);
|
||||
}
|
||||
+80
@@ -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} />;
|
||||
}
|
||||
+117
@@ -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,
|
||||
};
|
||||
}
|
||||
+9
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user