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

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

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

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

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

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

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

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

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

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

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

* fix(scaffolder): address BUI form PR feedback

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

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

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

---------

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-05-12 10:35:21 +02:00
committed by GitHub
parent 728629cf64
commit dbeb7aab3e
86 changed files with 4780 additions and 231 deletions
+1
View File
@@ -98,6 +98,7 @@
"lodash": "^4.17.21",
"luxon": "^3.0.0",
"qs": "^6.9.4",
"react-aria-components": "^1.14.0",
"react-resizable": "^3.0.5",
"react-resizable-panels": "^3.0.4",
"react-use": "^17.2.4",
+2
View File
@@ -552,10 +552,12 @@ const _default: OverridableFrontendPlugin<
}>;
'sub-page:scaffolder/templates': OverridableExtensionDefinition<{
config: {
enableBackstageUi: boolean;
path: string | undefined;
title: string | undefined;
};
configInput: {
enableBackstageUi?: boolean | undefined;
path?: string | undefined;
title?: string | undefined;
};
@@ -39,6 +39,7 @@ import {
} from '@backstage/plugin-scaffolder-react/alpha';
import {
FieldExtensionOptions,
FormProps,
SecretsContextProvider,
useCustomFieldExtensions,
useCustomLayouts,
@@ -159,7 +160,10 @@ function TemplateListContent() {
*
* @internal
*/
export function TemplatesSubPage(props: { formFields?: Array<FormField> }) {
export function TemplatesSubPage(props: {
formFields?: Array<FormField>;
formProps?: FormProps;
}) {
const customFieldExtensions = useCustomFieldExtensions(undefined);
const customLayouts = useCustomLayouts(undefined);
@@ -185,6 +189,7 @@ export function TemplatesSubPage(props: { formFields?: Array<FormField> }) {
<TemplateWizardPageContent
customFieldExtensions={fieldExtensions}
layouts={customLayouts}
formProps={props.formProps}
/>
</SecretsContextProvider>
}
+12 -2
View File
@@ -51,7 +51,12 @@ export const scaffolderPage = PageBlueprint.makeWithOverrides({
export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({
name: 'templates',
factory(originalFactory, { apis }) {
config: {
schema: {
enableBackstageUi: z => z.boolean().default(false),
},
},
factory(originalFactory, { apis, config }) {
const formFieldsApi = apis.get(formFieldsApiRef);
return originalFactory({
@@ -61,7 +66,12 @@ export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({
const formFields = (await formFieldsApi?.loadFormFields()) ?? [];
return import('./components/TemplatesSubPage').then(m => (
<m.TemplatesSubPage formFields={formFields} />
<m.TemplatesSubPage
formFields={formFields}
formProps={{
EXPERIMENTAL_theme: config.enableBackstageUi ? 'bui' : 'mui',
}}
/>
));
},
});
@@ -0,0 +1,53 @@
/*
* Copyright 2024 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 { forwardRef } from 'react';
import { Combobox } from '@backstage/ui';
import type { ComboboxProps } from '@backstage/ui';
export interface AutocompleteProps extends ComboboxProps {
/**
* Whether the autocomplete is currently loading options.
* When true, displays a loading indicator in the dropdown.
*/
isLoading?: boolean;
}
/**
* Thin wrapper around the BUI `Combobox` that adds an `isLoading` indicator and
* defaults to free-form text entry with focus-triggered menu.
*
* @public
*/
export const Autocomplete = forwardRef<HTMLDivElement, AutocompleteProps>(
({ isLoading, options, ...rest }, ref) => {
const finalOptions = isLoading
? [{ value: '__loading__', label: 'Loading…', disabled: true }]
: options;
return (
<Combobox
ref={ref}
allowsCustomValue
menuTrigger="focus"
options={finalOptions}
{...rest}
/>
);
},
);
Autocomplete.displayName = 'Autocomplete';
@@ -0,0 +1,18 @@
/*
* Copyright 2024 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.
*/
export { Autocomplete } from './Autocomplete';
export type { AutocompleteProps } from './Autocomplete';
@@ -14,9 +14,11 @@
* limitations under the License.
*/
import { EntityNamePickerProps } from './schema';
import TextField from '@material-ui/core/TextField';
import MuiTextField from '@material-ui/core/TextField';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { TextField as BuiTextField } from '@backstage/ui';
export { EntityNamePickerSchema } from './schema';
@@ -24,6 +26,7 @@ export { EntityNamePickerSchema } from './schema';
* EntityName Picker
*/
export const EntityNamePicker = (props: EntityNamePickerProps) => {
const theme = useScaffolderTheme();
const { t } = useTranslationRef(scaffolderTranslationRef);
const {
onChange,
@@ -34,13 +37,32 @@ export const EntityNamePicker = (props: EntityNamePickerProps) => {
},
rawErrors,
formData,
uiSchema,
uiSchema: { 'ui:autofocus': autoFocus },
idSchema,
placeholder,
} = props;
if (theme === 'bui') {
return (
<BuiTextField
id={idSchema?.$id}
label={title}
description={uiSchema['ui:description'] ?? description}
secondaryLabel={required ? 'Required' : undefined}
placeholder={placeholder}
isRequired={required}
value={formData ?? ''}
onChange={onChange}
isInvalid={rawErrors?.length > 0 && !formData}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
/>
);
}
return (
<TextField
<MuiTextField
id={idSchema?.$id}
label={title}
placeholder={placeholder}
@@ -34,7 +34,14 @@ import Autocomplete, {
AutocompleteChangeReason,
createFilterOptions,
} from '@material-ui/lab/Autocomplete';
import { useCallback, useEffect } from 'react';
import {
type Key,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import useAsync from 'react-use/esm/useAsync';
import {
EntityPickerFilterQueryValue,
@@ -45,7 +52,11 @@ import {
import { VirtualizedListbox } from '../VirtualizedListbox';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha';
import {
ScaffolderField,
useScaffolderTheme,
} from '@backstage/plugin-scaffolder-react/alpha';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
export { EntityPickerSchema } from './schema';
@@ -56,6 +67,7 @@ export { EntityPickerSchema } from './schema';
* @public
*/
export const EntityPicker = (props: EntityPickerProps) => {
const theme = useScaffolderTheme();
const { t } = useTranslationRef(scaffolderTranslationRef);
const {
onChange,
@@ -166,22 +178,158 @@ export const EntityPicker = (props: EntityPickerProps) => {
[onChange, formData, defaultKind, defaultNamespace, allowArbitraryValues],
);
// Since free solo can be enabled, attempt to parse as a full entity ref first, then fall
// back to the given value.
// Since free solo can be enabled, attempt to parse as a full entity ref first, then
// fall back to the given value.
const selectedEntity =
entities?.catalogEntities.find(e => stringifyEntityRef(e) === formData) ??
(allowArbitraryValues && formData ? getLabel(formData) : '');
// BUI: options for autocomplete
const buiOptions = useMemo(
() =>
(entities?.catalogEntities || []).map(entity => {
const entityRef = stringifyEntityRef(entity);
const presentation = entities?.entityRefToPresentation.get(entityRef);
return {
value: entityRef,
label: presentation?.primaryTitle || entityRef,
};
}),
[entities],
);
// BUI: controlled input value
const [inputValue, setInputValue] = useState(formData || '');
useEffect(() => {
if (
if (formData) {
const opt = buiOptions.find(o => o.value === formData);
setInputValue(opt?.label || formData);
} else {
setInputValue('');
}
}, [formData, buiOptions]);
const selectedKey =
formData && buiOptions.some(o => o.value === formData) ? formData : null;
const lastCommittedRef = useRef(formData);
useEffect(() => {
lastCommittedRef.current = formData;
}, [formData]);
const handleSelectionChange = useCallback(
(key: Key | null) => {
if (key !== null) {
const value = String(key);
lastCommittedRef.current = value;
onChange(value);
} else if (allowArbitraryValues && inputValue) {
let entityRef = inputValue;
try {
entityRef = stringifyEntityRef(
parseEntityRef(inputValue, { defaultKind, defaultNamespace }),
);
} catch {
// If the input isn't a valid entity ref, use it as-is
}
lastCommittedRef.current = entityRef;
onChange(entityRef);
} else {
lastCommittedRef.current = undefined;
onChange(undefined);
}
},
[onChange, allowArbitraryValues, inputValue, defaultKind, defaultNamespace],
);
const handleBlur = useCallback(() => {
if (allowArbitraryValues && inputValue) {
let entityRef = inputValue;
try {
entityRef = stringifyEntityRef(
parseEntityRef(inputValue, { defaultKind, defaultNamespace }),
);
} catch {
// If the input isn't a valid entity ref, use it as-is
}
if (lastCommittedRef.current !== entityRef) {
lastCommittedRef.current = entityRef;
onChange(entityRef);
}
}
}, [
allowArbitraryValues,
inputValue,
defaultKind,
defaultNamespace,
onChange,
]);
// Auto-select when only one entity and required
useEffect(() => {
if (theme === 'bui') {
if (
required &&
!allowArbitraryValues &&
entities?.catalogEntities.length === 1 &&
!formData
) {
onChange(stringifyEntityRef(entities.catalogEntities[0]));
}
} else {
if (
required &&
!allowArbitraryValues &&
entities?.catalogEntities.length === 1 &&
selectedEntity === ''
) {
onChange(stringifyEntityRef(entities.catalogEntities[0]));
}
}
}, [
entities,
onChange,
selectedEntity,
formData,
required,
allowArbitraryValues,
theme,
]);
if (theme === 'bui') {
const isAutoSelected =
required &&
!allowArbitraryValues &&
entities?.catalogEntities.length === 1 &&
selectedEntity === ''
) {
onChange(stringifyEntityRef(entities.catalogEntities[0]));
}
}, [entities, onChange, selectedEntity, required, allowArbitraryValues]);
entities?.catalogEntities.length === 1;
return (
<ScaffolderField
rawErrors={rawErrors}
rawDescription={uiSchema['ui:description'] ?? description}
required={required}
disabled={isDisabled}
errors={errors}
>
<BuiAutocomplete
id={idSchema?.$id}
label={title}
isRequired={required}
isDisabled={isDisabled || isAutoSelected}
selectedKey={selectedKey}
inputValue={inputValue}
onInputChange={setInputValue}
onSelectionChange={handleSelectionChange}
onBlur={handleBlur}
isLoading={loading}
options={buiOptions}
allowsCustomValue={allowArbitraryValues}
isInvalid={rawErrors && rawErrors.length > 0}
/>
</ScaffolderField>
);
}
return (
<ScaffolderField
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeEvent, useState } from 'react';
import { type Key, ChangeEvent, useCallback, useMemo, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import useEffectOnce from 'react-use/esm/useEffectOnce';
import { GetEntityFacetsRequest } from '@backstage/catalog-client';
@@ -25,7 +25,12 @@ import Autocomplete from '@material-ui/lab/Autocomplete';
import { EntityTagsPickerProps } from './schema';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha';
import {
ScaffolderField,
useScaffolderTheme,
} from '@backstage/plugin-scaffolder-react/alpha';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
import { chipStyle, chipRemoveStyle } from '../buiChipStyles';
export { EntityTagsPickerSchema } from './schema';
@@ -36,6 +41,7 @@ export { EntityTagsPickerSchema } from './schema';
* @public
*/
export const EntityTagsPicker = (props: EntityTagsPickerProps) => {
const theme = useScaffolderTheme();
const { t } = useTranslationRef(scaffolderTranslationRef);
const {
formData,
@@ -103,9 +109,117 @@ export const EntityTagsPicker = (props: EntityTagsPickerProps) => {
}
};
// BUI: computed tag options
const buiTagOptions = useMemo(() => {
if (!existingTags) return [];
return Object.keys(existingTags).sort((a, b) =>
showCounts ? existingTags[b] - existingTags[a] : a.localeCompare(b),
);
}, [existingTags, showCounts]);
const selectedTags = useMemo(() => formData || [], [formData]);
const availableOptions = useMemo(
() =>
buiTagOptions
.filter(tag => !selectedTags.includes(tag))
.map(tag => ({
value: tag,
label:
showCounts && existingTags ? `${tag} (${existingTags[tag]})` : tag,
})),
[buiTagOptions, selectedTags, showCounts, existingTags],
);
const handleSelectionChange = useCallback(
(key: Key | null) => {
if (key !== null) {
const tag = String(key);
if (!selectedTags.includes(tag)) {
onChange([...selectedTags, tag]);
}
setInputValue('');
setInputError(false);
} else if (inputValue) {
const newTag = inputValue.toLocaleLowerCase('en-US').trim();
const isValid = tagValidator(newTag);
const isDuplicate = selectedTags.includes(newTag);
setInputError(!isValid);
if (isValid && !isDuplicate) {
onChange([...selectedTags, newTag]);
setInputValue('');
}
}
},
[selectedTags, onChange, inputValue, tagValidator],
);
const handleRemove = useCallback(
(tag: string) => {
onChange(selectedTags.filter(item => item !== tag));
},
[selectedTags, onChange],
);
// Initialize field to always return an array
useEffectOnce(() => onChange(formData || []));
if (theme === 'bui') {
return (
<ScaffolderField
rawErrors={rawErrors}
rawDescription={
(helperText as string) ?? uiSchema['ui:description'] ?? description
}
required={required}
disabled={isDisabled}
errors={errors}
>
<div>
{selectedTags.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 'var(--bui-space-1)',
marginBottom: 'var(--bui-space-2)',
}}
>
{selectedTags.map(tag => (
<span key={tag} style={chipStyle}>
{tag}
{!isDisabled && (
<button
type="button"
onClick={() => handleRemove(tag)}
style={chipRemoveStyle}
aria-label={`Remove ${tag}`}
>
&times;
</button>
)}
</span>
))}
</div>
)}
<BuiAutocomplete
label={title}
isRequired={required}
isDisabled={isDisabled}
selectedKey={null}
inputValue={inputValue}
onInputChange={setInputValue}
onSelectionChange={handleSelectionChange}
isLoading={loading}
options={availableOptions}
allowsCustomValue
isInvalid={inputError || (rawErrors && rawErrors.length > 0)}
/>
</div>
</ScaffolderField>
);
}
return (
<ScaffolderField
rawErrors={rawErrors}
@@ -34,7 +34,7 @@ import Autocomplete, {
AutocompleteChangeReason,
createFilterOptions,
} from '@material-ui/lab/Autocomplete';
import { useCallback, useEffect, useState } from 'react';
import { type Key, useCallback, useEffect, useMemo, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import { FieldValidation } from '@rjsf/utils';
import {
@@ -44,9 +44,14 @@ import {
MultiEntityPickerFilterQuery,
} from './schema';
import { VirtualizedListbox } from '../VirtualizedListbox';
import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha';
import {
ScaffolderField,
useScaffolderTheme,
} from '@backstage/plugin-scaffolder-react/alpha';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { scaffolderTranslationRef } from '../../../translation';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
import { chipStyle, chipRemoveStyle } from '../buiChipStyles';
export { MultiEntityPickerSchema } from './schema';
@@ -61,6 +66,7 @@ const FREE_SOLO_EVENTS: readonly AutocompleteChangeReason[] = [
* field extension.
*/
export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
const theme = useScaffolderTheme();
const { t } = useTranslationRef(scaffolderTranslationRef);
const {
onChange,
@@ -81,6 +87,10 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
const defaultNamespace =
uiSchema['ui:options']?.defaultNamespace || undefined;
const isDisabled = uiSchema?.['ui:disabled'] ?? false;
const allowArbitraryValues =
uiSchema['ui:options']?.allowArbitraryValues ?? true;
const maxItems = props.schema.maxItems;
const [noOfItemsSelected, setNoOfItemsSelected] = useState(0);
const catalogApi = useApi(catalogApiRef);
@@ -106,11 +116,6 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
);
return { entities: items, entityRefToPresentation };
});
const allowArbitraryValues =
uiSchema['ui:options']?.allowArbitraryValues ?? true;
// if not specified, maxItems defaults to undefined
const maxItems = props.schema.maxItems;
const onSelect = useCallback(
(_: any, refs: (string | Entity)[], reason: AutocompleteChangeReason) => {
@@ -156,12 +161,140 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
[onChange, formData, defaultKind, defaultNamespace, allowArbitraryValues],
);
// BUI: options and selection state
const allOptions = useMemo(
() =>
(entities?.entities || []).map(entity => {
const entityRef = stringifyEntityRef(entity);
const presentation = entities?.entityRefToPresentation.get(entityRef);
return {
value: entityRef,
label: presentation?.primaryTitle || entityRef,
};
}),
[entities],
);
const selectedValues = useMemo(() => formData || [], [formData]);
const availableOptions = useMemo(
() => allOptions.filter(o => !selectedValues.includes(o.value)),
[allOptions, selectedValues],
);
const [inputValue, setInputValue] = useState('');
const atMaxItems =
maxItems !== undefined && selectedValues.length >= maxItems;
const handleSelectionChange = useCallback(
(key: Key | null) => {
if (atMaxItems) return;
if (key !== null) {
const newValue = String(key);
if (!selectedValues.includes(newValue)) {
onChange([...selectedValues, newValue]);
}
} else if (allowArbitraryValues && inputValue) {
let entityRef = inputValue;
try {
entityRef = stringifyEntityRef(
parseEntityRef(inputValue, { defaultKind, defaultNamespace }),
);
} catch {
// If the input isn't a valid entity ref, use it as-is
}
if (!selectedValues.includes(entityRef)) {
onChange([...selectedValues, entityRef]);
}
}
setInputValue('');
},
[
atMaxItems,
selectedValues,
onChange,
allowArbitraryValues,
inputValue,
defaultKind,
defaultNamespace,
],
);
const handleRemove = useCallback(
(value: string) => {
onChange(selectedValues.filter(v => v !== value));
},
[selectedValues, onChange],
);
useEffect(() => {
if (required && !allowArbitraryValues && entities?.entities?.length === 1) {
onChange([stringifyEntityRef(entities?.entities[0])]);
onChange([stringifyEntityRef(entities.entities[0])]);
}
}, [entities, onChange, required, allowArbitraryValues]);
if (theme === 'bui') {
const isAutoSelected =
required && !allowArbitraryValues && entities?.entities?.length === 1;
return (
<ScaffolderField
rawErrors={rawErrors}
rawDescription={uiSchema['ui:description'] ?? description}
required={required}
disabled={isDisabled}
errors={errors}
>
<div>
{selectedValues.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 'var(--bui-space-1)',
marginBottom: 'var(--bui-space-2)',
}}
>
{selectedValues.map(value => {
const opt = allOptions.find(o => o.value === value);
return (
<span key={value} style={chipStyle}>
{opt?.label || value}
{!isDisabled && !isAutoSelected && (
<button
type="button"
onClick={() => handleRemove(value)}
style={chipRemoveStyle}
aria-label={`Remove ${opt?.label || value}`}
>
&times;
</button>
)}
</span>
);
})}
</div>
)}
<BuiAutocomplete
id={idSchema?.$id}
label={title}
isRequired={required}
isDisabled={isDisabled || isAutoSelected || atMaxItems}
selectedKey={null}
inputValue={inputValue}
onInputChange={setInputValue}
onSelectionChange={handleSelectionChange}
isLoading={loading}
options={availableOptions}
allowsCustomValue={allowArbitraryValues}
isInvalid={rawErrors && rawErrors.length > 0}
/>
</div>
</ScaffolderField>
);
}
return (
<ScaffolderField
rawErrors={rawErrors}
@@ -14,7 +14,14 @@
* limitations under the License.
*/
import { ChangeEvent, useEffect } from 'react';
import {
type Key,
ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
errorApiRef,
identityApiRef,
@@ -37,11 +44,16 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { VirtualizedListbox } from '../VirtualizedListbox';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha';
import {
ScaffolderField,
useScaffolderTheme,
} from '@backstage/plugin-scaffolder-react/alpha';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
export { MyGroupsPickerSchema };
export const MyGroupsPicker = (props: MyGroupsPickerProps) => {
const theme = useScaffolderTheme();
const { t } = useTranslationRef(scaffolderTranslationRef);
const {
schema: {
@@ -53,6 +65,7 @@ export const MyGroupsPicker = (props: MyGroupsPickerProps) => {
onChange,
formData,
uiSchema,
idSchema,
errors,
} = props;
@@ -96,6 +109,7 @@ export const MyGroupsPicker = (props: MyGroupsPickerProps) => {
return { catalogEntities: items, entityRefToPresentation };
});
// MUI: update handler
const updateChange = (_: ChangeEvent<{}>, value: Entity | null) => {
onChange(value ? stringifyEntityRef(value) : '');
};
@@ -104,12 +118,76 @@ export const MyGroupsPicker = (props: MyGroupsPickerProps) => {
groups?.catalogEntities.find(e => stringifyEntityRef(e) === formData) ||
null;
// BUI: options
const buiOptions = useMemo(
() =>
(groups?.catalogEntities || []).map(entity => {
const entityRef = stringifyEntityRef(entity);
const presentation = groups?.entityRefToPresentation.get(entityRef);
return {
value: entityRef,
label: presentation?.primaryTitle || entityRef,
};
}),
[groups],
);
const [inputValue, setInputValue] = useState('');
useEffect(() => {
if (formData) {
const opt = buiOptions.find(o => o.value === formData);
setInputValue(opt?.label || formData);
} else {
setInputValue('');
}
}, [formData, buiOptions]);
const selectedKey =
formData && buiOptions.some(o => o.value === formData) ? formData : null;
const handleSelectionChange = useCallback(
(key: Key | null) => {
onChange(key !== null ? String(key) : '');
},
[onChange],
);
useEffect(() => {
if (required && groups?.catalogEntities.length === 1 && !selectedEntity) {
onChange(stringifyEntityRef(groups.catalogEntities[0]));
}
}, [groups, onChange, selectedEntity, required]);
if (theme === 'bui') {
const isAutoSelected = required && groups?.catalogEntities.length === 1;
return (
<ScaffolderField
rawErrors={rawErrors}
rawDescription={uiSchema['ui:description'] ?? description}
required={required}
disabled={isDisabled}
errors={errors}
>
<BuiAutocomplete
id={idSchema?.$id}
label={title}
isRequired={required}
isDisabled={isDisabled || isAutoSelected}
selectedKey={selectedKey}
inputValue={inputValue}
onInputChange={setInputValue}
onSelectionChange={handleSelectionChange}
isLoading={loading}
options={buiOptions}
allowsCustomValue={false}
isInvalid={rawErrors && rawErrors.length > 0}
/>
</ScaffolderField>
);
}
return (
<ScaffolderField
rawErrors={rawErrors}
@@ -16,7 +16,7 @@
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import MuiAutocomplete from '@material-ui/lab/Autocomplete';
import useAsync from 'react-use/esm/useAsync';
import { EntityPicker } from '../EntityPicker/EntityPicker';
@@ -24,7 +24,11 @@ import { OwnedEntityPickerProps } from './schema';
import { EntityPickerProps } from '../EntityPicker/schema';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha';
import {
ScaffolderField,
useScaffolderTheme,
} from '@backstage/plugin-scaffolder-react/alpha';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
export { OwnedEntityPickerSchema } from './schema';
@@ -36,6 +40,7 @@ export { OwnedEntityPickerSchema } from './schema';
*/
export const OwnedEntityPicker = (props: OwnedEntityPickerProps) => {
const { t } = useTranslationRef(scaffolderTranslationRef);
const theme = useScaffolderTheme();
const {
schema: {
title = t('fields.ownedEntityPicker.title'),
@@ -51,14 +56,31 @@ export const OwnedEntityPicker = (props: OwnedEntityPickerProps) => {
return identity.ownershipEntityRefs;
});
if (loading)
if (loading) {
if (theme === 'bui') {
return (
<ScaffolderField
rawDescription={uiSchema['ui:description'] ?? description}
required={required}
disabled={uiSchema['ui:disabled']}
>
<BuiAutocomplete
label={title}
isRequired={required}
isLoading
options={[]}
isDisabled
/>
</ScaffolderField>
);
}
return (
<ScaffolderField
rawDescription={uiSchema['ui:description'] ?? description}
required={required}
disabled={uiSchema['ui:disabled']}
>
<Autocomplete
<MuiAutocomplete
loading={loading}
renderInput={params => (
<TextField
@@ -78,6 +100,7 @@ export const OwnedEntityPicker = (props: OwnedEntityPickerProps) => {
/>
</ScaffolderField>
);
}
const entityPickerUISchema = buildEntityPickerUISchema(
uiSchema,
@@ -18,11 +18,14 @@ import { useApi } from '@backstage/core-plugin-api';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import MuiTextField from '@material-ui/core/TextField';
import MuiAutocomplete from '@material-ui/lab/Autocomplete';
import { useCallback, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { BaseRepoBranchPickerProps } from './types';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
import type { Key } from 'react-aria-components';
/**
* The underlying component that is rendered in the form for the `BitbucketRepoBranchPicker`
@@ -41,6 +44,7 @@ export const BitbucketRepoBranchPicker = ({
}: BaseRepoBranchPickerProps<{
accessToken?: string;
}>) => {
const theme = useScaffolderTheme();
const { host, workspace, repository, branch } = state;
const [availableBranches, setAvailableBranches] = useState<string[]>([]);
@@ -76,13 +80,35 @@ export const BitbucketRepoBranchPicker = ({
useDebounce(updateAvailableBranches, 500, [updateAvailableBranches]);
if (theme === 'bui') {
const options = availableBranches.map(b => ({ label: b, value: b }));
return (
<BuiAutocomplete
label="Branch"
description="The branch of the repository"
inputValue={branch ?? ''}
onInputChange={value => onChange({ branch: value })}
onSelectionChange={(key: Key | null) => {
if (key !== null) {
onChange({ branch: String(key) });
}
}}
options={options}
isDisabled={isDisabled}
isRequired={required}
isInvalid={rawErrors?.length > 0 && !branch}
/>
);
}
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !branch}
>
<Autocomplete
<MuiAutocomplete
value={branch}
onChange={(_, newValue) => {
onChange({ branch: newValue || '' });
@@ -90,7 +116,7 @@ export const BitbucketRepoBranchPicker = ({
disabled={isDisabled}
options={availableBranches}
renderInput={params => (
<TextField
<MuiTextField
{...params}
label="Branch"
disabled={isDisabled}
@@ -16,9 +16,11 @@
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import MuiTextField from '@material-ui/core/TextField';
import { BaseRepoBranchPickerProps } from './types';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { TextField as BuiTextField } from '@backstage/ui';
/**
* The underlying component that is rendered in the form for the `DefaultRepoBranchPicker`
@@ -34,15 +36,30 @@ export const DefaultRepoBranchPicker = ({
isDisabled,
required,
}: BaseRepoBranchPickerProps) => {
const theme = useScaffolderTheme();
const { branch } = state;
if (theme === 'bui') {
return (
<BuiTextField
label="Branch"
description="The branch of the repository"
isDisabled={isDisabled}
onChange={value => onChange({ branch: value })}
value={branch ?? ''}
isInvalid={rawErrors?.length > 0 && !branch}
isRequired={required}
/>
);
}
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !branch}
>
<TextField
<MuiTextField
id="branchInput"
label="Branch"
disabled={isDisabled}
@@ -18,11 +18,14 @@ import { useApi } from '@backstage/core-plugin-api';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import MuiTextField from '@material-ui/core/TextField';
import MuiAutocomplete from '@material-ui/lab/Autocomplete';
import { useCallback, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { BaseRepoBranchPickerProps } from './types';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
import type { Key } from 'react-aria-components';
/**
* The underlying component that is rendered in the form for the `GitHubRepoBranchPicker`
@@ -41,6 +44,7 @@ export const GitHubRepoBranchPicker = ({
}: BaseRepoBranchPickerProps<{
accessToken?: string;
}>) => {
const theme = useScaffolderTheme();
const { host, owner, repository, branch } = state;
const [availableBranches, setAvailableBranches] = useState<string[]>([]);
@@ -76,13 +80,35 @@ export const GitHubRepoBranchPicker = ({
useDebounce(updateAvailableBranches, 500, [updateAvailableBranches]);
if (theme === 'bui') {
const options = availableBranches.map(b => ({ label: b, value: b }));
return (
<BuiAutocomplete
label="Branch"
description="The branch of the repository"
inputValue={branch ?? ''}
onInputChange={value => onChange({ branch: value })}
onSelectionChange={(key: Key | null) => {
if (key !== null) {
onChange({ branch: String(key) });
}
}}
options={options}
isDisabled={isDisabled}
isRequired={required}
isInvalid={rawErrors?.length > 0 && !branch}
/>
);
}
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !branch}
>
<Autocomplete
<MuiAutocomplete
value={branch}
onChange={(_, newValue) => {
onChange({ branch: newValue || '' });
@@ -90,7 +116,7 @@ export const GitHubRepoBranchPicker = ({
disabled={isDisabled}
options={availableBranches}
renderInput={params => (
<TextField
<MuiTextField
{...params}
label="Branch"
disabled={isDisabled}
@@ -32,6 +32,8 @@ import { BitbucketRepoBranchPicker } from './BitbucketRepoBranchPicker';
import { DefaultRepoBranchPicker } from './DefaultRepoBranchPicker';
import { GitHubRepoBranchPicker } from './GitHubRepoBranchPicker';
import { MarkdownContent } from '@backstage/core-components';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Flex, Text } from '@backstage/ui';
/**
* The underlying component that is rendered in the form for the `RepoBranchPicker`
@@ -40,6 +42,7 @@ import { MarkdownContent } from '@backstage/core-components';
* @public
*/
export const RepoBranchPicker = (props: RepoBranchPickerProps) => {
const scaffolderTheme = useScaffolderTheme();
const {
uiSchema,
onChange,
@@ -122,6 +125,11 @@ export const RepoBranchPicker = (props: RepoBranchPickerProps) => {
const hostType = (host && integrationApi.byHost(host)?.type) ?? null;
const accessToken =
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey];
const isDisabled = uiSchema?.['ui:disabled'] ?? false;
const renderRepoBranchPicker = () => {
switch (hostType) {
case 'bitbucket':
@@ -130,11 +138,8 @@ export const RepoBranchPicker = (props: RepoBranchPickerProps) => {
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
isDisabled={uiSchema?.['ui:disabled'] ?? false}
accessToken={accessToken}
isDisabled={isDisabled}
required={required}
/>
);
@@ -144,11 +149,8 @@ export const RepoBranchPicker = (props: RepoBranchPickerProps) => {
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
isDisabled={uiSchema?.['ui:disabled'] ?? false}
accessToken={accessToken}
isDisabled={isDisabled}
required={required}
/>
);
@@ -158,7 +160,7 @@ export const RepoBranchPicker = (props: RepoBranchPickerProps) => {
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
isDisabled={uiSchema?.['ui:disabled'] ?? false}
isDisabled={isDisabled}
required={required}
/>
);
@@ -167,6 +169,20 @@ export const RepoBranchPicker = (props: RepoBranchPickerProps) => {
const description = uiSchema['ui:description'] ?? schema.description;
if (scaffolderTheme === 'bui') {
return (
<Flex direction="column" gap="4">
{schema.title && (
<Text as="h5" variant="title-small" weight="bold">
{schema.title}
</Text>
)}
{description && <MarkdownContent content={description} />}
{renderRepoBranchPicker()}
</Flex>
);
}
return (
<>
{schema.title && (
@@ -16,11 +16,13 @@
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import MuiTextField from '@material-ui/core/TextField';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { BaseRepoOwnerPickerProps } from './types';
import { scaffolderTranslationRef } from '../../../translation';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { TextField as BuiTextField } from '@backstage/ui';
/**
* The underlying component that is rendered in the form for the `DefaultRepoOwnerPicker`
@@ -37,17 +39,34 @@ export const DefaultRepoOwnerPicker = ({
required,
schema,
}: BaseRepoOwnerPickerProps) => {
const theme = useScaffolderTheme();
const { owner } = state;
const { t } = useTranslationRef(scaffolderTranslationRef);
if (theme === 'bui') {
return (
<BuiTextField
label={schema?.title ?? t('fields.repoOwnerPicker.title')}
description={
schema?.description ?? t('fields.repoOwnerPicker.description')
}
isDisabled={isDisabled}
onChange={value => onChange({ owner: value })}
value={owner ?? ''}
isInvalid={rawErrors?.length > 0 && !owner}
isRequired={required}
/>
);
}
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !owner}
>
<TextField
<MuiTextField
id="ownerInput"
label={schema?.title ?? t('fields.repoOwnerPicker.title')}
disabled={isDisabled}
@@ -18,14 +18,17 @@ import { useApi } from '@backstage/core-plugin-api';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import MuiTextField from '@material-ui/core/TextField';
import MuiAutocomplete from '@material-ui/lab/Autocomplete';
import { useCallback, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { BaseRepoOwnerPickerProps } from './types';
import { scaffolderTranslationRef } from '../../../translation';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
import type { Key } from 'react-aria-components';
/**
* The underlying component that is rendered in the form for the `GitHubRepoOwnerPicker`
@@ -47,6 +50,7 @@ export const GitHubRepoOwnerPicker = ({
accessToken?: string;
excludedOwners?: string[];
}>) => {
const theme = useScaffolderTheme();
const { host, owner } = state;
const [availableOwners, setAvailableOwners] = useState<string[]>([]);
@@ -81,13 +85,37 @@ export const GitHubRepoOwnerPicker = ({
useDebounce(updateAvailableOwners, 500, [updateAvailableOwners]);
if (theme === 'bui') {
const options = availableOwners.map(o => ({ label: o, value: o }));
return (
<BuiAutocomplete
label={schema?.title ?? t('fields.repoOwnerPicker.title')}
description={
schema?.description ?? t('fields.repoOwnerPicker.description')
}
inputValue={owner ?? ''}
onInputChange={value => onChange({ owner: value })}
onSelectionChange={(key: Key | null) => {
if (key !== null) {
onChange({ owner: String(key) });
}
}}
options={options}
isDisabled={isDisabled}
isRequired={required}
isInvalid={rawErrors?.length > 0 && !owner}
/>
);
}
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !owner}
>
<Autocomplete
<MuiAutocomplete
value={owner}
onChange={(_, newValue) => {
onChange({ owner: newValue || '' });
@@ -95,7 +123,7 @@ export const GitHubRepoOwnerPicker = ({
disabled={isDisabled}
options={availableOwners}
renderInput={params => (
<TextField
<MuiTextField
{...params}
label={schema?.title ?? t('fields.repoOwnerPicker.title')}
disabled={isDisabled}
@@ -27,7 +27,6 @@ import { RepoOwnerPickerProps } from './schema';
import { RepoOwnerPickerState } from './types';
import { DefaultRepoOwnerPicker } from './DefaultRepoOwnerPicker';
import { GitHubRepoOwnerPicker } from './GitHubRepoOwnerPicker';
/**
* The underlying component that is rendered in the form for the `RepoOwnerPicker`
* field extension.
@@ -102,37 +101,33 @@ export const RepoOwnerPicker = (props: RepoOwnerPickerProps) => {
const hostType = (host && integrationApi.byHost(host)?.type) ?? null;
const renderRepoOwnerPicker = () => {
switch (hostType) {
case 'github':
return (
<GitHubRepoOwnerPicker
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
isDisabled={uiSchema?.['ui:disabled'] ?? false}
required={required}
schema={schema}
excludedOwners={excludedOwners}
/>
);
default:
return (
<DefaultRepoOwnerPicker
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
isDisabled={uiSchema?.['ui:disabled'] ?? false}
required={required}
schema={schema}
/>
);
}
};
return renderRepoOwnerPicker();
switch (hostType) {
case 'github':
return (
<GitHubRepoOwnerPicker
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
isDisabled={uiSchema?.['ui:disabled'] ?? false}
required={required}
schema={schema}
excludedOwners={excludedOwners}
/>
);
default:
return (
<DefaultRepoOwnerPicker
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
isDisabled={uiSchema?.['ui:disabled'] ?? false}
required={required}
schema={schema}
/>
);
}
};
@@ -16,11 +16,15 @@
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import MuiTextField from '@material-ui/core/TextField';
import { BaseRepoUrlPickerProps } from './types';
import { Select, SelectItem } from '@backstage/core-components';
import { Select as MuiSelect, SelectItem } from '@backstage/core-components';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { TextField as BuiTextField, Select as BuiSelect } from '@backstage/ui';
import overrides from '../scaffolderFieldOverrides.module.css';
import type { Key } from 'react-aria-components';
export const AzureRepoPicker = (
props: BaseRepoUrlPickerProps<{
@@ -28,6 +32,7 @@ export const AzureRepoPicker = (
allowedProject?: string[];
}>,
) => {
const theme = useScaffolderTheme();
const {
allowedOrganizations = [],
allowedProject = [],
@@ -38,6 +43,91 @@ export const AzureRepoPicker = (
} = props;
const { t } = useTranslationRef(scaffolderTranslationRef);
const { organization, project } = state;
if (theme === 'bui') {
const renderOrganizationPicker = () => {
if (allowedOrganizations?.length) {
const organizationItems = allowedOrganizations.map(i => ({
label: i,
value: i,
}));
return (
<BuiSelect
className={overrides.select}
label={t('fields.azureRepoPicker.organization.title')}
description={t('fields.azureRepoPicker.organization.description')}
isDisabled={isDisabled || allowedOrganizations.length === 1}
isInvalid={rawErrors?.length > 0 && !organization}
selectedKey={organization ?? null}
onSelectionChange={(key: Key | null) => {
if (key !== null) onChange({ organization: String(key) });
}}
options={organizationItems}
isRequired
/>
);
}
return (
<BuiTextField
label={t('fields.azureRepoPicker.organization.title')}
description={t('fields.azureRepoPicker.organization.description')}
onChange={value => onChange({ organization: value })}
isDisabled={isDisabled}
value={organization ?? ''}
isInvalid={rawErrors?.length > 0 && !organization}
isRequired
/>
);
};
const renderProjectPicker = () => {
if (allowedProject?.length) {
const projectItems = allowedProject.map(i => ({
label: i,
value: i,
}));
return (
<BuiSelect
className={overrides.select}
label={t('fields.azureRepoPicker.project.title')}
description={t('fields.azureRepoPicker.project.description')}
isDisabled={isDisabled || allowedProject.length === 1}
isInvalid={rawErrors?.length > 0 && !project}
selectedKey={project ?? null}
onSelectionChange={(key: Key | null) => {
if (key !== null) onChange({ project: String(key) });
}}
options={projectItems}
isRequired
/>
);
}
return (
<BuiTextField
label={t('fields.azureRepoPicker.project.title')}
description={t('fields.azureRepoPicker.project.description')}
onChange={value => onChange({ project: value })}
value={project ?? ''}
isDisabled={isDisabled}
isInvalid={rawErrors?.length > 0 && !project}
isRequired
/>
);
};
return (
<>
{renderOrganizationPicker()}
{renderProjectPicker()}
</>
);
}
const organizationItems: SelectItem[] = allowedOrganizations
? allowedOrganizations.map(i => ({ label: i, value: i }))
: [{ label: 'Loading...', value: 'loading' }];
@@ -46,8 +136,6 @@ export const AzureRepoPicker = (
? allowedProject.map(i => ({ label: i, value: i }))
: [{ label: 'Loading...', value: 'loading' }];
const { organization, project } = state;
return (
<>
<FormControl
@@ -57,7 +145,7 @@ export const AzureRepoPicker = (
>
{allowedOrganizations?.length ? (
<>
<Select
<MuiSelect
native
label={t('fields.azureRepoPicker.organization.title')}
onChange={s =>
@@ -72,7 +160,7 @@ export const AzureRepoPicker = (
</FormHelperText>
</>
) : (
<TextField
<MuiTextField
id="orgInput"
label={t('fields.azureRepoPicker.organization.title')}
onChange={e => onChange({ organization: e.target.value })}
@@ -89,7 +177,7 @@ export const AzureRepoPicker = (
>
{allowedProject?.length ? (
<>
<Select
<MuiSelect
native
label={t('fields.azureRepoPicker.project.title')}
onChange={s =>
@@ -104,7 +192,7 @@ export const AzureRepoPicker = (
</FormHelperText>
</>
) : (
<TextField
<MuiTextField
id="projectInput"
label={t('fields.azureRepoPicker.project.title')}
onChange={e => onChange({ project: e.target.value })}
@@ -13,18 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Select, SelectItem } from '@backstage/core-components';
import { Select as MuiSelect, SelectItem } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import MuiTextField from '@material-ui/core/TextField';
import MuiAutocomplete from '@material-ui/lab/Autocomplete';
import { useCallback, useEffect, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { scaffolderTranslationRef } from '../../../translation';
import { BaseRepoUrlPickerProps } from './types';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Select as BuiSelect } from '@backstage/ui';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
import overrides from '../scaffolderFieldOverrides.module.css';
import type { Key } from 'react-aria-components';
/**
* The underlying component that is rendered in the form for the `BitbucketRepoPicker`
@@ -42,6 +47,7 @@ export const BitbucketRepoPicker = (
accessToken?: string;
}>,
) => {
const theme = useScaffolderTheme();
const {
allowedOwners = [],
allowedProjects = [],
@@ -54,12 +60,6 @@ export const BitbucketRepoPicker = (
const { t } = useTranslationRef(scaffolderTranslationRef);
const { host, workspace, project } = state;
const ownerItems: SelectItem[] = allowedOwners
? allowedOwners?.map(i => ({ label: i, value: i }))
: [];
const projectItems: SelectItem[] = allowedProjects
? allowedProjects?.map(i => ({ label: i, value: i }))
: [];
useEffect(() => {
if (host === 'bitbucket.org' && allowedOwners.length) {
@@ -151,9 +151,7 @@ export const BitbucketRepoPicker = (
})
.then(({ results }) => {
onChange({
availableRepos: results.map(r => {
return { name: r.id };
}),
availableRepos: results.map(r => ({ name: r.id })),
});
})
.catch(() => {
@@ -163,6 +161,114 @@ export const BitbucketRepoPicker = (
useDebounce(updateAvailableRepositories, 500, [updateAvailableRepositories]);
if (theme === 'bui') {
const renderWorkspacePicker = () => {
if (host !== 'bitbucket.org') return null;
if (allowedOwners?.length) {
const ownerItems = allowedOwners.map(i => ({ label: i, value: i }));
return (
<BuiSelect
className={overrides.select}
label={t('fields.bitbucketRepoPicker.workspaces.title')}
description={t('fields.bitbucketRepoPicker.workspaces.description')}
isDisabled={isDisabled || allowedOwners.length === 1}
isInvalid={rawErrors?.length > 0 && !workspace}
selectedKey={workspace ?? null}
onSelectionChange={(key: Key | null) => {
if (key !== null) onChange({ workspace: String(key) });
}}
options={ownerItems}
isRequired
/>
);
}
const workspaceOptions = availableWorkspaces.map(w => ({
label: w,
value: w,
}));
return (
<BuiAutocomplete
label={t('fields.bitbucketRepoPicker.workspaces.inputTitle')}
description={t('fields.bitbucketRepoPicker.workspaces.description')}
inputValue={workspace ?? ''}
onInputChange={value => onChange({ workspace: value })}
onSelectionChange={(key: Key | null) => {
if (key !== null) {
onChange({ workspace: String(key) });
}
}}
options={workspaceOptions}
isDisabled={isDisabled}
isRequired
isInvalid={rawErrors?.length > 0 && !workspace}
/>
);
};
const renderProjectPicker = () => {
if (allowedProjects?.length) {
const projectItems = allowedProjects.map(i => ({ label: i, value: i }));
return (
<BuiSelect
className={overrides.select}
label={t('fields.bitbucketRepoPicker.project.title')}
description={t('fields.bitbucketRepoPicker.project.description')}
isDisabled={isDisabled || allowedProjects.length === 1}
isInvalid={rawErrors?.length > 0 && !project}
selectedKey={project ?? null}
onSelectionChange={(key: Key | null) => {
if (key !== null) onChange({ project: String(key) });
}}
options={projectItems}
isRequired
/>
);
}
const projectOptions = availableProjects.map(p => ({
label: p,
value: p,
}));
return (
<BuiAutocomplete
label={t('fields.bitbucketRepoPicker.project.inputTitle')}
description={t('fields.bitbucketRepoPicker.project.description')}
inputValue={project ?? ''}
onInputChange={value => onChange({ project: value })}
onSelectionChange={(key: Key | null) => {
if (key !== null) {
onChange({ project: String(key) });
}
}}
options={projectOptions}
isDisabled={isDisabled}
isRequired
isInvalid={rawErrors?.length > 0 && !project}
/>
);
};
return (
<>
{renderWorkspacePicker()}
{renderProjectPicker()}
</>
);
}
const ownerItems: SelectItem[] = allowedOwners
? allowedOwners?.map(i => ({ label: i, value: i }))
: [];
const projectItems: SelectItem[] = allowedProjects
? allowedProjects?.map(i => ({ label: i, value: i }))
: [];
return (
<>
{host === 'bitbucket.org' && (
@@ -172,7 +278,7 @@ export const BitbucketRepoPicker = (
error={rawErrors?.length > 0 && !workspace}
>
{allowedOwners?.length ? (
<Select
<MuiSelect
native
label={t('fields.bitbucketRepoPicker.workspaces.title')}
onChange={s =>
@@ -183,14 +289,14 @@ export const BitbucketRepoPicker = (
items={ownerItems}
/>
) : (
<Autocomplete
<MuiAutocomplete
value={workspace}
onChange={(_, newValue) => {
onChange({ workspace: newValue || '' });
}}
options={availableWorkspaces}
renderInput={params => (
<TextField
<MuiTextField
{...params}
label={t('fields.bitbucketRepoPicker.workspaces.inputTitle')}
disabled={isDisabled}
@@ -213,7 +319,7 @@ export const BitbucketRepoPicker = (
error={rawErrors?.length > 0 && !project}
>
{allowedProjects?.length ? (
<Select
<MuiSelect
native
label={t('fields.bitbucketRepoPicker.project.title')}
onChange={s =>
@@ -224,7 +330,7 @@ export const BitbucketRepoPicker = (
items={projectItems}
/>
) : (
<Autocomplete
<MuiAutocomplete
value={project}
onChange={(_, newValue) => {
onChange({ project: newValue || '' });
@@ -232,7 +338,7 @@ export const BitbucketRepoPicker = (
options={availableProjects}
disabled={isDisabled}
renderInput={params => (
<TextField
<MuiTextField
{...params}
label={t('fields.bitbucketRepoPicker.project.inputTitle')}
disabled={isDisabled}
@@ -14,19 +14,47 @@
* limitations under the License.
*/
import FormControl from '@material-ui/core/FormControl';
import TextField from '@material-ui/core/TextField';
import MuiTextField from '@material-ui/core/TextField';
import { BaseRepoUrlPickerProps } from './types';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { TextField as BuiTextField } from '@backstage/ui';
export const GerritRepoPicker = (props: BaseRepoUrlPickerProps) => {
const theme = useScaffolderTheme();
const { onChange, rawErrors, state, isDisabled } = props;
const { t } = useTranslationRef(scaffolderTranslationRef);
const { workspace, owner } = state;
if (theme === 'bui') {
return (
<>
<BuiTextField
label={t('fields.gerritRepoPicker.owner.title')}
description={t('fields.gerritRepoPicker.owner.description')}
onChange={value => onChange({ owner: value })}
isDisabled={isDisabled}
value={owner ?? ''}
isInvalid={rawErrors?.length > 0 && !owner}
/>
<BuiTextField
label={t('fields.gerritRepoPicker.parent.title')}
description={t('fields.gerritRepoPicker.parent.description')}
onChange={value => onChange({ workspace: value })}
isDisabled={isDisabled}
value={workspace ?? ''}
isInvalid={rawErrors?.length > 0 && !workspace}
isRequired
/>
</>
);
}
return (
<>
<FormControl margin="normal" error={rawErrors?.length > 0 && !workspace}>
<TextField
<MuiTextField
id="ownerInput"
label={t('fields.gerritRepoPicker.owner.title')}
onChange={e => onChange({ owner: e.target.value })}
@@ -40,7 +68,7 @@ export const GerritRepoPicker = (props: BaseRepoUrlPickerProps) => {
required
error={rawErrors?.length > 0 && !workspace}
>
<TextField
<MuiTextField
id="parentInput"
label={t('fields.gerritRepoPicker.parent.title')}
onChange={e => onChange({ workspace: e.target.value })}
@@ -15,11 +15,15 @@
*/
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import { Select, SelectItem } from '@backstage/core-components';
import MuiTextField from '@material-ui/core/TextField';
import { Select as MuiSelect, SelectItem } from '@backstage/core-components';
import { BaseRepoUrlPickerProps } from './types';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { TextField as BuiTextField, Select as BuiSelect } from '@backstage/ui';
import overrides from '../scaffolderFieldOverrides.module.css';
import type { Key } from 'react-aria-components';
export const GiteaRepoPicker = (
props: BaseRepoUrlPickerProps<{
@@ -27,14 +31,50 @@ export const GiteaRepoPicker = (
allowedRepos?: string[];
}>,
) => {
const theme = useScaffolderTheme();
const { allowedOwners = [], state, onChange, rawErrors, isDisabled } = props;
const { t } = useTranslationRef(scaffolderTranslationRef);
const { owner } = state;
if (theme === 'bui') {
if (allowedOwners?.length) {
const ownerItems = allowedOwners.map(i => ({ label: i, value: i }));
return (
<BuiSelect
className={overrides.select}
label={t('fields.giteaRepoPicker.owner.title')}
description={t('fields.giteaRepoPicker.owner.description')}
isDisabled={isDisabled || allowedOwners.length === 1}
isInvalid={rawErrors?.length > 0 && !owner}
selectedKey={owner ?? null}
onSelectionChange={(key: Key | null) => {
if (key !== null) onChange({ owner: String(key) });
}}
options={ownerItems}
isRequired
/>
);
}
return (
<BuiTextField
label={t('fields.giteaRepoPicker.owner.inputTitle')}
description={t('fields.giteaRepoPicker.owner.description')}
onChange={value => onChange({ owner: value })}
isDisabled={isDisabled}
value={owner ?? ''}
isInvalid={rawErrors?.length > 0 && !owner}
isRequired
/>
);
}
const ownerItems: SelectItem[] = allowedOwners
? allowedOwners.map(i => ({ label: i, value: i }))
: [{ label: 'Loading...', value: 'loading' }];
const { owner } = state;
return (
<>
<FormControl
@@ -44,7 +84,7 @@ export const GiteaRepoPicker = (
>
{allowedOwners?.length ? (
<>
<Select
<MuiSelect
native
label={t('fields.giteaRepoPicker.owner.title')}
onChange={selected =>
@@ -64,7 +104,7 @@ export const GiteaRepoPicker = (
</>
) : (
<>
<TextField
<MuiTextField
id="ownerInput"
label={t('fields.giteaRepoPicker.owner.inputTitle')}
onChange={e => onChange({ owner: e.target.value })}
@@ -16,17 +16,22 @@
import { useCallback, useMemo, useState } from 'react';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import { Select, SelectItem } from '@backstage/core-components';
import MuiTextField from '@material-ui/core/TextField';
import { Select as MuiSelect, SelectItem } from '@backstage/core-components';
import { BaseRepoUrlPickerProps } from './types';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { useApi } from '@backstage/core-plugin-api';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import useDebounce from 'react-use/esm/useDebounce';
import Autocomplete from '@material-ui/lab/Autocomplete';
import MuiAutocomplete from '@material-ui/lab/Autocomplete';
import uniq from 'lodash/uniq';
import map from 'lodash/map';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Select as BuiSelect } from '@backstage/ui';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
import overrides from '../scaffolderFieldOverrides.module.css';
import type { Key } from 'react-aria-components';
export const GithubRepoPicker = (
props: BaseRepoUrlPickerProps<{
@@ -34,6 +39,7 @@ export const GithubRepoPicker = (
accessToken?: string;
}>,
) => {
const theme = useScaffolderTheme();
const {
allowedOwners = [],
rawErrors,
@@ -43,9 +49,6 @@ export const GithubRepoPicker = (
isDisabled,
} = props;
const { t } = useTranslationRef(scaffolderTranslationRef);
const ownerItems: SelectItem[] = allowedOwners
? allowedOwners.map(i => ({ label: i, value: i }))
: [{ label: 'Loading...', value: 'loading' }];
const { host, owner } = state;
@@ -102,6 +105,52 @@ export const GithubRepoPicker = (
useDebounce(updateAvailableRepositories, 500, [updateAvailableRepositories]);
if (theme === 'bui') {
if (allowedOwners?.length) {
const ownerItems = allowedOwners.map(i => ({ label: i, value: i }));
return (
<BuiSelect
className={overrides.select}
label={t('fields.githubRepoPicker.owner.title')}
description={t('fields.githubRepoPicker.owner.description')}
isDisabled={isDisabled || allowedOwners.length === 1}
isInvalid={rawErrors?.length > 0 && !owner}
selectedKey={owner ?? null}
onSelectionChange={(key: Key | null) => {
if (key !== null) onChange({ owner: String(key) });
}}
options={ownerItems}
isRequired
/>
);
}
const options = availableOwners.map(o => ({ label: o, value: o }));
return (
<BuiAutocomplete
label={t('fields.githubRepoPicker.owner.inputTitle')}
description={t('fields.githubRepoPicker.owner.description')}
inputValue={owner ?? ''}
onInputChange={value => onChange({ owner: value })}
onSelectionChange={(key: Key | null) => {
if (key !== null) {
onChange({ owner: String(key) });
}
}}
options={options}
isDisabled={isDisabled}
isRequired
isInvalid={rawErrors?.length > 0 && !owner}
/>
);
}
const ownerItems: SelectItem[] = allowedOwners
? allowedOwners.map(i => ({ label: i, value: i }))
: [{ label: 'Loading...', value: 'loading' }];
return (
<>
<FormControl
@@ -111,7 +160,7 @@ export const GithubRepoPicker = (
>
{allowedOwners?.length ? (
<>
<Select
<MuiSelect
native
label={t('fields.githubRepoPicker.owner.title')}
onChange={s =>
@@ -123,14 +172,14 @@ export const GithubRepoPicker = (
/>
</>
) : (
<Autocomplete
<MuiAutocomplete
value={owner}
onChange={(_, newValue) => {
onChange({ owner: newValue || '' });
}}
options={availableOwners}
renderInput={params => (
<TextField
<MuiTextField
{...params}
label={t('fields.githubRepoPicker.owner.inputTitle')}
disabled={isDisabled}
@@ -13,18 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Select, SelectItem } from '@backstage/core-components';
import { Select as MuiSelect, SelectItem } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import MuiTextField from '@material-ui/core/TextField';
import MuiAutocomplete from '@material-ui/lab/Autocomplete';
import { useCallback, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { scaffolderTranslationRef } from '../../../translation';
import { BaseRepoUrlPickerProps } from './types';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Select as BuiSelect } from '@backstage/ui';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
import overrides from '../scaffolderFieldOverrides.module.css';
import type { Key } from 'react-aria-components';
export const GitlabRepoPicker = (
props: BaseRepoUrlPickerProps<{
@@ -33,6 +38,7 @@ export const GitlabRepoPicker = (
accessToken?: string;
}>,
) => {
const theme = useScaffolderTheme();
const {
allowedOwners = [],
state,
@@ -45,9 +51,6 @@ export const GitlabRepoPicker = (
{ title: string; id: string }[]
>([]);
const { t } = useTranslationRef(scaffolderTranslationRef);
const ownerItems: SelectItem[] = allowedOwners
? allowedOwners.map(i => ({ label: i, value: i }))
: [{ label: 'Loading...', value: 'loading' }];
const { owner, host } = state;
@@ -68,12 +71,10 @@ export const GitlabRepoPicker = (
})
.then(({ results }) => {
setAvailableGroups(
results.map(r => {
return {
title: r.title!,
id: r.id,
};
}),
results.map(r => ({
title: r.title!,
id: r.id,
})),
);
})
.catch(() => {
@@ -104,9 +105,7 @@ export const GitlabRepoPicker = (
})
.then(({ results }) => {
onChange({
availableRepos: results.map(r => {
return { name: r.title!, id: r.id };
}),
availableRepos: results.map(r => ({ name: r.title!, id: r.id })),
});
})
.catch(() => {
@@ -116,6 +115,55 @@ export const GitlabRepoPicker = (
useDebounce(updateAvailableRepositories, 500, [updateAvailableRepositories]);
if (theme === 'bui') {
if (allowedOwners?.length) {
const ownerItems = allowedOwners.map(i => ({ label: i, value: i }));
return (
<BuiSelect
className={overrides.select}
label={t('fields.gitlabRepoPicker.owner.title')}
description={t('fields.gitlabRepoPicker.owner.description')}
isDisabled={isDisabled || allowedOwners.length === 1}
isInvalid={rawErrors?.length > 0 && !owner}
selectedKey={owner ?? null}
onSelectionChange={(key: Key | null) => {
if (key !== null) onChange({ owner: String(key) });
}}
options={ownerItems}
isRequired
/>
);
}
const options = availableGroups.map(group => ({
label: group.title,
value: group.title,
}));
return (
<BuiAutocomplete
label={t('fields.gitlabRepoPicker.owner.inputTitle')}
description={t('fields.gitlabRepoPicker.owner.description')}
inputValue={owner ?? ''}
onInputChange={value => onChange({ owner: value })}
onSelectionChange={(key: Key | null) => {
if (key !== null) {
onChange({ owner: String(key) });
}
}}
options={options}
isDisabled={isDisabled}
isRequired
isInvalid={rawErrors?.length > 0 && !owner}
/>
);
}
const ownerItems: SelectItem[] = allowedOwners
? allowedOwners.map(i => ({ label: i, value: i }))
: [{ label: 'Loading...', value: 'loading' }];
return (
<>
<FormControl
@@ -125,7 +173,7 @@ export const GitlabRepoPicker = (
>
{allowedOwners?.length ? (
<>
<Select
<MuiSelect
native
label={t('fields.gitlabRepoPicker.owner.title')}
onChange={selected =>
@@ -141,14 +189,14 @@ export const GitlabRepoPicker = (
/>
</>
) : (
<Autocomplete
<MuiAutocomplete
value={owner}
onChange={(_, newValue) => {
onChange({ owner: newValue || '' });
}}
options={availableGroups.map(group => group.title)}
renderInput={params => (
<TextField
<MuiTextField
{...params}
label={t('fields.gitlabRepoPicker.owner.inputTitle')}
disabled={isDisabled}
@@ -36,6 +36,8 @@ import { RepoUrlPickerFieldSchema } from './schema';
import { RepoUrlPickerState } from './types';
import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils';
import { MarkdownContent } from '@backstage/core-components';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Flex, Text } from '@backstage/ui';
export { RepoUrlPickerSchema } from './schema';
@@ -48,6 +50,7 @@ export { RepoUrlPickerSchema } from './schema';
export const RepoUrlPicker = (
props: typeof RepoUrlPickerFieldSchema.TProps,
) => {
const scaffolderTheme = useScaffolderTheme();
const { uiSchema, onChange, rawErrors, formData, schema } = props;
const [state, setState] = useState<RepoUrlPickerState>(
parseRepoPickerUrl(formData),
@@ -170,19 +173,12 @@ export const RepoUrlPicker = (
const description = uiSchema['ui:description'] ?? schema.description;
return (
const accessToken =
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey];
const subComponents = (
<>
{schema.title && (
<Box my={1}>
<Typography variant="h5">{schema.title}</Typography>
<Divider />
</Box>
)}
{description && (
<Typography variant="body1">
<MarkdownContent content={description} />
</Typography>
)}
<RepoUrlPickerHost
host={state.host}
hosts={allowedHosts}
@@ -197,10 +193,7 @@ export const RepoUrlPicker = (
rawErrors={rawErrors}
state={state}
isDisabled={isDisabled}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
accessToken={accessToken}
/>
)}
{hostType === 'gitea' && (
@@ -220,10 +213,7 @@ export const RepoUrlPicker = (
state={state}
onChange={updateLocalState}
isDisabled={isDisabled}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
accessToken={accessToken}
/>
)}
{hostType === 'bitbucket' && (
@@ -234,10 +224,7 @@ export const RepoUrlPicker = (
state={state}
onChange={updateLocalState}
isDisabled={isDisabled}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
accessToken={accessToken}
/>
)}
{hostType === 'azure' && (
@@ -273,4 +260,35 @@ export const RepoUrlPicker = (
/>
</>
);
if (scaffolderTheme === 'bui') {
return (
<Flex direction="column" gap="4">
{schema.title && (
<Text as="h5" variant="title-small" weight="bold">
{schema.title}
</Text>
)}
{description && <MarkdownContent content={description} />}
{subComponents}
</Flex>
);
}
return (
<>
{schema.title && (
<Box my={1}>
<Typography variant="h5">{schema.title}</Typography>
<Divider />
</Box>
)}
{description && (
<Typography variant="body1">
<MarkdownContent content={description} />
</Typography>
)}
{subComponents}
</>
);
};
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { useEffect } from 'react';
import { Progress, Select, SelectItem } from '@backstage/core-components';
import {
Progress,
Select as MuiSelect,
SelectItem,
} from '@backstage/core-components';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import { useApi } from '@backstage/core-plugin-api';
@@ -22,6 +26,10 @@ import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import useAsync from 'react-use/esm/useAsync';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Select as BuiSelect } from '@backstage/ui';
import overrides from '../scaffolderFieldOverrides.module.css';
import type { Key } from 'react-aria-components';
export const RepoUrlPickerHost = (props: {
host?: string;
@@ -30,6 +38,7 @@ export const RepoUrlPickerHost = (props: {
rawErrors: string[];
isDisabled?: boolean;
}) => {
const theme = useScaffolderTheme();
const { host, hosts, onChange, rawErrors, isDisabled } = props;
const { t } = useTranslationRef(scaffolderTranslationRef);
const scaffolderApi = useApi(scaffolderApiRef);
@@ -57,7 +66,7 @@ export const RepoUrlPickerHost = (props: {
// If there are no allowedHosts provided, then show all integrations. Otherwise, only show integrations
// that are provided in the dropdown for the user to choose from.
const hostsOptions: SelectItem[] = integrations
const hostsOptions = integrations
? integrations
.filter(i => (hosts?.length ? hosts?.includes(i.host) : true))
.map(i => ({ label: i.title, value: i.host }))
@@ -67,6 +76,27 @@ export const RepoUrlPickerHost = (props: {
return <Progress />;
}
if (theme === 'bui') {
return (
<BuiSelect
className={overrides.select}
label={t('fields.repoUrlPicker.host.title')}
description={t('fields.repoUrlPicker.host.description')}
isDisabled={isDisabled || hosts?.length === 1}
isInvalid={rawErrors?.length > 0 && !host}
selectedKey={host ?? null}
onSelectionChange={(key: Key | null) => {
if (key !== null) onChange(String(key));
}}
options={hostsOptions}
isRequired
data-testid="host-select"
/>
);
}
const muiHostsOptions: SelectItem[] = hostsOptions;
return (
<>
<FormControl
@@ -74,13 +104,13 @@ export const RepoUrlPickerHost = (props: {
required
error={rawErrors?.length > 0 && !host}
>
<Select
<MuiSelect
native
disabled={isDisabled || hosts?.length === 1}
label={t('fields.repoUrlPicker.host.title')}
onChange={s => onChange(String(Array.isArray(s) ? s[0] : s))}
selected={host}
items={hostsOptions}
items={muiHostsOptions}
data-testid="host-select"
/>
@@ -13,15 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Select, SelectItem } from '@backstage/core-components';
import { Select as MuiSelect, SelectItem } from '@backstage/core-components';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import MuiTextField from '@material-ui/core/TextField';
import MuiAutocomplete from '@material-ui/lab/Autocomplete';
import { useEffect } from 'react';
import { scaffolderTranslationRef } from '../../../translation';
import { AvailableRepositories } from './types';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Select as BuiSelect } from '@backstage/ui';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
import overrides from '../scaffolderFieldOverrides.module.css';
import type { Key } from 'react-aria-components';
export const RepoUrlPickerRepoName = (props: {
repoName?: string;
@@ -31,6 +36,7 @@ export const RepoUrlPickerRepoName = (props: {
availableRepos?: AvailableRepositories[];
isDisabled?: boolean;
}) => {
const theme = useScaffolderTheme();
const {
repoName,
allowedRepos,
@@ -51,6 +57,57 @@ export const RepoUrlPickerRepoName = (props: {
}
}, [allowedRepos, repoName, onChange]);
if (theme === 'bui') {
if (allowedRepos?.length) {
const repoItems = allowedRepos.map(i => ({ label: i, value: i }));
return (
<BuiSelect
className={overrides.select}
label={t('fields.repoUrlPicker.repository.title')}
description={t('fields.repoUrlPicker.repository.description')}
isDisabled={isDisabled || allowedRepos.length === 1}
isInvalid={rawErrors?.length > 0 && !repoName}
selectedKey={repoName ?? null}
onSelectionChange={(key: Key | null) => {
if (key !== null) onChange({ name: String(key) });
}}
options={repoItems}
isRequired
/>
);
}
const options = (availableRepos || []).map(r => ({
label: r.name,
value: r.name,
}));
return (
<BuiAutocomplete
label={t('fields.repoUrlPicker.repository.inputTitle')}
description={t('fields.repoUrlPicker.repository.description')}
inputValue={repoName ?? ''}
onInputChange={value => {
const selectedRepo = availableRepos?.find(r => r.name === value);
onChange(selectedRepo || { name: value });
}}
onSelectionChange={(key: Key | null) => {
if (key !== null) {
const selectedRepo = availableRepos?.find(
r => r.name === String(key),
);
onChange(selectedRepo || { name: String(key) });
}
}}
options={options}
isDisabled={isDisabled}
isRequired
isInvalid={rawErrors?.length > 0 && !repoName}
/>
);
}
const repoItems: SelectItem[] = allowedRepos
? allowedRepos.map(i => ({ label: i, value: i }))
: [{ label: 'Loading...', value: 'loading' }];
@@ -63,7 +120,7 @@ export const RepoUrlPickerRepoName = (props: {
error={rawErrors?.length > 0 && !repoName}
>
{allowedRepos?.length ? (
<Select
<MuiSelect
native
label={t('fields.repoUrlPicker.repository.title')}
onChange={selected =>
@@ -76,7 +133,7 @@ export const RepoUrlPickerRepoName = (props: {
items={repoItems}
/>
) : (
<Autocomplete
<MuiAutocomplete
value={repoName}
onChange={(_, newValue) => {
const selectedRepo = availableRepos?.find(
@@ -86,7 +143,7 @@ export const RepoUrlPickerRepoName = (props: {
}}
options={(availableRepos || []).map(r => r.name)}
renderInput={params => (
<TextField
<MuiTextField
{...params}
label={t('fields.repoUrlPicker.repository.inputTitle')}
required
@@ -13,15 +13,25 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ScaffolderRJSFFieldProps } from '@backstage/plugin-scaffolder-react';
import {
ScaffolderRJSFFieldProps,
useTemplateSecrets,
} from '@backstage/plugin-scaffolder-react';
import {
ScaffolderField,
SecretWidget,
useScaffolderTheme,
} from '@backstage/plugin-scaffolder-react/alpha';
import { PasswordField } from '@backstage/ui';
import { useEffect, useMemo, useState } from 'react';
import debounce from 'lodash/debounce';
export const SecretInput = (props: ScaffolderRJSFFieldProps) => {
const theme = useScaffolderTheme();
const {
schema: { description },
name,
onChange,
schema: { title, description, minLength, maxLength },
rawErrors,
disabled,
errors,
@@ -29,6 +39,62 @@ export const SecretInput = (props: ScaffolderRJSFFieldProps) => {
uiSchema,
} = props;
const { setSecrets, secrets } = useTemplateSecrets();
const [localValue, setLocalValue] = useState(secrets[name] ?? '');
const debouncedSetSecrets = useMemo(
() =>
debounce((value: string) => {
setSecrets({ [name]: value });
}, 300),
[setSecrets, name],
);
// Persist any in-flight value on unmount so the secret isn't lost if the
// user navigates away within the debounce window.
useEffect(() => {
return () => {
debouncedSetSecrets.flush();
};
}, [debouncedSetSecrets]);
// Mirror external changes to secrets back into the input so resets or
// programmatic updates aren't shadowed by stale local state.
useEffect(() => {
setLocalValue(secrets[name] ?? '');
}, [secrets, name]);
const handleChange = (value: string) => {
setLocalValue(value);
onChange(Array(value.length).fill('*').join(''));
debouncedSetSecrets(value);
};
if (theme === 'bui') {
return (
<ScaffolderField
rawErrors={rawErrors}
rawDescription={uiSchema['ui:description'] ?? description}
disabled={disabled}
errors={errors}
required={required}
>
<PasswordField
label={title}
secondaryLabel={required ? 'Required' : undefined}
onChange={handleChange}
value={localValue}
autoComplete="off"
isRequired={required}
isDisabled={disabled}
isInvalid={rawErrors && rawErrors.length > 0}
minLength={minLength}
maxLength={maxLength}
/>
</ScaffolderField>
);
}
return (
<ScaffolderField
rawErrors={rawErrors}
@@ -0,0 +1,40 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type CSSProperties } from 'react';
export const chipStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--bui-space-1)',
padding: '2px var(--bui-space-2)',
borderRadius: 'var(--bui-radius-3)',
backgroundColor: 'var(--bui-bg-neutral-2-hover)',
fontSize: 'var(--bui-font-size-3)',
color: 'var(--bui-fg-primary)',
lineHeight: '1.5',
};
export const chipRemoveStyle: CSSProperties = {
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0 2px',
color: 'var(--bui-fg-secondary)',
fontSize: '1rem',
lineHeight: '1',
display: 'flex',
alignItems: 'center',
};
@@ -0,0 +1,9 @@
/* Duplicated in plugins/scaffolder-react/src/next/components/Form/BuiTheme/widgets/selectOverrides.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);
}