diff --git a/frontend/src/hooks/tools/shared/useAccordionSteps.ts b/frontend/src/hooks/tools/shared/useAccordionSteps.ts new file mode 100644 index 000000000..cfd3def25 --- /dev/null +++ b/frontend/src/hooks/tools/shared/useAccordionSteps.ts @@ -0,0 +1,123 @@ +import { useState, useCallback, useMemo, useEffect } from 'react'; + +/** + * State conditions that affect accordion behavior + */ +export interface AccordionStateConditions { + /** Whether files are present (steps collapse when false) */ + hasFiles?: boolean; + /** Whether results are available (steps collapse when true) */ + hasResults?: boolean; + /** Whether the accordion is disabled (steps collapse when true) */ + disabled?: boolean; +} + +/** + * Configuration for the useAccordionSteps hook + */ +export interface UseAccordionStepsConfig { + /** Special step that represents "no step open" state */ + noneValue: T; + /** Initial step to open */ + initialStep: T; + /** Current state conditions that affect accordion behavior */ + stateConditions?: AccordionStateConditions; + /** Callback to run when interacting with a step when we have results (usually used for resetting params) */ + afterResults?: () => void; +} + +/** + * Return type for the useAccordionSteps hook + */ +export interface AccordionStepsAPI { + /** Currently active/open step (noneValue if no step is open) */ + currentStep: T; + /** Get whether a specific step should be collapsed */ + getCollapsedState: (step: T) => boolean; + /** Toggle a step open/closed (accordion behavior - only one open at a time) */ + handleStepToggle: (step: T) => void; + /** Set the currently open step */ + setOpenStep: (step: T) => void; + /** Close all steps */ + closeAllSteps: () => void; +} + +/** + * Accordion-style step management hook. + * + * Provides sophisticated accordion behavior where only one step can be open at a time, + * with configurable collapse conditions. + */ +export function useAccordionSteps( + config: UseAccordionStepsConfig +): AccordionStepsAPI { + const { initialStep, stateConditions, noneValue } = config; + + const [openStep, setOpenStep] = useState(initialStep); + + // Determine if all steps should be collapsed based on conditions + const shouldCollapseAll = useMemo(() => { + if (!stateConditions) { + return false; + } + + return ( + (stateConditions.hasFiles === false) || + (stateConditions.hasResults === true) || + (stateConditions.disabled === true) + ); + }, [stateConditions]); + + // Get collapsed state for a specific step + const getCollapsedState = useCallback((step: T): boolean => { + if (shouldCollapseAll) { + return true; + } else { + return openStep !== step; + } + }, [openStep, shouldCollapseAll]); + + // Handle step toggle with accordion behavior + const handleStepToggle = useCallback((step: T) => { + if (stateConditions?.hasResults) { + config.afterResults?.(); + } + + // If all steps should be collapsed, don't allow opening + if (shouldCollapseAll) { + return; + } + + // Accordion behavior: if clicking the open step, close it; otherwise open the clicked step + setOpenStep(currentStep => { + if (currentStep === step) { + // Clicking the open step - close it + return noneValue; + } else { + // Open the clicked step + return step; + } + }); + }, [shouldCollapseAll, noneValue, stateConditions?.hasResults, config.afterResults]); + + // Close all steps + const closeAllSteps = useCallback(() => { + setOpenStep(noneValue); + }, [noneValue]); + + // Automatically reset to first step if we have results + // Note that everything is still collapsed when this happens, it's just preparing for re-running the tool + useEffect(() => { + if (stateConditions?.hasResults) { + setOpenStep(initialStep); + } + }, [stateConditions?.hasResults, initialStep]); + + return { + currentStep: shouldCollapseAll ? noneValue : openStep, + getCollapsedState, + handleStepToggle, + setOpenStep, + closeAllSteps + }; +} diff --git a/frontend/src/tools/ChangeMetadata.tsx b/frontend/src/tools/ChangeMetadata.tsx index a8f103ccf..455828b7d 100644 --- a/frontend/src/tools/ChangeMetadata.tsx +++ b/frontend/src/tools/ChangeMetadata.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; import { useTranslation } from "react-i18next"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps"; import DeleteAllStep from "../components/tools/changeMetadata/steps/DeleteAllStep"; import StandardMetadataStep from "../components/tools/changeMetadata/steps/StandardMetadataStep"; import DocumentDatesStep from "../components/tools/changeMetadata/steps/DocumentDatesStep"; @@ -34,9 +34,6 @@ const ChangeMetadata = (props: BaseToolProps) => { const documentDatesTips = useDocumentDatesTips(); const advancedOptionsTips = useAdvancedOptionsTips(); - // Individual step collapse states - only one can be open at a time - const [openStep, setOpenStep] = useState(MetadataStep.DELETE_ALL); - const base = useBaseTool( 'changeMetadata', useChangeMetadataParameters, @@ -47,27 +44,22 @@ const ChangeMetadata = (props: BaseToolProps) => { // Extract metadata from uploaded files const { isExtractingMetadata } = useMetadataExtraction(base.params); - // Compute actual collapsed state based on results and accordion behavior - const getActualCollapsedState = (stepName: MetadataStep) => { - return (!base.hasFiles || base.hasResults) ? true : openStep !== stepName; - }; - - // Handle step toggle for accordion behavior - const handleStepToggle = (stepName: MetadataStep) => { - if (base.hasResults) { - if (base.settingsCollapsed) { - base.handleSettingsReset(); - } - return; - } - setOpenStep(openStep === stepName ? MetadataStep.NONE : stepName); - }; + // Accordion step management + const accordion = useAccordionSteps({ + noneValue: MetadataStep.NONE, + initialStep: MetadataStep.DELETE_ALL, + stateConditions: { + hasFiles: base.hasFiles, + hasResults: base.hasResults + }, + afterResults: base.handleSettingsReset, + }); // Create step objects const createStandardMetadataStep = () => ({ title: t("changeMetadata.standardFields.title", "Standard Fields"), - isCollapsed: getActualCollapsedState(MetadataStep.STANDARD_METADATA), - onCollapsedClick: () => handleStepToggle(MetadataStep.STANDARD_METADATA), + isCollapsed: accordion.getCollapsedState(MetadataStep.STANDARD_METADATA), + onCollapsedClick: () => accordion.handleStepToggle(MetadataStep.STANDARD_METADATA), tooltip: standardMetadataTips, content: ( { const createDocumentDatesStep = () => ({ title: t("changeMetadata.dates.title", "Date Fields"), - isCollapsed: getActualCollapsedState(MetadataStep.DOCUMENT_DATES), - onCollapsedClick: () => handleStepToggle(MetadataStep.DOCUMENT_DATES), + isCollapsed: accordion.getCollapsedState(MetadataStep.DOCUMENT_DATES), + onCollapsedClick: () => accordion.handleStepToggle(MetadataStep.DOCUMENT_DATES), tooltip: documentDatesTips, content: ( { const createAdvancedOptionsStep = () => ({ title: t("changeMetadata.advanced.title", "Advanced Options"), - isCollapsed: getActualCollapsedState(MetadataStep.ADVANCED_OPTIONS), - onCollapsedClick: () => handleStepToggle(MetadataStep.ADVANCED_OPTIONS), + isCollapsed: accordion.getCollapsedState(MetadataStep.ADVANCED_OPTIONS), + onCollapsedClick: () => accordion.handleStepToggle(MetadataStep.ADVANCED_OPTIONS), tooltip: advancedOptionsTips, content: ( { const steps = [ { title: t("changeMetadata.deleteAll.label", "Remove Existing Metadata"), - isCollapsed: getActualCollapsedState(MetadataStep.DELETE_ALL), - onCollapsedClick: () => handleStepToggle(MetadataStep.DELETE_ALL), + isCollapsed: accordion.getCollapsedState(MetadataStep.DELETE_ALL), + onCollapsedClick: () => accordion.handleStepToggle(MetadataStep.DELETE_ALL), tooltip: deleteAllTips, content: (