Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,57 +1,46 @@
'use client';

import { Key } from '@op/ui/RAC';
import { Tab, TabList, Tabs } from '@op/ui/Tabs';

import { useTranslations } from '@/lib/i18n';

import { SidebarNavItems } from './components/SidebarNavItems';
import { useNavigationConfig } from './useNavigationConfig';
import { usePhaseValidation } from './usePhaseValidation';
import { useProcessNavigation } from './useProcessNavigation';
import { useProcessPhases } from './useProcessPhases';
import { useProcessBuilderValidation } from './validation/useProcessBuilderValidation';

export const ProcessBuilderSidebar = ({
instanceId,
decisionProfileId,
}: {
instanceId: string;
decisionProfileId?: string;
}) => {
const t = useTranslations();
const navigationConfig = useNavigationConfig(instanceId);
const { visibleSections, currentSection, currentStep, setSection } =
useProcessNavigation(navigationConfig);

const handleSelectionChange = (key: Key) => {
setSection(String(key));
};
const { sections: validationSections } =
useProcessBuilderValidation(decisionProfileId);
const phases = useProcessPhases(instanceId, decisionProfileId);
const phaseValidation = usePhaseValidation(instanceId, decisionProfileId);

// 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;
}
const { visibleSections, currentSection, setSection } = useProcessNavigation(
navigationConfig,
phases,
);

return (
<nav className="h-auto shrink-0 overflow-x-auto overflow-y-hidden p-0 py-4 md:sticky md:top-0 md:h-full md:w-64 md:overflow-x-hidden md:overflow-y-auto md:border-r md:p-8">
<Tabs
key={currentStep?.id}
orientation="vertical"
selectedKey={currentSection?.id}
onSelectionChange={handleSelectionChange}
>
<TabList
aria-label={t('Section navigation')}
className="scrollbar-none flex w-full gap-4 border-none md:flex-col md:gap-1"
>
{visibleSections.map((section) => (
<Tab
key={section.id}
id={section.id}
variant="pill"
className="cursor-pointer first:ml-4 last:mr-4 hover:bg-neutral-gray1 hover:text-charcoal focus-visible:outline-solid md:first:ml-0 md:last:mr-0 selected:text-charcoal md:selected:bg-neutral-offWhite"
>
{section.isDynamic ? section.labelKey : t(section.labelKey)}
</Tab>
))}
</TabList>
</Tabs>
<nav
aria-label={t('Section navigation')}
className="hidden shrink-0 md:sticky md:top-0 md:flex md:h-full md:w-60 md:flex-col md:overflow-y-auto md:border-r md:p-4"
>
<SidebarNavItems
visibleSections={visibleSections}
phases={phases}
currentSectionId={currentSection?.id}
phaseValidation={phaseValidation}
validationSections={validationSections}
onSectionClick={setSection}
/>
</nav>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function CornerDownRight({ className }: { className?: string }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M10 7.5H4C3.46957 7.5 2.96086 7.28929 2.58579 6.91421C2.21071 6.53914 2 6.03043 2 5.5V2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
'use client';

import { cn } from '@op/ui/utils';

import { useTranslations } from '@/lib/i18n';

import {
type SectionId,
type SidebarItem,
isPhaseSection,
isSectionId,
phaseToSectionId,
} from '../navigationConfig';
import type { ProcessPhase } from '../useProcessPhases';
import { CornerDownRight } from './CornerDownRight';

type StaticSidebarItem = Extract<SidebarItem, { isDynamic?: false }>;

interface SidebarNavItemsProps {
visibleSections: SidebarItem[];
phases: ProcessPhase[];
currentSectionId: string | undefined;
phaseValidation: Record<string, boolean>;
validationSections: Record<SectionId, boolean>;
onSectionClick: (sectionId: string) => void;
}

export function SidebarNavItems({
visibleSections,
phases,
currentSectionId,
phaseValidation,
validationSections,
onSectionClick,
}: SidebarNavItemsProps) {
return (
<ul className="flex flex-col gap-1">
{visibleSections
.filter((section): section is StaticSidebarItem => !section.isDynamic)
.map((section) => (
<SectionItem
key={section.id}
section={section}
phases={phases}
currentSectionId={currentSectionId}
phaseValidation={phaseValidation}
validationSections={validationSections}
onSectionClick={onSectionClick}
/>
))}
</ul>
);
}

interface SectionItemProps {
section: StaticSidebarItem;
phases: ProcessPhase[];
currentSectionId: string | undefined;
phaseValidation: Record<string, boolean>;
validationSections: Record<SectionId, boolean>;
onSectionClick: (sectionId: string) => void;
}

function SectionItem({
section,
phases,
currentSectionId,
phaseValidation,
validationSections,
onSectionClick,
}: SectionItemProps) {
const t = useTranslations();
const isActive = currentSectionId === section.id;

return (
<li>
<button
type="button"
onClick={() => onSectionClick(section.id)}
className={cn(
'flex w-full cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 text-left text-base transition-colors',
isActive
? 'bg-primary-tealWhite text-primary'
: 'text-neutral-black hover:bg-neutral-gray1',
)}
>
{t(section.labelKey)}
{isSectionId(section.id) &&
validationSections[section.id] === false && (
<span className="size-1.5 shrink-0 rounded-full bg-primary-teal" />
)}
</button>
{section.id === 'phases' && phases.length > 0 && (
<ul className="mt-0.5 flex flex-col gap-0.5">
{phases.map((phase) => (
<PhaseItem
key={phase.phaseId}
phase={phase}
currentSectionId={currentSectionId}
phaseValidation={phaseValidation}
onSectionClick={onSectionClick}
/>
))}
</ul>
)}
</li>
);
}

interface PhaseItemProps {
phase: ProcessPhase;
currentSectionId: string | undefined;
phaseValidation: Record<string, boolean>;
onSectionClick: (sectionId: string) => void;
}

function PhaseItem({
phase,
currentSectionId,
phaseValidation,
onSectionClick,
}: PhaseItemProps) {
const t = useTranslations();
const phaseSectionId = phaseToSectionId(phase.phaseId);
const isActive =
currentSectionId !== undefined &&
isPhaseSection(currentSectionId) &&
currentSectionId === phaseSectionId;

return (
<li>
<button
type="button"
onClick={() => onSectionClick(phaseSectionId)}
className={cn(
'flex w-full cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1 text-left text-sm transition-colors',
isActive
? 'bg-primary-tealWhite text-primary'
: 'text-neutral-black hover:bg-neutral-gray1',
)}
>
<CornerDownRight className="shrink-0 opacity-50" />
<span className="truncate">{phase.name || t('Untitled phase')}</span>
{phaseValidation[phase.phaseId] === false && (
<span className="ml-auto size-1.5 shrink-0 rounded-full bg-primary-teal" />
)}
</button>
</li>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

import { type ComponentType } from 'react';

import type { StepId } from './navigationConfig';
import {
type SectionId,
type StepId,
isPhaseSection,
} from './navigationConfig';
import OverviewSection from './stepContent/general/OverviewSection';
import PhaseDetailSection from './stepContent/general/PhaseDetailSection';
import PhasesSection from './stepContent/general/PhasesSection';
import ProposalCategoriesSection from './stepContent/general/ProposalCategoriesSection';
import ParticipantsSection from './stepContent/participants/ParticipantsSection';
Expand Down Expand Up @@ -52,3 +57,26 @@ export function getContentComponent(
}
return CONTENT_REGISTRY[stepId]?.[sectionId] ?? null;
}

// Flat section-to-component mapping for the unified sidebar
const FLAT_CONTENT_REGISTRY: Record<string, SectionComponent> = {
overview: OverviewSection,
phases: PhasesSection,
proposalCategories: ProposalCategoriesSection,
templateEditor: TemplateEditorSection,
criteria: CriteriaSection,
roles: RolesSection,
participants: ParticipantsSection,
};

export function getContentComponentFlat(
sectionId: SectionId | string | undefined,
): SectionComponent | null {
if (!sectionId) {
return null;
}
if (isPhaseSection(sectionId)) {
return PhaseDetailSection;
}
return FLAT_CONTENT_REGISTRY[sectionId] ?? null;
}
Loading
Loading