Skip to content
Draft
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
Expand Up @@ -2,6 +2,7 @@ import { createClient } from '@op/api/serverClient';
import { notFound } from 'next/navigation';

import { ProcessBuilderContent } from '@/components/decisions/ProcessBuilder/ProcessBuilderContent';
import { ProcessBuilderFooter } from '@/components/decisions/ProcessBuilder/ProcessBuilderFooter';
import { ProcessBuilderHeader } from '@/components/decisions/ProcessBuilder/ProcessBuilderHeader';
import { ProcessBuilderSidebar } from '@/components/decisions/ProcessBuilder/ProcessBuilderSectionNav';
import { ProcessBuilderStoreInitializer } from '@/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer';
Expand Down Expand Up @@ -48,7 +49,10 @@ const EditDecisionPage = async ({
/>
<ProcessBuilderHeader instanceId={instanceId} slug={slug} />
<div className="flex min-h-0 grow flex-col overflow-y-auto md:flex-row md:overflow-y-hidden">
<ProcessBuilderSidebar instanceId={instanceId} />
<ProcessBuilderSidebar
instanceId={instanceId}
decisionProfileId={decisionProfile.id}
/>
<main className="h-full grow overflow-y-auto">
<ProcessBuilderContent
decisionProfileId={decisionProfile.id}
Expand All @@ -57,6 +61,11 @@ const EditDecisionPage = async ({
/>
</main>
</div>
<ProcessBuilderFooter
instanceId={instanceId}
slug={slug}
decisionProfileId={decisionProfile.id}
/>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
'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 { toast } from '@op/ui/Toast';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import {
LuCheck,
LuCircle,
LuCircleAlert,
LuLogOut,
LuPlus,
LuSave,
} from 'react-icons/lu';

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

import { LaunchProcessModal } from './LaunchProcessModal';
import { useProcessBuilderStore } from './stores/useProcessBuilderStore';
import type { ValidationSummary } from './validation/processBuilderValidation';
import { useProcessBuilderValidation } from './validation/useProcessBuilderValidation';

export const ProcessBuilderFooter = ({
instanceId,
slug,
decisionProfileId,
}: {
instanceId: string;
slug: string;
decisionProfileId: string;
}) => {
const t = useTranslations();
const router = useRouter();
const [isLaunchModalOpen, setIsLaunchModalOpen] = useState(false);

const validation = useProcessBuilderValidation(decisionProfileId);

const { data: decisionProfile } = trpc.decision.getDecisionBySlug.useQuery(
{ slug },
{ enabled: !!slug },
);

const processInstance = decisionProfile?.processInstance;
const instanceStatus = processInstance?.status as ProcessStatus | undefined;
const isDraft = instanceStatus === ProcessStatus.DRAFT;
const isTerminalStatus =
instanceStatus === ProcessStatus.COMPLETED ||
instanceStatus === ProcessStatus.CANCELLED;

const storeData = useProcessBuilderStore(
(s) => s.instances[decisionProfileId],
);
const displayName =
storeData?.name || decisionProfile?.name || t('New process');

const utils = trpc.useUtils();

const updateInstance = trpc.decision.updateDecisionInstance.useMutation({
onSuccess: () => {
toast.success({ message: t('Changes saved successfully') });
router.push(`/decisions/${slug}`);
},
onError: (error) => {
toast.error({
message: t('Failed to save changes'),
title: error.message,
});
},
onSettled: () => {
void utils.decision.getDecisionBySlug.invalidate({ slug });
},
});

const handleLaunchOrSave = () => {
if (isDraft) {
setIsLaunchModalOpen(true);
} else {
updateInstance.mutate({
instanceId,
name: storeData?.name || undefined,
description: storeData?.description || undefined,
stewardProfileId: storeData?.stewardProfileId || undefined,
phases: storeData?.phases,
proposalTemplate: storeData?.proposalTemplate,
config: storeData?.config,
});
}
};

return (
<>
<footer className="sticky bottom-0 z-20 flex h-14 shrink-0 items-center justify-between border-t bg-white/80 px-4 backdrop-blur md:px-8">
<div className="flex items-center gap-2">
<Link
href={`/decisions/${slug}`}
className="inline-flex h-8 items-center gap-2 rounded-md border border-neutral-gray2 px-3 text-sm text-charcoal transition-colors hover:bg-neutral-gray1"
>
<LuLogOut className="size-4 rotate-180" />
{t('Exit')}
</Link>
</div>

<div className="flex items-center gap-2">
{validation.stepsRemaining > 0 && (
<StepsRemainingPopover validation={validation} />
)}
<Button
className="h-8 rounded-md"
onPress={handleLaunchOrSave}
isDisabled={
updateInstance.isPending ||
!validation.isReadyToLaunch ||
isTerminalStatus
}
>
{isDraft ? (
<LuPlus className="size-4" />
) : (
<LuSave className="size-4" />
)}
<span className="md:hidden">
{isDraft ? t('Launch') : t('Update')}
</span>
<span className="hidden md:inline">
{isDraft ? t('Launch Process') : t('Update Process')}
</span>
</Button>
</div>
</footer>

<LaunchProcessModal
isOpen={isLaunchModalOpen}
onOpenChange={setIsLaunchModalOpen}
instanceId={instanceId}
processName={displayName}
slug={slug}
decisionProfileId={decisionProfileId}
/>
</>
);
};

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

return (
<DialogTrigger>
<Button
className="flex aspect-square h-8 gap-2 rounded-md 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="top 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>
);
};
Loading
Loading