Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { notFound } from 'next/navigation';
import { ProcessBuilderContent } from '@/components/decisions/ProcessBuilder/ProcessBuilderContent';
import { ProcessBuilderHeader } from '@/components/decisions/ProcessBuilder/ProcessBuilderHeader';
import { ProcessBuilderSidebar } from '@/components/decisions/ProcessBuilder/ProcessBuilderSectionNav';
import { ProcessBuilderStoreInitializer } from '@/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer';
import type { FormInstanceData } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore';

const EditDecisionPage = async ({
params,
Expand All @@ -22,10 +24,31 @@ const EditDecisionPage = async ({
notFound();
}

const instanceId = decisionProfile.processInstance.id;
const { processInstance } = decisionProfile;
const instanceId = processInstance.id;
const instanceData = processInstance.instanceData;

// Map server data into the shape the store expects so validation works
// immediately — even before the user visits any section.
const serverData: FormInstanceData = {
name: processInstance.name ?? undefined,
description: processInstance.description ?? undefined,
stewardProfileId: processInstance.steward?.id,
phases: instanceData.phases,
proposalTemplate:
instanceData.proposalTemplate as FormInstanceData['proposalTemplate'],
hideBudget: instanceData.config?.hideBudget,
categories: instanceData.config?.categories,
requireCategorySelection: instanceData.config?.requireCategorySelection,
allowMultipleCategories: instanceData.config?.allowMultipleCategories,
};

return (
<div className="bg-background relative flex size-full flex-1 flex-col">
<ProcessBuilderStoreInitializer
decisionProfileId={decisionProfile.id}
serverData={serverData}
/>
<ProcessBuilderHeader
processName={decisionProfile.name}
instanceId={instanceId}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
'use client';

import { trpc } from '@op/api/client';
import { ProcessStatus } from '@op/api/encoders';
import { Button } from '@op/ui/Button';
import { DialogTrigger } from '@op/ui/Dialog';
import { Popover } from '@op/ui/Popover';
import { Key } from '@op/ui/RAC';
import {
Sidebar,
Expand All @@ -9,14 +13,25 @@ import {
useSidebar,
} from '@op/ui/Sidebar';
import { Tab, TabList, Tabs } from '@op/ui/Tabs';
import { LuChevronRight, LuCircleAlert, LuHouse, LuPlus } from 'react-icons/lu';
import { toast } from '@op/ui/Toast';
import {
LuCheck,
LuChevronRight,
LuCircle,
LuCircleAlert,
LuHouse,
LuPlus,
LuSave,
} from 'react-icons/lu';

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

import { UserAvatarMenu } from '@/components/SiteHeader';

import { useNavigationConfig } from './useNavigationConfig';
import { useProcessNavigation } from './useProcessNavigation';
import type { ValidationSummary } from './validation/processBuilderValidation';
import { useProcessBuilderValidation } from './validation/useProcessBuilderValidation';

export const ProcessBuilderHeader = ({
processName,
Expand Down Expand Up @@ -50,8 +65,46 @@ const ProcessBuilderHeaderContent = ({
useProcessNavigation(navigationConfig);
const hasSteps = visibleSteps.length > 0;

const { data: instance } = trpc.decision.getInstance.useQuery(
{ instanceId: instanceId! },
{ enabled: !!instanceId },
);

const instanceStatus = instance?.status as ProcessStatus | undefined;
const decisionProfileId = instance?.profileId ?? undefined;
const validation = useProcessBuilderValidation(decisionProfileId);

const { setOpen } = useSidebar();

const isDraft = instanceStatus === ProcessStatus.DRAFT;
const isTerminalStatus =
instanceStatus === ProcessStatus.COMPLETED ||
instanceStatus === ProcessStatus.CANCELLED;

// Save mutation for non-draft states
const updateInstance = trpc.decision.updateDecisionInstance.useMutation({
onSuccess: () => {
toast.success({ message: t('Changes saved successfully') });
},
onError: (error) => {
toast.error({
message: t('Failed to save changes'),
title: error.message,
});
},
});

const handleLaunchOrSave = () => {
if (!instanceId) {
return;
}
if (isDraft) {
// TODO: Open LaunchProcessModal
} else {
updateInstance.mutate({ instanceId });
}
};

const handleSelectionChange = (key: Key) => {
setStep(String(key));
setOpen(false);
Expand Down Expand Up @@ -101,24 +154,27 @@ const ProcessBuilderHeaderContent = ({
<div className="relative z-10 flex gap-4 pr-4 md:pr-8">
{hasSteps && (
<div className="flex gap-2">
{validation.stepsRemaining > 0 && (
<StepsRemainingPopover validation={validation} />
)}
<Button
className="flex aspect-square h-8 gap-2 rounded-sm md:aspect-auto"
color="warn"
className="h-8 rounded-sm"
onPress={handleLaunchOrSave}
isDisabled={
updateInstance.isPending ||
(isDraft && !validation.isReadyToLaunch) ||
isTerminalStatus
}
>
<LuCircleAlert className="size-4 shrink-0" />
<span className="hidden md:block">
{t(
'{stepCount, plural, =1 {1 step} other {# steps}} remaining',
{
stepCount: 3,
},
)}
</span>
</Button>
<Button className="h-8 rounded-sm">
<LuPlus className="size-4" />
{t('Launch')}
<span className="hidden md:inline"> {t('Process')}</span>
{isDraft ? (
<LuPlus className="size-4" />
) : (
<LuSave className="size-4" />
)}
{isDraft ? t('Launch') : t('Save')}
{isDraft && (
<span className="hidden md:inline"> {t('Process')}</span>
)}
</Button>
</div>
)}
Expand Down Expand Up @@ -189,3 +245,53 @@ const ComingSoonIndicator = () => {
</span>
);
};

const StepsRemainingPopover = ({
validation,
}: {
validation: ValidationSummary;
}) => {
const t = useTranslations();

return (
<DialogTrigger>
<Button
className="flex aspect-square h-8 gap-2 rounded-sm md:aspect-auto"
color="warn"
>
<LuCircleAlert className="size-4 shrink-0" />
<span className="hidden md:block">
{t('{stepCount, plural, =1 {1 step} other {# steps}} remaining', {
stepCount: validation.stepsRemaining,
})}
</span>
</Button>
<Popover
placement="bottom end"
className="w-72 rounded-lg border bg-white p-4 shadow-lg"
>
<p className="mb-3 font-medium text-neutral-black">
{t('Complete these steps to launch')}
</p>
<ul className="space-y-3">
{validation.checklist.map((item) => (
<li key={item.id} className="flex items-center gap-2">
{item.isValid ? (
<LuCheck className="size-5 shrink-0 text-functional-green" />
) : (
<LuCircle className="size-5 shrink-0 text-neutral-gray4" />
)}
<span
className={
item.isValid ? 'text-functional-green' : 'text-neutral-black'
}
>
{t(item.labelKey)}
</span>
</li>
))}
</ul>
</Popover>
</DialogTrigger>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';

import { useEffect, useRef } from 'react';

import {
type FormInstanceData,
useProcessBuilderStore,
} from './stores/useProcessBuilderStore';

/**
* Seeds the Zustand store with server-fetched instance data so that
* validation (and other consumers) have data immediately — even before
* the user visits any individual section.
*
* Merge strategy: server data is the base layer, localStorage edits overlay
* on top — but only for keys that have a defined, non-empty value. This
* prevents stale localStorage entries (e.g. an empty string from a cleared
* field in a previous session) from overwriting fresh server data.
*/
export function ProcessBuilderStoreInitializer({
decisionProfileId,
serverData,
}: {
decisionProfileId: string;
serverData: FormInstanceData;
}) {
const serverDataRef = useRef(serverData);
serverDataRef.current = serverData;

useEffect(() => {
const unsubscribe = useProcessBuilderStore.persist.onFinishHydration(() => {
const existing =
useProcessBuilderStore.getState().instances[decisionProfileId];

const base = serverDataRef.current;

// Only overlay localStorage values that are defined and non-empty.
// This prevents stale empty strings or undefined keys from
// clobbering valid server data.
const merged: FormInstanceData = { ...base };
if (existing) {
for (const [key, value] of Object.entries(existing)) {
if (value !== undefined && value !== '') {
(merged as Record<string, unknown>)[key] = value;
}
}
}

useProcessBuilderStore
.getState()
.setInstanceData(decisionProfileId, merged);
});

void useProcessBuilderStore.persist.rehydrate();
return unsubscribe;
}, [decisionProfileId]);

return null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ export function OverviewSectionForm({
if (isDraft) {
updateInstance.mutate({
instanceId,
name: values.name || undefined,
description: values.description || undefined,
name: values.name,
description: values.description,
stewardProfileId: values.stewardProfileId || undefined,
});
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,12 @@ export function PhasesSectionContent({
rules: phase.rules,
}));

// Always update the store so validation stays reactive
setInstanceData(decisionProfileId, { phases: phasesPayload });

if (isDraft) {
updateInstance.mutate({ instanceId, phases: phasesPayload });
} else {
setInstanceData(decisionProfileId, { phases: phasesPayload });
markSaved(decisionProfileId);
}
}, AUTOSAVE_DEBOUNCE_MS);
Expand Down
Loading