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('
+
+
+ );
+ }
+
+ // Handle URLs and data URIs
+ if (
+ strIcon.startsWith('https://') ||
+ strIcon.startsWith('http://') ||
+ strIcon.startsWith('/') ||
+ strIcon.startsWith('data:image/')
+ ) {
+ return (
+
+
+
+ );
+ }
+
+ // 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 (
+
+
+
+ );
+ }
+
+ // 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];
+}