diff --git a/apps/app/src/components/decisions/ProcessBuilder/components/ToggleRow.tsx b/apps/app/src/components/decisions/ProcessBuilder/components/ToggleRow.tsx index a039bf1a6..52eff611a 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/components/ToggleRow.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/components/ToggleRow.tsx @@ -1,8 +1,6 @@ -'use client'; - import { Button } from '@op/ui/Button'; import { Tooltip, TooltipTrigger } from '@op/ui/Tooltip'; -import { LuCircleHelp } from 'react-icons/lu'; +import { LuInfo } from 'react-icons/lu'; // Toggle row component for consistent styling export function ToggleRow({ @@ -20,8 +18,8 @@ export function ToggleRow({ {label} {tooltip && ( - {tooltip} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSection.tsx index 126513ae6..ea02cb6db 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import type { SectionProps } from '../../contentRegistry'; import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; @@ -11,24 +11,23 @@ import { OverviewSectionSkeleton } from './OverviewSectionSkeleton'; export default function OverviewSection(props: SectionProps) { const [hasHydrated, setHasHydrated] = useState(false); - // Manually trigger hydration and wait for completion - // Using skipHydration: true in the store gives us full control over timing useEffect(() => { const unsubscribe = useProcessBuilderStore.persist.onFinishHydration(() => { setHasHydrated(true); }); - // Manually trigger rehydration from localStorage void useProcessBuilderStore.persist.rehydrate(); return unsubscribe; }, []); - // Show skeleton until Zustand has hydrated from localStorage if (!hasHydrated) { return ; } - // Only render the form after hydration so defaultValues are correct - return ; + return ( + }> + + + ); } diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx index ae6ebc491..62281671c 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx @@ -1,14 +1,13 @@ 'use client'; import { trpc } from '@op/api/client'; -import { useDebounce } from '@op/hooks'; -import { NumberField } from '@op/ui/NumberField'; +import { useDebouncedCallback } from '@op/hooks'; import { SelectItem } from '@op/ui/Select'; import { useEffect, useRef } from 'react'; +import { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; -import { RichTextEditorWithToolbar } from '@/components/RichTextEditor/RichTextEditorWithToolbar'; import { getFieldErrorMessage, useAppForm } from '@/components/form/utils'; import { SaveStatusIndicator } from '../../components/SaveStatusIndicator'; @@ -18,43 +17,39 @@ import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; const AUTOSAVE_DEBOUNCE_MS = 1000; +const createOverviewValidator = (t: (key: string) => string) => + z.object({ + stewardProfileId: z + .string({ message: t('Select a steward for this process') }) + .min(1, { message: t('Select a steward for this process') }), + name: z + .string({ message: t('Enter a process name') }) + .min(1, { message: t('Enter a process name') }), + description: z + .string({ message: t('Enter a description') }) + .min(1, { message: t('Enter a description') }), + organizeByCategories: z.boolean(), + requireCollaborativeProposals: z.boolean(), + isPrivate: z.boolean(), + }); + // Form data type -interface OverviewFormData { - stewardProfileId: string; - objective: string; - name: string; - description: string; - budget: number | undefined; - hideBudget: boolean; - includeReview: boolean; - isPrivate: boolean; -} +type OverviewFormData = z.infer>; -// Auto-save component that subscribes to form values -function AutoSaveHandler({ +// Watches form values and triggers debounced save on changes +function FormValueWatcher({ values, - decisionProfileId, - setInstanceData, - setSaveStatus, - markSaved, + onValuesChange, }: { values: OverviewFormData; - decisionProfileId: string; - setInstanceData: (id: string, data: Partial) => void; - setSaveStatus: ( - id: string, - status: 'idle' | 'saving' | 'saved' | 'error', - ) => void; - markSaved: (id: string) => void; + onValuesChange: (values: OverviewFormData) => void; }) { - const [debouncedValues] = useDebounce(values, AUTOSAVE_DEBOUNCE_MS); const isInitialMount = useRef(true); const previousValues = useRef(null); useEffect(() => { - const valuesString = JSON.stringify(debouncedValues); + const valuesString = JSON.stringify(values); - // Skip initial mount if (isInitialMount.current) { isInitialMount.current = false; previousValues.current = valuesString; @@ -66,31 +61,8 @@ function AutoSaveHandler({ return; } previousValues.current = valuesString; - - // Update Zustand store (persists to localStorage) - setSaveStatus(decisionProfileId, 'saving'); - setInstanceData(decisionProfileId, { - name: debouncedValues.name, - description: debouncedValues.description, - stewardProfileId: debouncedValues.stewardProfileId, - objective: debouncedValues.objective, - budget: debouncedValues.budget, - hideBudget: debouncedValues.hideBudget, - includeReview: debouncedValues.includeReview, - isPrivate: debouncedValues.isPrivate, - }); - - // Mark as saved with timestamp - markSaved(decisionProfileId); - - // TODO: Add API mutation here once storage location is decided - }, [ - debouncedValues, - decisionProfileId, - setInstanceData, - setSaveStatus, - markSaved, - ]); + onValuesChange(values); + }, [values, onValuesChange]); return null; } @@ -102,11 +74,12 @@ export function OverviewSectionForm({ decisionName, }: SectionProps) { const t = useTranslations(); + const utils = trpc.useUtils(); - // tRPC mutation - const updateInstance = trpc.decision.updateDecisionInstance.useMutation(); + const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); + const isDraft = instance.status === 'draft'; - // Zustand store - using new instanceData structure + // Store: used as a localStorage buffer for non-draft edits only const instanceData = useProcessBuilderStore( (s) => s.instances[decisionProfileId], ); @@ -117,6 +90,20 @@ export function OverviewSectionForm({ const setSaveStatus = useProcessBuilderStore((s) => s.setSaveStatus); const markSaved = useProcessBuilderStore((s) => s.markSaved); + // Reset stale save status from previous sessions + useEffect(() => { + setSaveStatus(decisionProfileId, 'idle'); + }, [decisionProfileId, setSaveStatus]); + + // tRPC mutation with cache invalidation (matches phase editor pattern) + const updateInstance = trpc.decision.updateDecisionInstance.useMutation({ + onSuccess: () => markSaved(decisionProfileId), + onError: () => setSaveStatus(decisionProfileId, 'error'), + onSettled: () => { + void utils.decision.getInstance.invalidate({ instanceId }); + }, + }); + // Fetch the current user's profiles (individual + organizations) const { data: userProfiles } = trpc.account.getUserProfiles.useQuery(); const profileItems = (userProfiles ?? []).map((p) => ({ @@ -124,35 +111,55 @@ export function OverviewSectionForm({ name: p.name, })); + // Debounced save: always buffer in localStorage; draft also persists to API. + const debouncedSave = useDebouncedCallback((values: OverviewFormData) => { + setSaveStatus(decisionProfileId, 'saving'); + + setInstanceData(decisionProfileId, { + name: values.name, + description: values.description, + stewardProfileId: values.stewardProfileId, + organizeByCategories: values.organizeByCategories, + requireCollaborativeProposals: values.requireCollaborativeProposals, + isPrivate: values.isPrivate, + }); + + if (isDraft) { + updateInstance.mutate({ + instanceId, + name: values.name || undefined, + description: values.description || undefined, + stewardProfileId: values.stewardProfileId || undefined, + }); + } else { + markSaved(decisionProfileId); + } + }, AUTOSAVE_DEBOUNCE_MS); + + // Non-draft: prefer store (localStorage buffer) over API data. + // Draft: use API data (query cache kept fresh via onSettled invalidation). + const initialName = + !isDraft && instanceData?.name + ? instanceData.name + : (instance.name ?? decisionName ?? ''); + const initialDescription = + !isDraft && instanceData?.description + ? instanceData.description + : (instance.description ?? ''); + const form = useAppForm({ defaultValues: { stewardProfileId: instanceData?.stewardProfileId ?? '', - objective: instanceData?.objective ?? '', - budget: instanceData?.budget, - hideBudget: instanceData?.hideBudget ?? true, - includeReview: instanceData?.includeReview ?? true, + name: initialName, + description: initialDescription, + organizeByCategories: instanceData?.organizeByCategories ?? true, + requireCollaborativeProposals: + instanceData?.requireCollaborativeProposals ?? true, isPrivate: instanceData?.isPrivate ?? false, - // Instance-level fields - name: instanceData?.name ?? decisionName ?? '', - description: instanceData?.description ?? '', }, - onSubmit: ({ value }) => { - setSaveStatus(decisionProfileId, 'saving'); - updateInstance.mutate( - { - instanceId, - name: value.name, - description: value.description, - stewardProfileId: value.stewardProfileId || undefined, - config: { - hideBudget: value.hideBudget, - }, - }, - { - onSuccess: () => markSaved(decisionProfileId), - onError: () => setSaveStatus(decisionProfileId, 'error'), - }, - ); + validators: { + onBlur: createOverviewValidator(t), + onChange: createOverviewValidator(t), }, }); @@ -161,34 +168,35 @@ export function OverviewSectionForm({
{ e.preventDefault(); - void form.handleSubmit(); }} > - {/* Auto-save handler - subscribes to form values */} + {/* Watch form values and trigger debounced save */} state.values} children={(values) => ( - )} />
- {/* Process Stewardship Section */} + {/* Process Overview Section */}
-
-

- {t('Process Overview')} -

- +
+
+

+ {t('Process Overview')} +

+ +
+

+ {t('Define the key details for your decision process.')} +

(
@@ -259,73 +265,15 @@ export function OverviewSectionForm({ )} /> - ( -
- - -

- {t( - 'This information appears when participants want to learn more about the process', - )} -

-
- )} - /> - - ( -
- field.handleChange(value ?? undefined)} - prefixText="$" - inputProps={{ - placeholder: '0.00', - }} - /> -

- {t('The total amount available this funding round.')} -

-
- )} - /> - {/* Toggle Options */} -
+
( - - - - )} - /> - - ( (
- {t('Save')} + + {/* Visibility Section */} +
+

{t('Visibility')}

+ + ( + + + + )} + /> +
diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionSkeleton.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionSkeleton.tsx index ab9f32283..a75f7ee22 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionSkeleton.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionSkeleton.tsx @@ -1,5 +1,3 @@ -'use client'; - import { Skeleton } from '@op/ui/Skeleton'; // Skeleton shown while Zustand hydrates from localStorage diff --git a/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts b/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts index 327ffeee3..0eb086d37 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts @@ -70,6 +70,10 @@ export interface FormInstanceData includeReview?: boolean; /** Whether to keep process private */ isPrivate?: boolean; + /** Whether to organize proposals into categories */ + organizeByCategories?: boolean; + /** Whether to require collaborative proposals */ + requireCollaborativeProposals?: boolean; /** Proposal template (JSON Schema) */ proposalTemplate?: ProposalTemplate; /** Proposal categories */ diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 02ee70e8a..326a52f24 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -524,6 +524,12 @@ "Add a review stage where designated reviewers evaluate proposals before voting": "একটি পর্যালোচনা পর্যায় যোগ করুন যেখানে মনোনীত পর্যালোচকরা ভোট দেওয়ার আগে প্রস্তাবগুলি মূল্যায়ন করে", "Keep this process private": "এই প্রক্রিয়াটি ব্যক্তিগত রাখুন", "Only invited members can view and participate in this process": "শুধুমাত্র আমন্ত্রিত সদস্যরা এই প্রক্রিয়া দেখতে এবং অংশগ্রহণ করতে পারেন", + "Process Overview": "প্রক্রিয়া সংক্ষেপ", + "Organize proposals into categories": "প্রস্তাবগুলি বিভাগে সংগঠিত করুন", + "Group proposals into categories for better organization and evaluation": "ভালো সংগঠন এবং মূল্যায়নের জন্য প্রস্তাবগুলি বিভাগে গোষ্ঠীবদ্ধ করুন", + "Require collaborative proposals": "সহযোগী প্রস্তাব প্রয়োজন", + "Require proposals to be co-authored by multiple participants": "একাধিক অংশগ্রহণকারীদের দ্বারা প্রস্তাবগুলি সহ-রচনা করা প্রয়োজন", + "Visibility": "দৃশ্যমানতা", "Save": "সংরক্ষণ", "Saving...": "���ংরক্ষণ হচ্ছে...", "Saved": "সংরক্ষিত", @@ -663,5 +669,8 @@ "Minimum must be less than or equal to maximum": "সর্বনিম্ন অবশ্যই সর্বোচ্চের সমান বা কম হতে হবে", "These are the categories you defined in": "এগুলি আপনার সংজ্ঞায়িত বিভাগগুলি", "Set maximum budget": "সর্বোচ্চ বাজেট নির্ধারণ করুন", + "Select a steward for this process": "এই প্রক্রিয়ার জন্য একজন তত্ত্বাবধায়ক নির্বাচন করুন", + "Enter a process name": "একটি প্রক্রিয়ার নাম লিখুন", + "Enter a description": "একটি বিবরণ লিখুন", "This invite is no longer valid": "এই আমন্ত্রণটি আর বৈধ নয়" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 7a3793971..e5cc60d3a 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -517,6 +517,12 @@ "Add a review stage where designated reviewers evaluate proposals before voting": "Add a review stage where designated reviewers evaluate proposals before voting", "Keep this process private": "Keep this process private", "Only invited members can view and participate in this process": "Only invited members can view and participate in this process", + "Process Overview": "Process Overview", + "Organize proposals into categories": "Organize proposals into categories", + "Group proposals into categories for better organization and evaluation": "Group proposals into categories for better organization and evaluation", + "Require collaborative proposals": "Require collaborative proposals", + "Require proposals to be co-authored by multiple participants": "Require proposals to be co-authored by multiple participants", + "Visibility": "Visibility", "Save": "Save", "Saving...": "Saving...", "Saved": "Saved", @@ -656,5 +662,8 @@ "Minimum must be less than or equal to maximum": "Minimum must be less than or equal to maximum", "These are the categories you defined in": "These are the categories you defined in", "Set maximum budget": "Set maximum budget", + "Select a steward for this process": "Select a steward for this process", + "Enter a process name": "Enter a process name", + "Enter a description": "Enter a description", "This invite is no longer valid": "This invite is no longer valid" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 799bd6e3f..e63b4389d 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -516,6 +516,12 @@ "Add a review stage where designated reviewers evaluate proposals before voting": "Agregue una etapa de revisión donde revisores designados evalúan propuestas antes de votar", "Keep this process private": "Mantener este proceso privado", "Only invited members can view and participate in this process": "Solo los miembros invitados pueden ver y participar en este proceso", + "Process Overview": "Resumen del proceso", + "Organize proposals into categories": "Organizar propuestas en categorías", + "Group proposals into categories for better organization and evaluation": "Agrupar propuestas en categorías para una mejor organización y evaluación", + "Require collaborative proposals": "Requerir propuestas colaborativas", + "Require proposals to be co-authored by multiple participants": "Requerir que las propuestas sean coautoradas por múltiples participantes", + "Visibility": "Visibilidad", "Save": "Guardar", "Saving...": "Guardando...", "Saved": "Guardado", @@ -655,5 +661,8 @@ "Minimum must be less than or equal to maximum": "El mínimo debe ser menor o igual al máximo", "These are the categories you defined in": "Estas son las categorías que definiste en", "Set maximum budget": "Establecer presupuesto máximo", + "Select a steward for this process": "Selecciona un administrador para este proceso", + "Enter a process name": "Ingresa un nombre para el proceso", + "Enter a description": "Ingresa una descripción", "This invite is no longer valid": "Esta invitación ya no es válida" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index b514618ba..e06f705ea 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -517,6 +517,12 @@ "Add a review stage where designated reviewers evaluate proposals before voting": "Ajoutez une étape d'évaluation où des évaluateurs désignés examinent les propositions avant le vote", "Keep this process private": "Garder ce processus privé", "Only invited members can view and participate in this process": "Seuls les membres invités peuvent voir et participer à ce processus", + "Process Overview": "Aperçu du processus", + "Organize proposals into categories": "Organiser les propositions en catégories", + "Group proposals into categories for better organization and evaluation": "Regrouper les propositions en catégories pour une meilleure organisation et évaluation", + "Require collaborative proposals": "Exiger des propositions collaboratives", + "Require proposals to be co-authored by multiple participants": "Exiger que les propositions soient co-rédigées par plusieurs participants", + "Visibility": "Visibilité", "Save": "Enregistrer", "Saving...": "Enregistrement...", "Saved": "Enregistré", @@ -655,5 +661,8 @@ "Minimum must be less than or equal to maximum": "Le minimum doit être inférieur ou égal au maximum", "These are the categories you defined in": "Ce sont les catégories que vous avez définies dans", "Set maximum budget": "Définir le budget maximum", + "Select a steward for this process": "Sélectionnez un responsable pour ce processus", + "Enter a process name": "Entrez un nom de processus", + "Enter a description": "Entrez une description", "This invite is no longer valid": "Cette invitation n'est plus valide" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 026e69f8a..bd0fcf32a 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -517,6 +517,12 @@ "Add a review stage where designated reviewers evaluate proposals before voting": "Adicione uma etapa de revisão onde revisores designados avaliam propostas antes da votação", "Keep this process private": "Manter este processo privado", "Only invited members can view and participate in this process": "Apenas membros convidados podem ver e participar deste processo", + "Process Overview": "Visão geral do processo", + "Organize proposals into categories": "Organizar propostas em categorias", + "Group proposals into categories for better organization and evaluation": "Agrupar propostas em categorias para melhor organização e avaliação", + "Require collaborative proposals": "Exigir propostas colaborativas", + "Require proposals to be co-authored by multiple participants": "Exigir que propostas sejam co-autoradas por múltiplos participantes", + "Visibility": "Visibilidade", "Save": "Salvar", "Saving...": "Salvando...", "Saved": "Salvo", @@ -651,5 +657,8 @@ "Minimum must be less than or equal to maximum": "O mínimo deve ser menor ou igual ao máximo", "These are the categories you defined in": "Estas são as categorias que você definiu em", "Set maximum budget": "Definir orçamento máximo", + "Select a steward for this process": "Selecione um responsável para este processo", + "Enter a process name": "Digite um nome para o processo", + "Enter a description": "Digite uma descrição", "This invite is no longer valid": "Este convite não é mais válido" } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx index 11a8ad6df..6266289e0 100644 --- a/packages/ui/src/components/Select.tsx +++ b/packages/ui/src/components/Select.tsx @@ -120,7 +120,7 @@ export const Select = ({