diff --git a/workspaces/bulk-import/.changeset/configurable-instructions-section.md b/workspaces/bulk-import/.changeset/configurable-instructions-section.md new file mode 100644 index 0000000000..9865e7980f --- /dev/null +++ b/workspaces/bulk-import/.changeset/configurable-instructions-section.md @@ -0,0 +1,48 @@ +--- +'@red-hat-developer-hub/backstage-plugin-bulk-import': minor +--- + +Add configurable instructions section for bulk import workflow + +This change introduces a fully configurable "Import to Red Hat Developer Hub" instructions section that allows administrators to customize the workflow steps displayed to users. + +**New Features:** + +- **Configurable Steps**: Define custom workflow steps via `app-config.yaml` with custom text and icons +- **Enhanced Icon Support**: Comprehensive icon system supporting Backstage system icons, Material Design icons, SVG strings, URLs, and legacy built-in icons +- **Dynamic Layout**: Steps automatically adjust width for optimal space usage (≤6 steps fill width, >6 steps scroll horizontally) +- **User Preferences**: Collapsed/expanded state persisted in localStorage per user +- **Universal Display**: Instructions section now shows for both PR flow and scaffolder flow +- **Smart Hiding**: Section automatically hides when no steps are configured + +**Configuration Schema:** + +```yaml +bulkImport: + # Enable/disable the instructions section (default: true) + instructionsEnabled: true + + # Default expanded state (default: true) + instructionsDefaultExpanded: true + + # Custom workflow steps + instructionsSteps: + - id: 'step1' + text: 'Choose your source control platform' + icon: 'kind:component' # Backstage system icon + - id: 'step2' + text: 'Browse repositories' + icon: 'search' # Material Design icon + - id: 'step3' + text: 'Custom SVG icon' + icon: '...' # SVG string + - id: 'step4' + text: 'External icon' + icon: 'https://example.com/icon.png' # URL + - id: 'step5' + text: 'Legacy built-in icon' + icon: 'approval-tool' # Legacy format (backward compatible) + - id: 'step6' + text: 'No icon step' + # Steps without icons show text only +``` diff --git a/workspaces/bulk-import/plugins/bulk-import/config.d.ts b/workspaces/bulk-import/plugins/bulk-import/config.d.ts index 4ef39c8101..f181b5bf07 100644 --- a/workspaces/bulk-import/plugins/bulk-import/config.d.ts +++ b/workspaces/bulk-import/plugins/bulk-import/config.d.ts @@ -27,5 +27,57 @@ export interface Config { * @visibility frontend */ importAPI?: 'open-pull-requests' | 'scaffolder'; + + /** + * The name of the scaffolder template to execute for importing a repository. + * @visibility backend + */ + importTemplate?: string; + + /** + * Whether to show the instructions section + * @default true + * @visibility frontend + */ + instructionsEnabled?: boolean; + + /** + * Whether the section should be expanded by default + * @default true + * @visibility frontend + */ + instructionsDefaultExpanded?: boolean; + + /** + * Array of steps to display in the instructions section + * If not provided, uses the default built-in steps + * Users can define any number of custom steps + * @visibility frontend + * @deepVisibility frontend + */ + instructionsSteps?: Array<{ + /** + * Unique identifier for the step + * @visibility frontend + */ + id: string; + + /** + * Display text for the step + * @visibility frontend + */ + text: string; + + /** + * Icon configuration - supports multiple formats: + * - Backstage system icons: 'kind:component', 'kind:api', etc. + * - Material Design icons: 'settings', 'home', 'build', etc. + * - SVG strings: '...' + * - URLs: 'https://example.com/icon.png', '/assets/icon.svg' + * - Data URIs: 'data:image/svg+xml;base64,...' + * @visibility frontend + */ + icon?: string; + }>; }; } diff --git a/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesPage.test.tsx b/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesPage.test.tsx index 6e6050f0a0..8d5fef8baf 100644 --- a/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesPage.test.tsx +++ b/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesPage.test.tsx @@ -22,13 +22,20 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { screen } from '@testing-library/react'; import { bulkImportApiRef } from '../../api/BulkImportBackendClient'; -import { useNumberOfApprovalTools, useRepositories } from '../../hooks'; +import { + useInstructionsConfig, + useInstructionsPreference, + useNumberOfApprovalTools, + useRepositories, +} from '../../hooks'; import { useImportFlow } from '../../hooks/useImportFlow'; import { AddRepositoriesPage } from './AddRepositoriesPage'; jest.mock('../../hooks', () => ({ useNumberOfApprovalTools: jest.fn(), useRepositories: jest.fn(), + useInstructionsConfig: jest.fn(), + useInstructionsPreference: jest.fn(), })); jest.mock('../../hooks/useImportFlow', () => ({ @@ -126,6 +133,31 @@ describe('AddRepositoriesPage', () => { data: [], error: null, }); + (useInstructionsConfig as jest.Mock).mockReturnValue({ + enabled: true, + defaultExpanded: true, + steps: [ + { + id: 'step1', + text: 'Choose your source control platform', + icon: { type: 'builtin', source: 'approval-tool' }, + }, + { + id: 'step2', + text: 'Browse and select repositories', + icon: { type: 'builtin', source: 'choose-repositories' }, + }, + { + id: 'step5', + text: 'Review and edit pull requests', + icon: { type: 'builtin', source: 'edit-pullrequest' }, + }, + ], + }); + (useInstructionsPreference as jest.Mock).mockReturnValue([ + true, // isExpanded + jest.fn(), // setExpanded + ]); }); it('should render page with correct title', async () => { @@ -140,9 +172,7 @@ describe('AddRepositoriesPage', () => { // Instructions section should be shown for pull request flow expect( - screen.getByText( - 'Choose a source control tool for pull request creation', - ), + screen.getByText('Choose your source control platform'), ).toBeInTheDocument(); }); @@ -151,12 +181,10 @@ describe('AddRepositoriesPage', () => { // All steps should be shown for pull request flow (both GitHub and GitLab) expect( - screen.getByText( - 'Choose a source control tool for pull request creation', - ), + screen.getByText('Choose your source control platform'), ).toBeInTheDocument(); expect( - screen.getByText('View the pull/merge request details'), + screen.getByText('Review and edit pull requests'), ).toBeInTheDocument(); }); @@ -173,30 +201,23 @@ describe('AddRepositoriesPage', () => { screen.queryByText('Import to Red Hat Developer Hub'), ).not.toBeInTheDocument(); expect( - screen.queryByText( - 'Choose a source control tool for pull request creation', - ), + screen.queryByText('Choose your source control platform'), ).not.toBeInTheDocument(); // Form should still be rendered (it will show missing configurations) expect(screen.getByTestId('add-repositories-form')).toBeInTheDocument(); }); - it('should hide instructions section for scaffolder flow', async () => { + it('should show instructions section for scaffolder flow', async () => { // Override default to test scaffolder flow (useImportFlow as jest.Mock).mockReturnValue('scaffolder'); await renderWithProviders(); - // Instructions section should be hidden for scaffolder flow - expect( - screen.queryByText('Import to Red Hat Developer Hub'), - ).not.toBeInTheDocument(); + // Instructions section should now be shown for scaffolder flow since it's customizable expect( - screen.queryByText( - 'Choose a source control tool for pull request creation', - ), - ).not.toBeInTheDocument(); + screen.getByText('Import to Red Hat Developer Hub'), + ).toBeInTheDocument(); // Form should still be rendered expect(screen.getByTestId('add-repositories-form')).toBeInTheDocument(); diff --git a/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesPage.tsx b/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesPage.tsx index caa1f3e3a0..e7eb131fda 100644 --- a/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesPage.tsx +++ b/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesPage.tsx @@ -19,26 +19,17 @@ import { useState } from 'react'; import { Content, Header, Page, Progress } from '@backstage/core-components'; import { usePermission } from '@backstage/plugin-permission-react'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import Accordion from '@mui/material/Accordion'; -import AccordionDetails from '@mui/material/AccordionDetails'; -import AccordionSummary from '@mui/material/AccordionSummary'; import Alert from '@mui/material/Alert'; import AlertTitle from '@mui/material/AlertTitle'; -import { useTheme } from '@mui/material/styles'; -import Typography from '@mui/material/Typography'; import { bulkImportPermission } from '@red-hat-developer-hub/backstage-plugin-bulk-import-common'; import { useNumberOfApprovalTools } from '../../hooks'; -import { useImportFlow } from '../../hooks/useImportFlow'; import { useTranslation } from '../../hooks/useTranslation'; -import { ImportFlow } from '../../types'; import { AddRepositoriesForm } from './AddRepositoriesForm'; -import { Illustrations } from './Illustrations'; +import { ConfigurableInstructions } from './ConfigurableInstructions'; export const AddRepositoriesPage = () => { - const theme = useTheme(); const { t } = useTranslation(); const [formError, setFormError] = useState(null); @@ -48,12 +39,10 @@ export const AddRepositoriesPage = () => { }); const { numberOfApprovalTools } = useNumberOfApprovalTools(); - const importFlow = useImportFlow(); - // Show instructions section only for pull request flow, hide for scaffolder flow - // Also hide if no integrations are configured (missing configurations) - const showInstructionsSection = - importFlow === ImportFlow.OpenPullRequests && numberOfApprovalTools > 0; + // Show instructions section for all flows now that it's customizable + // Only hide if no integrations are configured (missing configurations) + const showInstructionsSection = numberOfApprovalTools > 0; const showContent = () => { if (bulkImportViewPermissionResult.loading) { @@ -63,69 +52,7 @@ export const AddRepositoriesPage = () => { return ( <> {showInstructionsSection && !formError && ( -
- - } - id="add-repository-summary" - > - - {t('page.importEntitiesSubtitle')} - - - - {numberOfApprovalTools > 1 && ( - - )} - - - - - - -
+ )} diff --git a/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/ConfigurableInstructions.tsx b/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/ConfigurableInstructions.tsx new file mode 100644 index 0000000000..8b5af326d7 --- /dev/null +++ b/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/ConfigurableInstructions.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { useMemo } from 'react'; + +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; + +import { useInstructionsConfig, useInstructionsPreference } from '../../hooks'; +import { useTranslation } from '../../hooks/useTranslation'; +import { InstructionIcon } from './InstructionIcon'; + +/** + * Configurable instructions component that displays the "How does it work" section + * based on configuration from app-config.yaml and user preferences + */ +export const ConfigurableInstructions = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const config = useInstructionsConfig(); + const [isExpanded, setExpanded] = useInstructionsPreference( + config.defaultExpanded, + ); + + // Build the list of steps based on configuration from app-config.yaml + const steps = useMemo(() => { + return config.steps.map(configStep => ({ + id: configStep.id, + text: configStep.text, + icon: configStep.icon, + })); + }, [config.steps]); + + // Don't render if disabled or no steps configured + if (!config.enabled || steps.length === 0) { + return null; + } + + const title = t('page.importEntitiesSubtitle'); + + return ( +
+ setExpanded(expanded)} + > + } + id="add-repository-summary" + > + + {title} + + + + + {steps.map(step => ( + + + + ))} + + + +
+ ); +}; diff --git a/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/InstructionIcon.tsx b/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/InstructionIcon.tsx new file mode 100644 index 0000000000..28ba221454 --- /dev/null +++ b/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/InstructionIcon.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { useApp } from '@backstage/core-plugin-api'; + +import MuiIcon from '@mui/material/Icon'; +import { useTheme } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; + +import { getImageForIconClass } from '../../utils/icons'; + +export interface InstructionIconProps { + icon?: string; + text: string; +} + +/** + * Icon component for instruction steps that supports multiple icon formats: + * - Backstage system icons (e.g., 'kind:component', 'kind:api') + * - Material Design icons (e.g., 'settings', 'home', 'build') + * - SVG strings (e.g., '...') + * - URLs (e.g., 'https://example.com/icon.png', '/assets/icon.svg') + * - Data URIs (e.g., 'data:image/svg+xml;base64,...') + * - Legacy built-in icons (e.g., 'approval-tool', 'choose-repositories') + */ +export const InstructionIcon = ({ icon, text }: InstructionIconProps) => { + const app = useApp(); + const theme = useTheme(); + + // Common wrapper for consistent layout + const IconWrapper = ({ children }: { children: React.ReactNode }) => ( +
+
+ {children} +
+ + {text} + +
+ ); + + // If no icon is provided, show only text with consistent spacing + if (!icon) { + return ( + +
+ + ); + } + + // Handle React elements (though not expected from config) + if (isValidElement(icon)) { + return {icon}; + } + + const strIcon = icon as string; + + // Ensure we have a valid string + if (typeof strIcon !== 'string') { + return ( + +
+ + ); + } + + // Try Backstage system icons first + const SystemIcon = app.getSystemIcon(strIcon); + if (SystemIcon) { + return ( + +
+ +
+
+ ); + } + + // Handle SVG strings + if (strIcon.startsWith(' + {text} + + ); + } + + // Handle URLs and data URIs + if ( + strIcon.startsWith('https://') || + strIcon.startsWith('http://') || + strIcon.startsWith('/') || + strIcon.startsWith('data:image/') + ) { + return ( + + {text} + + ); + } + + // Check for legacy built-in icons (backward compatibility) + const legacyIcons = [ + 'approval-tool', + 'choose-repositories', + 'generate-cataloginfo', + 'edit-pullrequest', + 'track-status', + ]; + + if (legacyIcons.includes(strIcon)) { + // Use theme-aware legacy icons + const iconClassname = + theme.palette.mode === 'dark' + ? `icon-${strIcon}-white` + : `icon-${strIcon}-black`; + + return ( + + {text} + + ); + } + + // Fallback: treat as Material Design icon name + return ( + + + {strIcon} + + + ); +}; diff --git a/workspaces/bulk-import/plugins/bulk-import/src/hooks/index.ts b/workspaces/bulk-import/plugins/bulk-import/src/hooks/index.ts index 39a4af9da9..8e2a1d37ea 100644 --- a/workspaces/bulk-import/plugins/bulk-import/src/hooks/index.ts +++ b/workspaces/bulk-import/plugins/bulk-import/src/hooks/index.ts @@ -15,6 +15,8 @@ */ export { useAddedRepositories } from './useAddedRepositories'; +export { useInstructionsConfig } from './useInstructionsConfig'; +export { useInstructionsPreference } from './useInstructionsPreference'; export { useRepositories } from './useRepositories'; export { useNumberOfApprovalTools, diff --git a/workspaces/bulk-import/plugins/bulk-import/src/hooks/useInstructionsConfig.ts b/workspaces/bulk-import/plugins/bulk-import/src/hooks/useInstructionsConfig.ts new file mode 100644 index 0000000000..0bbb1e8a3f --- /dev/null +++ b/workspaces/bulk-import/plugins/bulk-import/src/hooks/useInstructionsConfig.ts @@ -0,0 +1,113 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { configApiRef, useApi } from '@backstage/core-plugin-api'; + +export interface InstructionsStep { + id: string; + text: string; + icon?: string; +} + +export interface InstructionsConfig { + enabled: boolean; + defaultExpanded: boolean; + steps: InstructionsStep[]; +} + +/** + * Hook to get instructions configuration from app-config.yaml + */ +export function useInstructionsConfig(): InstructionsConfig { + const configApi = useApi(configApiRef); + const bulkImportConfig = configApi.getOptionalConfig('bulkImport'); + const instructionsEnabled = + configApi.getOptionalBoolean('bulkImport.instructionsEnabled') ?? true; + const instructionsDefaultExpanded = + configApi.getOptionalBoolean('bulkImport.instructionsDefaultExpanded') ?? + true; + let instructionsSteps = + configApi.getOptionalConfigArray('bulkImport.instructionsSteps') ?? []; + + if (instructionsSteps.length === 0 && bulkImportConfig) { + instructionsSteps = + bulkImportConfig.getOptionalConfigArray('instructionsSteps') ?? []; + + // If still empty, try accessing the raw data (bypass schema validation) + if (instructionsSteps.length === 0) { + try { + const rawData = (bulkImportConfig as any).data; + if ( + rawData && + rawData.instructionsSteps && + Array.isArray(rawData.instructionsSteps) + ) { + // Convert raw data to InstructionsStep format + instructionsSteps = rawData.instructionsSteps.map((step: any) => ({ + id: step.id, + text: step.text, + icon: step.icon + ? { + type: step.icon.type as 'builtin' | 'url', + source: step.icon.source, + } + : undefined, + })); + } + } catch (error) { + // Silently handle config access errors + } + } + } + + // Use the flat config values + const enabled = instructionsEnabled; + const defaultExpanded = instructionsDefaultExpanded; + let stepsConfig = instructionsSteps; + + // Legacy fallback approach (no longer needed with current config structure) + if (stepsConfig.length === 0) { + // Try accessing via bulkImport config object + const legacyBulkImportConfig = configApi.getOptionalConfig('bulkImport'); + if (legacyBulkImportConfig) { + const instructionsConfig = + legacyBulkImportConfig.getOptionalConfig('instructions'); + if (instructionsConfig) { + stepsConfig = instructionsConfig.getOptionalConfigArray('steps') ?? []; + } + } + } + + const steps: InstructionsStep[] = + stepsConfig.length > 0 + ? stepsConfig.map(stepConfig => ({ + id: stepConfig.getString('id'), + text: stepConfig.getString('text'), + icon: stepConfig.has('icon') + ? stepConfig.getString('icon') + : undefined, + })) + : []; // No fallback needed - config should work now + + // Use config steps directly - no hardcoded fallback + const finalSteps = steps; + + return { + enabled, + defaultExpanded, + steps: finalSteps, + }; +} diff --git a/workspaces/bulk-import/plugins/bulk-import/src/hooks/useInstructionsPreference.ts b/workspaces/bulk-import/plugins/bulk-import/src/hooks/useInstructionsPreference.ts new file mode 100644 index 0000000000..dd9bdbf235 --- /dev/null +++ b/workspaces/bulk-import/plugins/bulk-import/src/hooks/useInstructionsPreference.ts @@ -0,0 +1,62 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { useCallback, useEffect, useState } from 'react'; + +const STORAGE_KEY = 'bulk-import-instructions-expanded'; + +/** + * Hook to manage user preference for instructions section expanded state + * Persists the state in localStorage + */ +export function useInstructionsPreference( + defaultExpanded: boolean, +): [boolean, (expanded: boolean) => void] { + const [isExpanded, setIsExpanded] = useState(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored !== null ? JSON.parse(stored) : defaultExpanded; + } catch { + // If there's an error reading from localStorage, use the default + return defaultExpanded; + } + }); + + const setExpanded = useCallback((expanded: boolean) => { + setIsExpanded(expanded); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(expanded)); + } catch { + // Silently fail if localStorage is not available + // The state will still work for the current session + } + }, []); + + // Update state if defaultExpanded changes and no user preference is stored + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === null) { + setIsExpanded(defaultExpanded); + } + } catch { + // If localStorage is not available, just use the default + setIsExpanded(defaultExpanded); + } + }, [defaultExpanded]); + + return [isExpanded, setExpanded]; +}