From 01d4713bd9b14b2eec5f069ed7c14676298069f7 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Wed, 31 Dec 2025 13:08:15 +0530 Subject: [PATCH] feat: add configurable instructions section for bulk import --- .../configurable-instructions-section.md | 38 +++ .../plugins/bulk-import/config.d.ts | 60 +++++ .../AddRepositoriesPage.test.tsx | 61 +++-- .../AddRepositories/AddRepositoriesPage.tsx | 83 +------ .../ConfigurableInstructions.tsx | 231 ++++++++++++++++++ .../plugins/bulk-import/src/hooks/index.ts | 2 + .../src/hooks/useInstructionsConfig.ts | 119 +++++++++ .../src/hooks/useInstructionsPreference.ts | 62 +++++ 8 files changed, 558 insertions(+), 98 deletions(-) create mode 100644 workspaces/bulk-import/.changeset/configurable-instructions-section.md create mode 100644 workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/ConfigurableInstructions.tsx create mode 100644 workspaces/bulk-import/plugins/bulk-import/src/hooks/useInstructionsConfig.ts create mode 100644 workspaces/bulk-import/plugins/bulk-import/src/hooks/useInstructionsPreference.ts 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..13d42efc6c --- /dev/null +++ b/workspaces/bulk-import/.changeset/configurable-instructions-section.md @@ -0,0 +1,38 @@ +--- +'@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 +- **Icon Support**: Support for both built-in theme-aware icons and custom URL-based 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: + type: 'builtin' # or "url" + source: 'approval-tool' # icon name or URL + - id: 'step2' + text: 'Configure without icon' + # 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..cff5a01ea2 100644 --- a/workspaces/bulk-import/plugins/bulk-import/config.d.ts +++ b/workspaces/bulk-import/plugins/bulk-import/config.d.ts @@ -27,5 +27,65 @@ 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 + * @visibility frontend + */ + icon?: { + /** + * Icon type: 'builtin' for predefined icons, 'url' for custom images + * @visibility frontend + */ + type: 'builtin' | 'url'; + + /** + * For builtin: icon name (e.g., 'approval-tool', 'choose-repositories', 'generate-cataloginfo', 'edit-pullrequest', 'track-status') + * For url: full URL to the icon image + * @visibility frontend + */ + source: 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..846d00f47a --- /dev/null +++ b/workspaces/bulk-import/plugins/bulk-import/src/components/AddRepositories/ConfigurableInstructions.tsx @@ -0,0 +1,231 @@ +/* + * 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 { Illustrations } from './Illustrations'; + +interface ConfigurableInstructionsProps { + // No props needed - everything comes from configuration +} + +/** + * Enhanced Illustrations component that supports custom URL icons + */ +const ConfigurableIllustration = ({ + iconClassname, + iconText, +}: { + iconClassname: string; + iconText: string; +}) => { + // If no icon is configured, show only text + if (!iconClassname) { + return ( +
+ {/* Empty space to maintain consistent layout with icon steps */} + + {/* Empty space where icon would be */} + + + {iconText} + +
+ ); + } + + // Check if this is a custom URL icon + const isCustomUrl = iconClassname.startsWith('custom-url:'); + + if (isCustomUrl) { + const imageUrl = iconClassname.replace('custom-url:', ''); + return ( +
+ + {iconText} + + + {iconText} + +
+ ); + } + + // Use the existing Illustrations component for built-in icons + return ; +}; + +/** + * Configurable instructions component that displays the "How does it work" section + * based on configuration from app-config.yaml and user preferences + */ +export const ConfigurableInstructions: React.FC< + ConfigurableInstructionsProps +> = () => { + 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 => { + let iconClassname = ''; + + if (configStep.icon) { + if (configStep.icon.type === 'builtin') { + // For builtin icons, construct the classname based on theme + const iconName = configStep.icon.source; + iconClassname = + theme.palette.mode === 'dark' + ? `icon-${iconName}-white` + : `icon-${iconName}-black`; + } else if (configStep.icon.type === 'url') { + // For URL icons, we'll use the URL directly in a special way + iconClassname = `custom-url:${configStep.icon.source}`; + } + } + // No fallback - leave iconClassname empty if no icon is configured + + return { + id: configStep.id, + text: configStep.text, + iconClassname, + }; + }); + }, [config.steps, theme.palette.mode]); + + // 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/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..d931622e93 --- /dev/null +++ b/workspaces/bulk-import/plugins/bulk-import/src/hooks/useInstructionsConfig.ts @@ -0,0 +1,119 @@ +/* + * 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?: { + type: 'builtin' | 'url'; + source: 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') + ? { + type: stepConfig.getString('icon.type') as 'builtin' | 'url', + source: stepConfig.getString('icon.source'), + } + : 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]; +}