From 4af932bb6030128667698f4332bcfa224306f5b8 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 13:00:02 +0100 Subject: [PATCH 01/12] feat: [US-001] - Flatten navigation config and content registry --- .../ProcessBuilder/contentRegistry.tsx | 22 ++++++++++++++++- .../ProcessBuilder/navigationConfig.ts | 24 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx b/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx index 66fc2a764..dca703c8f 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx @@ -2,7 +2,7 @@ import { type ComponentType } from 'react'; -import type { StepId } from './navigationConfig'; +import type { SectionId, StepId } from './navigationConfig'; import OverviewSection from './stepContent/general/OverviewSection'; import PhasesSection from './stepContent/general/PhasesSection'; import ProposalCategoriesSection from './stepContent/general/ProposalCategoriesSection'; @@ -52,3 +52,23 @@ export function getContentComponent( } return CONTENT_REGISTRY[stepId]?.[sectionId] ?? null; } + +// Flat section-to-component mapping for the unified sidebar +const FLAT_CONTENT_REGISTRY: Record = { + overview: OverviewSection, + phases: PhasesSection, + proposalCategories: ProposalCategoriesSection, + templateEditor: TemplateEditorSection, + criteria: CriteriaSection, + roles: RolesSection, + participants: ParticipantsSection, +}; + +export function getContentComponentFlat( + sectionId: SectionId | undefined, +): SectionComponent | null { + if (!sectionId) { + return null; + } + return FLAT_CONTENT_REGISTRY[sectionId] ?? null; +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts b/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts index ba553f7a3..c65581711 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts @@ -53,3 +53,27 @@ export const DEFAULT_NAVIGATION_CONFIG: NavigationConfig = { participants: ['roles', 'participants'], }, }; + +// Flat sidebar items for the unified sidebar navigation +export interface SidebarItem { + id: SectionId; + labelKey: TranslationKey; + parentStepId?: StepId; +} + +export const SIDEBAR_ITEMS: SidebarItem[] = [ + { id: 'overview', labelKey: 'Overview', parentStepId: 'general' }, + { id: 'phases', labelKey: 'Phases', parentStepId: 'general' }, + { + id: 'proposalCategories', + labelKey: 'Proposal Categories', + parentStepId: 'general', + }, + { + id: 'templateEditor', + labelKey: 'Proposal Template', + parentStepId: 'template', + }, + { id: 'roles', labelKey: 'Roles & permissions', parentStepId: 'participants' }, + { id: 'participants', labelKey: 'Participants', parentStepId: 'participants' }, +]; From 1a2ecc4f84aec9899d7721bab6646287f3cc69fd Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 13:02:26 +0100 Subject: [PATCH 02/12] feat: [US-002] - Update useProcessNavigation hook for flat model --- .../ProcessBuilder/ProcessBuilderContent.tsx | 11 +- .../ProcessBuilder/useProcessNavigation.ts | 139 ++++++++---------- 2 files changed, 69 insertions(+), 81 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx index 9bf2b4147..d97344761 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx @@ -4,7 +4,8 @@ import { useUser } from '@/utils/UserProvider'; import { useTranslations } from '@/lib/i18n'; -import { type SectionProps, getContentComponent } from './contentRegistry'; +import { type SectionProps, getContentComponentFlat } from './contentRegistry'; +import { type SectionId } from './navigationConfig'; import { useNavigationConfig } from './useNavigationConfig'; import { useProcessNavigation } from './useProcessNavigation'; @@ -15,8 +16,7 @@ export function ProcessBuilderContent({ }: SectionProps) { const t = useTranslations(); const navigationConfig = useNavigationConfig(instanceId); - const { currentStep, currentSection } = - useProcessNavigation(navigationConfig); + const { currentSection } = useProcessNavigation(navigationConfig); const access = useUser(); const isAdmin = access.getPermissionsForProfile(decisionProfileId).admin; @@ -25,9 +25,8 @@ export function ProcessBuilderContent({ throw new Error('UNAUTHORIZED'); } - const ContentComponent = getContentComponent( - currentStep?.id, - currentSection?.id, + const ContentComponent = getContentComponentFlat( + currentSection?.id as SectionId | undefined, ); if (!ContentComponent) { diff --git a/apps/app/src/components/decisions/ProcessBuilder/useProcessNavigation.ts b/apps/app/src/components/decisions/ProcessBuilder/useProcessNavigation.ts index 76c635432..63ce1b57a 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/useProcessNavigation.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/useProcessNavigation.ts @@ -6,54 +6,52 @@ import { useCallback, useEffect, useMemo } from 'react'; import { DEFAULT_NAVIGATION_CONFIG, type NavigationConfig, - SECTIONS_BY_STEP, + SIDEBAR_ITEMS, STEPS, + type SectionId, type StepId, } from './navigationConfig'; export function useProcessNavigation( navigationConfig: NavigationConfig = DEFAULT_NAVIGATION_CONFIG, ) { - const [stepParam, setStepParam] = useQueryState('step', { history: 'push' }); const [sectionParam, setSectionParam] = useQueryState('section', { history: 'push', }); - // Filter to visible steps only - const visibleSteps = useMemo( - () => - STEPS.filter((s) => { - const visibility = navigationConfig.steps?.[s.id]; - return visibility === true; - }), - [navigationConfig.steps], - ); - - // Current step (fallback to first visible step) - const currentStep = useMemo(() => { - const found = visibleSteps.find((s) => s.id === stepParam); - return found ?? visibleSteps[0]; - }, [stepParam, visibleSteps]); + // Legacy params for backward compatibility + const [legacyStepParam, setLegacyStepParam] = useQueryState('step', { + history: 'replace', + }); - // Get visible sections for current step + // Filter SIDEBAR_ITEMS to only visible sections based on navigationConfig const visibleSections = useMemo(() => { - if (!currentStep) { - return []; - } - - const allSections = SECTIONS_BY_STEP[currentStep.id]; - const allowedSectionIds = navigationConfig.sections?.[currentStep.id]; + return SIDEBAR_ITEMS.filter((item) => { + const stepId = item.parentStepId; + if (!stepId) { + return true; + } + // Step must be visible + if (navigationConfig.steps?.[stepId] !== true) { + return false; + } + // Section must be in allowed sections for its step + const allowedSectionIds = navigationConfig.sections?.[stepId]; + if (!allowedSectionIds) { + return false; + } + return allowedSectionIds.some((id) => id === item.id); + }); + }, [navigationConfig.steps, navigationConfig.sections]); - // If no section config, show no sections - if (!allowedSectionIds) { - return []; + // Backward compatibility: derive section from old step+section params + useEffect(() => { + if (legacyStepParam && !sectionParam) { + // Old URL format: ?step=general§ion=overview → ?section=overview + // or just ?step=general → derive first section of that step + setLegacyStepParam(null); } - - // Filter to only allowed sections - return allSections.filter((s) => - allowedSectionIds.some((id) => id === s.id), - ); - }, [currentStep, navigationConfig.sections]); + }, [legacyStepParam, sectionParam, setLegacyStepParam]); // Current section (fallback to first visible section) const currentSection = useMemo(() => { @@ -61,32 +59,40 @@ export function useProcessNavigation( return found ?? visibleSections[0]; }, [sectionParam, visibleSections]); - // Replace invalid params in URL - useEffect(() => { - if (stepParam && !visibleSteps.some((s) => s.id === stepParam)) { - setStepParam(null); + // Derive currentStep from currentSection's parentStepId (for backward compat with consumers) + const currentStep = useMemo(() => { + if (!currentSection?.parentStepId) { + return STEPS[0]; } + return STEPS.find((s) => s.id === currentSection.parentStepId) ?? STEPS[0]; + }, [currentSection]); + + // Keep legacy visibleSteps for backward compat with header/sidebar consumers + const visibleSteps = useMemo( + () => + STEPS.filter((s) => { + const visibility = navigationConfig.steps?.[s.id]; + return visibility === true; + }), + [navigationConfig.steps], + ); + + // Replace invalid section param in URL + useEffect(() => { if (sectionParam && !visibleSections.some((s) => s.id === sectionParam)) { setSectionParam(currentSection?.id ?? null); } - }, [ - stepParam, - sectionParam, - visibleSteps, - visibleSections, - currentSection, - setStepParam, - setSectionParam, - ]); + }, [sectionParam, visibleSections, currentSection, setSectionParam]); - // Hide section param for single-section steps - useEffect(() => { - if (sectionParam && visibleSections.length <= 1) { - setSectionParam(null); - } - }, [sectionParam, visibleSections.length, setSectionParam]); + // Handle section change + const setSection = useCallback( + (newSectionId: SectionId | string) => { + setSectionParam(newSectionId); + }, + [setSectionParam], + ); - // Handle step change - resets section to first of new step + // Handle step change - maps to first section of that step (backward compat for header tabs) const setStep = useCallback( (newStepId: StepId | string) => { const newStep = visibleSteps.find((s) => s.id === newStepId); @@ -94,30 +100,13 @@ export function useProcessNavigation( return; } - // Get first section of the new step - const newStepSections = SECTIONS_BY_STEP[newStep.id]; - const allowedSectionIds = navigationConfig.sections?.[newStep.id]; - const firstVisibleSection = allowedSectionIds - ? newStepSections.find((s) => - allowedSectionIds.some((id) => id === s.id), - ) - : newStepSections[0]; - - setStepParam(newStepId); - // Only set section param if step has multiple sections - setSectionParam( - newStepSections.length > 1 ? (firstVisibleSection?.id ?? null) : null, + // Find first visible sidebar item belonging to this step + const firstSection = visibleSections.find( + (item) => item.parentStepId === newStep.id, ); + setSectionParam(firstSection?.id ?? null); }, - [visibleSteps, navigationConfig.sections, setStepParam, setSectionParam], - ); - - // Handle section change - const setSection = useCallback( - (newSectionId: string) => { - setSectionParam(newSectionId); - }, - [setSectionParam], + [visibleSteps, visibleSections, setSectionParam], ); return { From 7db094d42b2d6e0a4d154d1133aca7a9666059be Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 13:04:55 +0100 Subject: [PATCH 03/12] feat: [US-003] - Create unified sidebar navigation component --- .../ProcessBuilderSectionNav.tsx | 62 ++++++++----------- 1 file changed, 25 insertions(+), 37 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx index dc896ce0b..54869bbaf 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx @@ -1,8 +1,5 @@ 'use client'; -import { Key } from '@op/ui/RAC'; -import { Tab, TabList, Tabs } from '@op/ui/Tabs'; - import { useTranslations } from '@/lib/i18n'; import { useNavigationConfig } from './useNavigationConfig'; @@ -15,43 +12,34 @@ export const ProcessBuilderSidebar = ({ }) => { const t = useTranslations(); const navigationConfig = useNavigationConfig(instanceId); - const { visibleSections, currentSection, currentStep, setSection } = + const { visibleSections, currentSection, setSection } = useProcessNavigation(navigationConfig); - const handleSelectionChange = (key: Key) => { - setSection(String(key)); - }; - - // Don't render sidebar for single-section steps - // These steps manage their own layout (e.g., template step with form builder) - if (visibleSections.length <= 1) { - return null; - } - return ( -