From a51ece71b09f8bdad3e42d5c284aef7c45f580f4 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Tue, 1 Sep 2026 17:48:34 -0400 Subject: [PATCH 1/9] [Notebook] Applying Autosave functionality and removing local storage from notebook --- app/notebook/NotebookClientLayout.tsx | 5 +- app/notebook/[orgSlug]/page.tsx | 4 +- .../Editor/lib/utils/publishingFormStorage.ts | 128 ---------- .../components/FundingSection.tsx | 3 +- components/Notebook/PublishingForm/index.tsx | 231 +++++++++++++----- components/modals/ApplyToGrantModal.tsx | 25 +- .../modals/SelectFundingOpportunityModal.tsx | 3 +- components/work/WorkHeader/WorkHeader.tsx | 4 - .../work/WorkHeader/WorkHeaderGrant.tsx | 2 - .../work/WorkHeader/WorkHeaderModals.tsx | 6 - hooks/useNoteDetailsSaver.ts | 84 +++++++ services/note.service.ts | 22 +- types/grant.ts | 19 ++ types/note.ts | 169 ++++++++++++- 14 files changed, 452 insertions(+), 253 deletions(-) delete mode 100644 components/Editor/lib/utils/publishingFormStorage.ts create mode 100644 hooks/useNoteDetailsSaver.ts diff --git a/app/notebook/NotebookClientLayout.tsx b/app/notebook/NotebookClientLayout.tsx index 6b9de9a60..b2aebcda4 100644 --- a/app/notebook/NotebookClientLayout.tsx +++ b/app/notebook/NotebookClientLayout.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactNode, useEffect, useState } from 'react'; +import { ReactNode, useState } from 'react'; import './globals.css'; import 'cal-sans/index.css'; import 'katex/dist/katex.min.css'; @@ -16,11 +16,8 @@ import '@fontsource/inter/700.css'; import { PageLayout } from '@/app/layouts/PageLayout'; import { NotebookProvider } from '@/contexts/NotebookContext'; import { NoteEditorLayout } from '@/components/Notebook/NoteEditorLayout'; -import { clearPendingGrant } from '@/components/Editor/lib/utils/publishingFormStorage'; function NotebookContent({ children }: Readonly<{ children: ReactNode }>) { - useEffect(() => () => clearPendingGrant(), []); - // The docked assistant already reserves its own gutter inside the page // container, so keeping the container capped would centre the document in // what's left and leave a wide dead band on either side. Release the cap for diff --git a/app/notebook/[orgSlug]/page.tsx b/app/notebook/[orgSlug]/page.tsx index 7d32ec6e3..fb1bc6b0a 100644 --- a/app/notebook/[orgSlug]/page.tsx +++ b/app/notebook/[orgSlug]/page.tsx @@ -14,7 +14,6 @@ import { import { useCreateNote, useNoteContent } from '@/hooks/useNote'; import { NoteCreationPopover } from '@/components/Notebook/NoteCreationPopover'; import { useUser } from '@/contexts/UserContext'; -import { getPendingGrant } from '@/components/Editor/lib/utils/publishingFormStorage'; import type { ID } from '@/types/root'; // An empty document for the "Start blank" funding-opportunity path. The @@ -42,6 +41,7 @@ export default function OrganizationPage() { const isNewGrant = searchParams.get('newGrant') === 'true'; const grantSource = searchParams.get('grantSource'); const proposalSource = searchParams.get('proposalSource'); + const selectedGrantId = searchParams.get('selectedGrantId') ?? undefined; const createNoteWithContent = async ( orgSlug: string, @@ -103,7 +103,6 @@ export default function OrganizationPage() { } else if (isNewFunding) { // "Upload a document" is handled inline in OpenProposalModal; here we // only create from template/blank. - const selectedGrantId = getPendingGrant()?.id; if (proposalSource === 'blank') { createNoteWithContent(selectedOrg.slug, { template: BLANK_DOCUMENT, @@ -132,6 +131,7 @@ export default function OrganizationPage() { isNewGrant, grantSource, proposalSource, + selectedGrantId, ]); // eslint-disable-line react-hooks/exhaustive-deps const handleStartFromTemplate = async (selectedGrantId?: Exclude) => { diff --git a/components/Editor/lib/utils/publishingFormStorage.ts b/components/Editor/lib/utils/publishingFormStorage.ts deleted file mode 100644 index a658d1957..000000000 --- a/components/Editor/lib/utils/publishingFormStorage.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { PublishingFormData } from '@/components/Notebook/PublishingForm/schema'; -import type { GrantApplicationVisibility } from '@/types/grant'; - -const STORAGE_KEY = 'publishing_forms'; -const MAX_STORED_NOTES = 20; - -// Fields that should be excluded from storage -const EXCLUDED_FIELDS = ['coverImage'] as const; - -type StoredNote = { - noteId: string; - data: Partial; - timestamp: number; -}; - -const getStoredNotes = (): StoredNote[] => { - if (typeof window === 'undefined') return []; - try { - const stored = localStorage.getItem(STORAGE_KEY); - return stored ? JSON.parse(stored) : []; - } catch (error) { - console.error('Error reading publishing forms from localStorage:', error); - return []; - } -}; - -// Helper function to remove excluded fields from data -const removeExcludedFields = (data: Partial): Partial => { - const filteredData = { ...data }; - EXCLUDED_FIELDS.forEach((field) => { - delete filteredData[field]; - }); - return filteredData; -}; - -export const savePublishingFormToStorage = (noteId: string, data: Partial) => { - if (typeof window === 'undefined') return; - try { - const storedNotes = getStoredNotes(); - const currentIndex = storedNotes.findIndex((note) => note.noteId === noteId); - // Remove excluded fields before storing - const filteredData = removeExcludedFields(data); - const newNote: StoredNote = { noteId, data: filteredData, timestamp: Date.now() }; - - if (currentIndex !== -1) { - // Update existing note - storedNotes[currentIndex] = newNote; - } else { - // Add new note, remove oldest if at limit - if (storedNotes.length >= MAX_STORED_NOTES) { - storedNotes.shift(); // Remove oldest note - } - storedNotes.push(newNote); - } - - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedNotes)); - } catch (error) { - console.error('Error saving publishing form to localStorage:', error); - } -}; - -export const loadPublishingFormFromStorage = ( - noteId: string -): Partial | null => { - if (typeof window === 'undefined') return null; - try { - const storedNotes = getStoredNotes(); - const note = storedNotes.find((note) => note.noteId === noteId); - - if (!note) return null; - - return note.data; - } catch (error) { - console.error('Error reading publishing form from localStorage:', error); - return null; - } -}; - -export const clearPublishingFormStorage = (noteId: string) => { - if (typeof window === 'undefined') return; - try { - const storedNotes = getStoredNotes(); - const filteredNotes = storedNotes.filter((note) => note.noteId !== noteId); - localStorage.setItem(STORAGE_KEY, JSON.stringify(filteredNotes)); - } catch (error) { - console.error('Error clearing publishing form from localStorage:', error); - } -}; - -const PENDING_GRANT_KEY = 'pendingGrant'; - -export interface SelectedGrantData { - id: string; - shortTitle: string; - imageUrl: string; - fundingAmount: number; - organization: string; - applicationVisibility?: GrantApplicationVisibility; -} - -export const setPendingGrant = (grant: SelectedGrantData) => { - if (globalThis.window === undefined) return; - try { - sessionStorage.setItem(PENDING_GRANT_KEY, JSON.stringify(grant)); - } catch (error) { - console.error('Error saving pending grant:', error); - } -}; - -export const getPendingGrant = (): SelectedGrantData | null => { - if (globalThis.window === undefined) return null; - try { - const raw = sessionStorage.getItem(PENDING_GRANT_KEY); - return raw ? JSON.parse(raw) : null; - } catch (error) { - console.error('Error reading pending grant:', error); - return null; - } -}; - -export const clearPendingGrant = () => { - if (globalThis.window === undefined) return; - try { - sessionStorage.removeItem(PENDING_GRANT_KEY); - } catch (error) { - console.error('Error clearing pending grant:', error); - } -}; diff --git a/components/Notebook/PublishingForm/components/FundingSection.tsx b/components/Notebook/PublishingForm/components/FundingSection.tsx index ee05d50ab..44aeecfb4 100644 --- a/components/Notebook/PublishingForm/components/FundingSection.tsx +++ b/components/Notebook/PublishingForm/components/FundingSection.tsx @@ -12,9 +12,8 @@ import { NonprofitSearchSection } from '@/components/Nonprofit'; import { useNonprofitByFundraiseId } from '@/hooks/useNonprofitByFundraiseId'; import { useNonprofitSearch } from '@/hooks/useNonprofitSearch'; import { SelectFundingOpportunityModal } from '@/components/modals/SelectFundingOpportunityModal'; -import type { SelectedGrantData } from '@/components/Editor/lib/utils/publishingFormStorage'; import { formatCompactAmount } from '@/utils/currency'; -import { GRANT_IMAGE_FALLBACK_GRADIENT } from '@/types/grant'; +import { GRANT_IMAGE_FALLBACK_GRADIENT, type SelectedGrantData } from '@/types/grant'; import { NoteService } from '@/services/note.service'; interface FundingSectionProps { diff --git a/components/Notebook/PublishingForm/index.tsx b/components/Notebook/PublishingForm/index.tsx index 2ed430b15..64fa1948f 100644 --- a/components/Notebook/PublishingForm/index.tsx +++ b/components/Notebook/PublishingForm/index.tsx @@ -1,7 +1,7 @@ import { useForm, FormProvider } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { publishingFormSchema } from './schema'; -import type { PublishingFormData } from './schema'; +import type { PublishingFormData, SelectOption } from './schema'; import { WorkImageSection } from './components/WorkImageSection'; import { FundingSection } from './components/FundingSection'; import { AuthorsSection } from './components/AuthorsSection'; @@ -29,12 +29,6 @@ import { import { ResearchCoinSection } from './components/ResearchCoinSection'; import { EndDateSection } from './components/EndDateSection'; import { toast } from 'react-hot-toast'; -import { - loadPublishingFormFromStorage, - savePublishingFormToStorage, - getPendingGrant, - clearPendingGrant, -} from '@/components/Editor/lib/utils/publishingFormStorage'; import { PublishingFormSkeleton } from '@/components/skeletons/PublishingFormSkeleton'; import { Loader2 } from 'lucide-react'; import { DOISection } from '@/components/work/components/DOISection'; @@ -49,7 +43,15 @@ import { extractApiErrorMessage } from '@/services/lib/serviceUtils'; import { ARTICLE_TYPE_API_MAP } from '@/services/post.service'; import { mergeRegisteredReportPrefill } from '@/utils/registeredReportPrefill'; import { buildRegisteredReportUrl } from '@/utils/registeredReportRoute'; -import { isChangelogNote, isRegisteredReportNote, type NoteWithContent } from '@/types/note'; +import { + isChangelogNote, + isRegisteredReportNote, + type NoteDetailsUpdate, + type NoteWithContent, +} from '@/types/note'; +import type { NonprofitOrg } from '@/types/nonprofit'; +import { NonprofitService } from '@/services/nonprofit.service'; +import { useNoteDetailsSaver, type NoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; import { getAvailableNotebookWorkTypes } from '@/components/Notebook/NotebookPrimaryNavigation'; const FEATURE_FLAG_RESEARCH_COIN = false; @@ -183,50 +185,147 @@ const populateFromPost = (post: any, setValue: (name: any, value: any) => void) } }; -const populateRegisteredReportFields = ( +const mapOptionsToIds = (options: SelectOption[]): number[] => + options.map((option) => Number(option.value)).filter((id) => !Number.isNaN(id)); + +/** Both amount inputs accept digits only, so a saved `5000.00` reads back as `5000`. */ +const dropZeroCents = (amount: string): string => amount.replace(/\.0+$/, ''); + +/** Loads the Details this draft has already saved on the server. */ +const populateNoteDetails = (note: NoteWithContent, setValue: (name: any, value: any) => void) => { + if (note.topics?.length) { + setValue( + 'topics', + note.topics.map((topic) => ({ value: topic.id.toString(), label: topic.name })) + ); + } + if (note.authors?.length) { + setValue( + 'authors', + note.authors.map((author) => ({ value: author.authorId.toString(), label: author.name })) + ); + } + if (note.selectedGrant) { + setValue('selectedGrant', note.selectedGrant); + } + + const { grantSettings, preregistrationSettings } = note; + if (grantSettings) { + if (grantSettings.amount) setValue('budget', dropZeroCents(grantSettings.amount)); + if (grantSettings.organization) setValue('organization', grantSettings.organization); + if (grantSettings.description) setValue('shortDescription', grantSettings.description); + if (grantSettings.applicationVisibility) { + setValue('applicationVisibility', grantSettings.applicationVisibility); + } + if (grantSettings.contacts.length > 0) { + setValue( + 'contacts', + grantSettings.contacts.map((contact) => ({ + value: contact.id.toString(), + label: contact.name, + })) + ); + } + } + if (preregistrationSettings) { + const { goalAmount, durationDays, isPublic, nonprofit } = preregistrationSettings; + if (goalAmount) setValue('budget', dropZeroCents(goalAmount)); + if (durationDays) setValue('fundraiseEndDays', durationDays.toString()); + if (isPublic !== null) setValue('isPublic', isPublic); + if (nonprofit) setValue('selectedNonprofit', nonprofit); + } +}; + +/** Fills the gaps a Registered Report's proposal covers, which are ids without labels. */ +const populateRegisteredReportPrefill = ( note: NoteWithContent, getValues: (name: any) => any, setValue: (name: any, value: any) => void ) => { - const topics = note.topics ?? []; - const authors = note.authors ?? []; - const topicOptions = - topics.length > 0 - ? topics.map((topic) => ({ value: topic.id.toString(), label: topic.name })) - : (note.registeredReportPrefill?.topicIds ?? []).map((id) => ({ - value: id.toString(), - label: `Topic ${id}`, - })); - const authorOptions = - authors.length > 0 - ? authors.map((author) => ({ - value: author.authorId.toString(), - label: author.name, - })) - : (note.registeredReportPrefill?.authorIds ?? []).map((id) => ({ - value: id.toString(), - label: `Author ${id}`, - })); + const { topicIds = [], authorIds = [] } = note.registeredReportPrefill ?? {}; if (note.previewImage && !getValues('coverImage')) { setValue('coverImage', { file: null, url: note.previewImage }); } - if (topicOptions.length > 0 && getValues('topics').length === 0) { - setValue('topics', topicOptions); + if (topicIds.length > 0 && getValues('topics').length === 0) { + setValue( + 'topics', + topicIds.map((id) => ({ value: id.toString(), label: `Topic ${id}` })) + ); + } + + if (authorIds.length > 0 && getValues('authors').length === 0) { + setValue( + 'authors', + authorIds.map((id) => ({ value: id.toString(), label: `Author ${id}` })) + ); } +}; - if (authorOptions.length > 0 && getValues('authors').length === 0) { - setValue('authors', authorOptions); +/** Maps one changed Details field to the update that saves it on the note. */ +const buildDetailsUpdate = ( + field: string, + values: PublishingFormData +): NoteDetailsUpdate | null => { + const isGrant = values.articleType === 'grant'; + const isProposal = values.articleType === 'preregistration'; + + switch (field) { + case 'articleType': + return { documentType: ARTICLE_TYPE_API_MAP[values.articleType] }; + case 'authors': + return { authorIds: mapOptionsToIds(values.authors) }; + case 'topics': + return { hubIds: mapOptionsToIds(values.topics) }; + case 'contacts': + return isGrant ? { grantSettings: { contactIds: mapOptionsToIds(values.contacts) } } : null; + case 'organization': + return isGrant ? { grantSettings: { organization: values.organization } } : null; + case 'shortDescription': + return isGrant ? { grantSettings: { description: values.shortDescription } } : null; + case 'applicationVisibility': + return isGrant + ? { grantSettings: { applicationVisibility: values.applicationVisibility } } + : null; + case 'fundraiseEndDays': + return isProposal + ? { preregistrationSettings: { durationDays: Number(values.fundraiseEndDays) } } + : null; + case 'isPublic': + return isProposal ? { preregistrationSettings: { isPublic: values.isPublic } } : null; + case 'budget': + // An empty box is a half-typed amount, not a decision to clear the saved one. + if (!values.budget) return null; + if (isGrant) return { grantSettings: { amount: values.budget, currency: 'USD' } }; + return isProposal + ? { preregistrationSettings: { goalAmount: values.budget, goalCurrency: 'USD' } } + : null; + default: + return null; } }; -const restoreFromStorage = ( - data: Record, - setValue: (name: any, value: any) => void +/** Saves the nonprofit under the id the Note API stores, not its Endaoment one. */ +const saveSelectedNonprofit = async ( + nonprofit: NonprofitOrg | null, + saveDetailsSoon: NoteDetailsSaver['saveDetailsSoon'] ) => { - for (const [key, value] of Object.entries(data)) { - setValue(key, key === 'applicationDeadline' && value ? new Date(value) : value); + if (!nonprofit) { + saveDetailsSoon({ preregistrationSettings: { nonprofitId: null } }); + return; + } + + try { + const saved = await NonprofitService.createNonprofit({ + name: nonprofit.name, + endaomentOrgId: nonprofit.endaomentOrgId, + ein: nonprofit.ein, + baseWalletAddress: nonprofit.baseWalletAddress, + }); + saveDetailsSoon({ preregistrationSettings: { nonprofitId: saved.id } }); + } catch (error) { + console.error('Error saving selected nonprofit:', error); } }; @@ -301,6 +400,8 @@ export function PublishingForm({ }); const noteId = note?.id; + const isPublished = Boolean(note?.post); + const { saveDetailsSoon, saveDetailsNow } = useNoteDetailsSaver(noteId); useEffect(() => { if (!note) return; @@ -311,17 +412,13 @@ export function PublishingForm({ if (note.post) { populateFromPost(note.post, methods.setValue); } else { - const storedData = loadPublishingFormFromStorage(note.id.toString()); - if (storedData) { - restoreFromStorage(storedData, methods.setValue); - } + populateNoteDetails(note, methods.setValue); if (isRegisteredReport) { - populateRegisteredReportFields(note, methods.getValues, methods.setValue); + populateRegisteredReportPrefill(note, methods.getValues, methods.setValue); } const articleType = - storedData?.articleType ?? (note.documentType ? mapDocumentTypeToArticleType(note.documentType) : null) ?? resolveArticleType(searchParams); @@ -337,31 +434,33 @@ export function PublishingForm({ applyGrantDefaults(methods.getValues, methods.setValue); autoAddCurrentUser(methods.getValues, methods.setValue, currentUser); - const pending = getPendingGrant(); - if (pending) { - methods.setValue('selectedGrant', pending); - if (pending.applicationVisibility === 'PRIVATE') { - methods.setValue('isPublic', false); - } - clearPendingGrant(); + // A proposal answering a private Request for Proposal cannot be public. + if (methods.getValues('selectedGrant')?.applicationVisibility === 'PRIVATE') { + methods.setValue('isPublic', false); } - - savePublishingFormToStorage( - note.id.toString(), - methods.getValues() as Partial - ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [noteId]); + // Declared after the effect above so React unsubscribes before it hydrates, + // which is what keeps loading a note from saving it straight back. useEffect(() => { - if (!noteId) return; + if (!noteId || isPublished) return; - const subscription = methods.watch((data) => { - savePublishingFormToStorage(noteId.toString(), data as Partial); + const subscription = methods.watch((_values, { name }) => { + if (!name) return; + + const values = methods.getValues(); + if (name === 'selectedNonprofit') { + void saveSelectedNonprofit(values.selectedNonprofit, saveDetailsSoon); + return; + } + + const update = buildDetailsUpdate(name, values); + if (update) saveDetailsSoon(update); }); return () => subscription.unsubscribe(); - }, [noteId, methods]); + }, [noteId, isPublished, methods, saveDetailsSoon]); const { watch, clearErrors } = methods; const articleType = watch('articleType'); @@ -511,6 +610,10 @@ export function PublishingForm({ try { setDocumentTitle(editor, editedTitle); + // Drain the queue now: publishing supersedes the draft, and the API + // rejects Details on a published note. + await saveDetailsNow(); + const text = editor?.getText(); const json = editor?.getJSON() ?? { type: 'doc', content: [] }; const html = editor?.getHTML(); @@ -565,14 +668,8 @@ export function PublishingForm({ fullSrc: html || '', assignDOI: !formData.workId, topics: formData.topics.map((topic) => topic.value), - authors: formData.authors - .map((author) => author.value) - .map(Number) - .filter((id) => !Number.isNaN(id)), - contacts: formData.contacts - .map((contact) => contact.value) - .map(Number) - .filter((id) => !Number.isNaN(id)), + authors: mapOptionsToIds(formData.authors), + contacts: mapOptionsToIds(formData.contacts), articleType: ARTICLE_TYPE_API_MAP[formData.articleType] ?? 'DISCUSSION', image: imagePath, previewImg: diff --git a/components/modals/ApplyToGrantModal.tsx b/components/modals/ApplyToGrantModal.tsx index 886e58ef9..808a29868 100644 --- a/components/modals/ApplyToGrantModal.tsx +++ b/components/modals/ApplyToGrantModal.tsx @@ -10,7 +10,6 @@ import { Badge } from '@/components/ui/Badge'; import { Tooltip } from '@/components/ui/Tooltip'; import AnimatedProposal from '@/components/Proposal/AnimatedProposal'; import { NoteService } from '@/services/note.service'; -import { setPendingGrant } from '@/components/Editor/lib/utils/publishingFormStorage'; import { useUser } from '@/contexts/UserContext'; import { useOrganizationContext } from '@/contexts/OrganizationContext'; import { useRouter } from 'next/navigation'; @@ -41,10 +40,6 @@ interface ApplyToGrantModalProps { onUseSelected: (proposal: ProposalForModal) => void; grantId: string; grantTitle?: string; - grantAmountUsd?: number; - grantShortTitle?: string; - grantImageUrl?: string; - grantOrganization?: string; grantApplicationVisibility?: GrantApplicationVisibility; } @@ -53,10 +48,6 @@ export const ApplyToGrantModal: React.FC = ({ onClose, grantId, grantTitle, - grantAmountUsd, - grantShortTitle, - grantImageUrl, - grantOrganization, grantApplicationVisibility, }) => { const [draftNotes, setDraftNotes] = useState([]); @@ -70,17 +61,6 @@ export const ApplyToGrantModal: React.FC = ({ const selectedDraftNote = draftNotes.find((n) => n.id.toString() === selectedDraftNoteId); - const setPendingGrantForGrant = () => { - setPendingGrant({ - id: grantId, - shortTitle: grantShortTitle || grantTitle || '', - imageUrl: grantImageUrl || '', - fundingAmount: grantAmountUsd || 0, - organization: grantOrganization || '', - applicationVisibility: grantApplicationVisibility, - }); - }; - const handleSelectDraftNew = () => { setDraftNewSelected(true); setSelectedDraftNoteId(null); @@ -91,10 +71,10 @@ export const ApplyToGrantModal: React.FC = ({ setDraftNewSelected(false); }; + // The new note is created against this RFP, so its Details read back from the note. const handleDraftNew = () => { - setPendingGrantForGrant(); onClose(); - router.push('/notebook?newFunding=true'); + router.push(`/notebook?newFunding=true&selectedGrantId=${encodeURIComponent(grantId)}`); }; const handleContinueWithDraft = async () => { @@ -106,7 +86,6 @@ export const ApplyToGrantModal: React.FC = ({ noteId: selectedDraftNote.id, selectedGrantId: grantId, }); - setPendingGrantForGrant(); onClose(); router.push( `/notebook/${selectedDraftNote.organization.slug}/${selectedDraftNote.id}?tab=details` diff --git a/components/modals/SelectFundingOpportunityModal.tsx b/components/modals/SelectFundingOpportunityModal.tsx index a4647efbe..efdb4eb43 100644 --- a/components/modals/SelectFundingOpportunityModal.tsx +++ b/components/modals/SelectFundingOpportunityModal.tsx @@ -8,8 +8,7 @@ import { GrantService } from '@/services/grant.service'; import { FeedEntry, FeedGrantContent } from '@/types/feed'; import { Loader2 } from 'lucide-react'; import { formatCompactAmount } from '@/utils/currency'; -import { SelectedGrantData } from '@/components/Editor/lib/utils/publishingFormStorage'; -import { GRANT_IMAGE_FALLBACK_GRADIENT } from '@/types/grant'; +import { GRANT_IMAGE_FALLBACK_GRADIENT, type SelectedGrantData } from '@/types/grant'; interface SelectFundingOpportunityModalProps { isOpen: boolean; diff --git a/components/work/WorkHeader/WorkHeader.tsx b/components/work/WorkHeader/WorkHeader.tsx index 3837a7946..f7c73ec14 100644 --- a/components/work/WorkHeader/WorkHeader.tsx +++ b/components/work/WorkHeader/WorkHeader.tsx @@ -55,8 +55,6 @@ interface WorkHeaderProps { isApplyToGrantModalOpen: boolean; onCloseApplyToGrantModal: () => void; grantId: string; - grantAmountUsd?: number; - grantOrganization?: string; grantApplicationVisibility?: GrantApplicationVisibility; }; } @@ -283,8 +281,6 @@ export function WorkHeader({ isApplyToGrantModalOpen={grantModalProps?.isApplyToGrantModalOpen} onCloseApplyToGrantModal={grantModalProps?.onCloseApplyToGrantModal} grantId={grantModalProps?.grantId} - grantAmountUsd={grantModalProps?.grantAmountUsd} - grantOrganization={grantModalProps?.grantOrganization} grantApplicationVisibility={grantModalProps?.grantApplicationVisibility} showReopenModal={showReopenModal} onCloseReopenModal={closeReopenModal} diff --git a/components/work/WorkHeader/WorkHeaderGrant.tsx b/components/work/WorkHeader/WorkHeaderGrant.tsx index ae09bafb9..cfab1539a 100644 --- a/components/work/WorkHeader/WorkHeaderGrant.tsx +++ b/components/work/WorkHeader/WorkHeaderGrant.tsx @@ -164,8 +164,6 @@ export function WorkHeaderGrant({ isApplyToGrantModalOpen: isApplyModalOpen, onCloseApplyToGrantModal: () => setIsApplyModalOpen(false), grantId, - grantAmountUsd: amountUsd, - grantOrganization: organization, grantApplicationVisibility: applicationVisibility, } : undefined diff --git a/components/work/WorkHeader/WorkHeaderModals.tsx b/components/work/WorkHeader/WorkHeaderModals.tsx index a1e97e082..a43c1130d 100644 --- a/components/work/WorkHeader/WorkHeaderModals.tsx +++ b/components/work/WorkHeader/WorkHeaderModals.tsx @@ -35,8 +35,6 @@ export interface WorkHeaderModalsProps { isApplyToGrantModalOpen?: boolean; onCloseApplyToGrantModal?: () => void; grantId?: string; - grantAmountUsd?: number; - grantOrganization?: string; grantApplicationVisibility?: GrantApplicationVisibility; showReopenModal?: boolean; onCloseReopenModal?: () => void; @@ -67,8 +65,6 @@ export function WorkHeaderModals({ isApplyToGrantModalOpen = false, onCloseApplyToGrantModal, grantId, - grantAmountUsd, - grantOrganization, grantApplicationVisibility, showReopenModal = false, onCloseReopenModal, @@ -150,8 +146,6 @@ export function WorkHeaderModals({ onUseSelected={onCloseApplyToGrantModal} grantId={grantId} grantTitle={work.title} - grantAmountUsd={grantAmountUsd} - grantOrganization={grantOrganization} grantApplicationVisibility={grantApplicationVisibility} /> )} diff --git a/hooks/useNoteDetailsSaver.ts b/hooks/useNoteDetailsSaver.ts new file mode 100644 index 000000000..3a6b76b64 --- /dev/null +++ b/hooks/useNoteDetailsSaver.ts @@ -0,0 +1,84 @@ +'use client'; + +import { useCallback, useEffect, useRef } from 'react'; +import { debounce } from 'lodash-es'; +import { NoteService } from '@/services/note.service'; +import { mergeNoteDetailsUpdates, type NoteDetailsUpdate } from '@/types/note'; + +const DEBOUNCE_MS = 2000; + +export interface NoteDetailsSaver { + /** Queues an edit, combining it with the edits made alongside it. */ + saveDetailsSoon: (details: NoteDetailsUpdate) => void; + /** Sends whatever is still queued without waiting for the debounce. */ + saveDetailsNow: () => Promise; +} + +/** A queued edit carries its note, so it follows the note it was made on. */ +interface QueuedNoteDetails { + noteId: number; + details: NoteDetailsUpdate; +} + +/** + * The single writer for a notebook draft's Details. A burst of edits becomes + * one request, and requests run in the order they were made so a slow save + * cannot land on top of the edit that followed it. + */ +export const useNoteDetailsSaver = (noteId?: number): NoteDetailsSaver => { + const queuedDetailsRef = useRef(null); + const lastSaveRef = useRef>(Promise.resolve()); + + const sendQueuedDetails = useCallback((): Promise => { + const queued = queuedDetailsRef.current; + queuedDetailsRef.current = null; + if (!queued) return lastSaveRef.current; + + const save = lastSaveRef.current.then(async () => { + try { + await NoteService.updateNote({ noteId: queued.noteId, details: queued.details }); + } catch (error) { + // The form still holds the value, so the next edit to it saves again. + console.error('Error saving note details:', error); + } + }); + lastSaveRef.current = save; + return save; + }, []); + + const sendQueuedDetailsSoon = useRef( + debounce(() => void sendQueuedDetails(), DEBOUNCE_MS) + ).current; + + const saveDetailsSoon = useCallback( + (details: NoteDetailsUpdate) => { + if (noteId == null) return; + + // An edit belonging to another note goes out first, under its own id. + if (queuedDetailsRef.current && queuedDetailsRef.current.noteId !== noteId) { + void sendQueuedDetails(); + } + + queuedDetailsRef.current = { + noteId, + details: mergeNoteDetailsUpdates(queuedDetailsRef.current?.details ?? {}, details), + }; + sendQueuedDetailsSoon(); + }, + [noteId, sendQueuedDetails, sendQueuedDetailsSoon] + ); + + const saveDetailsNow = useCallback((): Promise => { + sendQueuedDetailsSoon.cancel(); + return sendQueuedDetails(); + }, [sendQueuedDetails, sendQueuedDetailsSoon]); + + // Leaving the notebook must not cost the user the edits still inside the debounce. + useEffect(() => { + return () => { + void saveDetailsNow(); + }; + }, [saveDetailsNow]); + + return { saveDetailsSoon, saveDetailsNow }; +}; diff --git a/services/note.service.ts b/services/note.service.ts index 36b9dc9c7..0fff7d9fc 100644 --- a/services/note.service.ts +++ b/services/note.service.ts @@ -1,6 +1,17 @@ import { ApiClient } from './client'; -import { transformNote, transformNoteContent, transformNoteWithContent } from '@/types/note'; -import type { Note, NoteAccess, NoteContent, NoteWithContent } from '@/types/note'; +import { + buildNoteDetailsPayload, + transformNote, + transformNoteContent, + transformNoteWithContent, +} from '@/types/note'; +import type { + Note, + NoteAccess, + NoteContent, + NoteDetailsUpdate, + NoteWithContent, +} from '@/types/note'; import { ID } from '@/types/root'; import { ApiError } from './types'; import { extractApiErrorMessage } from './lib/serviceUtils'; @@ -41,8 +52,8 @@ export interface UpdateNoteContentParams { export interface UpdateNoteParams { noteId: ID; title?: string; - document_type?: string; - selectedGrantId: ID; + selectedGrantId?: ID; + details?: NoteDetailsUpdate; } export interface UpdateNoteTitleParams { @@ -285,10 +296,11 @@ export class NoteService { throw new NoteError('Missing note ID', 'INVALID_PARAMS'); } - const { noteId, selectedGrantId, ...fields } = params; + const { noteId, selectedGrantId, details, ...fields } = params; const payload = { ...fields, ...(selectedGrantId === undefined ? {} : { selected_grant: selectedGrantId }), + ...(details && buildNoteDetailsPayload(details)), }; try { diff --git a/types/grant.ts b/types/grant.ts index 8840d1ad0..4328cd87c 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -40,6 +40,25 @@ export interface GrantAmount { formatted: string; } +/** The Request for Proposal a notebook draft is answering, as its card draws it. */ +export interface SelectedGrantData { + id: string; + shortTitle: string; + imageUrl: string; + fundingAmount: number; + organization: string; + applicationVisibility?: GrantApplicationVisibility; +} + +export const transformSelectedGrant = createTransformer((raw) => ({ + id: raw.id.toString(), + shortTitle: raw.short_title || '', + imageUrl: raw.image_url || '', + fundingAmount: raw.amount?.usd ?? 0, + organization: raw.organization || '', + applicationVisibility: raw.application_visibility, +})); + export interface Grant { id: ID; createdBy: { diff --git a/types/note.ts b/types/note.ts index 0e930ce6e..830f6b51e 100644 --- a/types/note.ts +++ b/types/note.ts @@ -2,11 +2,18 @@ import { CHANGELOG_NOTEBOOK_ROLLOUT_AT, CHANGELOG_POST_IDS } from '@/constants/c import type { Organization } from './organization'; import { createTransformer, BaseTransformed } from './transformer'; import { transformOrganization } from './organization'; -import { ID } from './root'; +import { Currency, ID } from './root'; import { ContentType, ModerationStatus } from './work'; import { Fundraise, transformFundraise } from './funding'; import { Topic, transformTopic } from './topic'; -import { Grant, transformGrant } from './grant'; +import { + Grant, + GrantApplicationVisibility, + SelectedGrantData, + transformGrant, + transformSelectedGrant, +} from './grant'; +import { NonprofitOrg, transformNonprofitDetailsToOrg } from './nonprofit'; import { AuthorProfile, transformAuthorProfile } from './authorProfile'; export type NoteAccess = 'WORKSPACE' | 'PRIVATE' | 'SHARED'; @@ -43,6 +50,25 @@ export type Post = { image?: string; }; +/** What a draft Request for Proposal has filled in so far. */ +export interface NoteGrantSettings { + /** A decimal string, so cents cannot be rounded away in JavaScript. */ + amount: string | null; + organization: string | null; + description: string | null; + applicationVisibility: GrantApplicationVisibility | null; + contacts: Contact[]; +} + +/** What a draft proposal has filled in so far. */ +export interface NotePreregistrationSettings { + goalAmount: string | null; + /** How long the fundraise runs; publishing turns it into a deadline. */ + durationDays: number | null; + isPublic: boolean | null; + nonprofit: NonprofitOrg | null; +} + export interface Note { id: number; access: NoteAccess; @@ -58,9 +84,104 @@ export interface Note { previewImage?: string | null; topics?: Topic[]; authors?: Author[]; + grantSettings?: NoteGrantSettings | null; + preregistrationSettings?: NotePreregistrationSettings | null; + selectedGrant?: SelectedGrantData | null; registeredReportPrefill?: RegisteredReportPrefill | null; } +/** Grant fields the Note API accepts; an omitted key keeps its saved value. */ +export interface NoteGrantSettingsUpdate { + amount?: string; + currency?: Currency; + organization?: string; + description?: string; + applicationVisibility?: GrantApplicationVisibility; + /** User ids, not author profile ids. */ + contactIds?: number[]; +} + +/** Proposal funding fields the Note API accepts. */ +export interface NotePreregistrationSettingsUpdate { + goalAmount?: string; + goalCurrency?: Currency; + durationDays?: number; + isPublic?: boolean; + nonprofitId?: string | null; +} + +/** A partial update to the Details a notebook draft saves before it is published. */ +export interface NoteDetailsUpdate { + documentType?: string; + authorIds?: number[]; + hubIds?: number[]; + grantSettings?: NoteGrantSettingsUpdate; + preregistrationSettings?: NotePreregistrationSettingsUpdate; +} + +type NoteDetailsFields = Omit; + +const NOTE_FIELD_KEYS: Record = { + documentType: 'document_type', + authorIds: 'author_ids', + hubIds: 'hub_ids', +}; + +const GRANT_SETTINGS_KEYS: Record = { + amount: 'amount', + currency: 'currency', + organization: 'organization', + description: 'description', + applicationVisibility: 'application_visibility', + contactIds: 'contact_ids', +}; + +const PREREGISTRATION_SETTINGS_KEYS: Record = { + goalAmount: 'goal_amount', + goalCurrency: 'goal_currency', + durationDays: 'duration_days', + isPublic: 'is_public', + nonprofitId: 'nonprofit_id', +}; + +/** Renames the fields the update actually set, so an untouched key is never sent. */ +const toApiPayload = (apiKeys: Record, update: T) => + Object.fromEntries( + Object.entries(update).map(([field, value]) => [apiKeys[field as keyof T], value]) + ); + +export const buildNoteDetailsPayload = ({ + grantSettings, + preregistrationSettings, + ...noteFields +}: NoteDetailsUpdate): Record => ({ + ...toApiPayload(NOTE_FIELD_KEYS, noteFields), + ...(grantSettings && { grant_settings: toApiPayload(GRANT_SETTINGS_KEYS, grantSettings) }), + ...(preregistrationSettings && { + preregistration_settings: toApiPayload(PREREGISTRATION_SETTINGS_KEYS, preregistrationSettings), + }), +}); + +/** Combines queued edits so a later settings change cannot drop an earlier one. */ +export const mergeNoteDetailsUpdates = ( + earlier: NoteDetailsUpdate, + later: NoteDetailsUpdate +): NoteDetailsUpdate => ({ + ...earlier, + ...later, + ...(earlier.grantSettings && + later.grantSettings && { + grantSettings: { ...earlier.grantSettings, ...later.grantSettings }, + }), + ...(earlier.preregistrationSettings && + later.preregistrationSettings && { + preregistrationSettings: { + ...earlier.preregistrationSettings, + ...later.preregistrationSettings, + }, + }), +}); + /** * Who committed a note version: the editor autosave endpoint, the notebook AI * tools, or a programmatic writer (publish snapshots, imports). The backend @@ -113,10 +234,13 @@ export interface NoteApiItem { export type TransformedNote = Note & BaseTransformed; +const buildFullName = (raw: any): string => + `${raw.first_name || ''} ${raw.last_name || ''}`.trim() || 'Unknown'; + export const transformAuthor = createTransformer((raw: any) => ({ authorId: raw.id, userId: raw.user, - name: `${raw.first_name || ''} ${raw.last_name || ''}`.trim() || 'Unknown', + name: buildFullName(raw), })); export const transformContact = createTransformer((raw) => ({ @@ -125,6 +249,27 @@ export const transformContact = createTransformer((raw) => ({ authorProfile: raw.author_profile ? transformAuthorProfile(raw.author_profile) : undefined, })); +const transformNoteGrantSettings = createTransformer((raw) => ({ + amount: raw.amount ?? null, + organization: raw.organization ?? null, + description: raw.description ?? null, + applicationVisibility: raw.application_visibility ?? null, + // Saved grant contacts are users, so they arrive as names rather than a label. + contacts: (raw.contacts ?? []).map((contact: any) => ({ + id: contact.id, + name: buildFullName(contact), + })), +})); + +const transformNotePreregistrationSettings = createTransformer( + (raw) => ({ + goalAmount: raw.goal_amount ?? null, + durationDays: raw.duration_days ?? null, + isPublic: raw.is_public ?? null, + nonprofit: raw.nonprofit_details ? transformNonprofitDetailsToOrg(raw.nonprofit_details) : null, + }) +); + const getDocumentType = (raw: any): string | null => [raw.document_type, raw.unified_document?.document_type, raw.type] .find((value): value is string => typeof value === 'string' && value.trim().length > 0) @@ -217,18 +362,26 @@ export const transformNote = createTransformer((raw) => { raw.registered_report_prefill?.preview_img || raw.registered_report_prefill?.image_url || null, + // Saved values first, so a Registered Report prefill only fills the gaps. topics: transformTopicsFromSources( - raw.registered_report_prefill?.topics, - raw.registered_report_prefill?.hubs, raw.hubs, raw.topics, - raw.unified_document?.hubs + raw.unified_document?.hubs, + raw.registered_report_prefill?.topics, + raw.registered_report_prefill?.hubs ), authors: transformAuthorsFromSources( - raw.registered_report_prefill?.authors, raw.authors, - raw.author_profiles + raw.author_profiles, + raw.registered_report_prefill?.authors ), + grantSettings: raw.grant_settings ? transformNoteGrantSettings(raw.grant_settings) : null, + preregistrationSettings: raw.preregistration_settings + ? transformNotePreregistrationSettings(raw.preregistration_settings) + : null, + selectedGrant: raw.selected_grant_details + ? transformSelectedGrant(raw.selected_grant_details) + : null, }; }); From 887c09fbf1e86e17e77219e1d5efa0510c8b562e Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Tue, 1 Sep 2026 18:01:04 -0400 Subject: [PATCH 2/9] [Notebook] Small fixes for image/amounts --- .../components/WorkImageSection.tsx | 24 ++++++++++++++-- components/Notebook/PublishingForm/index.tsx | 28 +++++++++++-------- components/Notebook/PublishingForm/schema.ts | 1 + types/note.ts | 12 +++++--- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/components/Notebook/PublishingForm/components/WorkImageSection.tsx b/components/Notebook/PublishingForm/components/WorkImageSection.tsx index bf2fa6f3a..879e701d0 100644 --- a/components/Notebook/PublishingForm/components/WorkImageSection.tsx +++ b/components/Notebook/PublishingForm/components/WorkImageSection.tsx @@ -3,6 +3,7 @@ import { useFormContext, Controller } from 'react-hook-form'; import { Image as ImageIcon, Plus, X } from 'lucide-react'; import { SectionHeader } from './SectionHeader'; import { PublishingFormData } from '../schema'; +import { useAssetUpload } from '@/hooks/useAssetUpload'; const ACCEPT = ['image/jpeg', 'image/png']; const MAX_SIZE_MB = 10; @@ -13,10 +14,12 @@ const isValidFile = (file: unknown): file is File => export function WorkImageSection() { const { control, + getValues, formState: { errors }, } = useFormContext(); const [error, setError] = useState(null); + const [, uploadAsset] = useAssetUpload(); return (
@@ -33,9 +36,26 @@ export function WorkImageSection() { file={file} existingUrl={existingUrl} error={error || (errors.coverImage?.message as string) || null} - onSelect={(selected) => { + onSelect={async (selected) => { setError(null); - field.onChange({ file: selected, url: null }); + const previousCover = field.value ?? null; + // Show the pick right away, then swap in what the server stored. + field.onChange({ file: selected, key: null, url: null }); + + const uploaded = await uploadAsset(selected, 'post').catch((uploadError) => { + console.error('Error uploading cover image:', uploadError); + return null; + }); + + // A newer pick may have replaced this one while it uploaded. + if (getValues('coverImage')?.file !== selected) return; + + if (!uploaded) { + field.onChange(previousCover); + setError('Failed to upload image. Please try again.'); + return; + } + field.onChange({ file: null, key: uploaded.objectKey, url: uploaded.absoluteUrl }); }} onRemove={() => { setError(null); diff --git a/components/Notebook/PublishingForm/index.tsx b/components/Notebook/PublishingForm/index.tsx index 64fa1948f..e68e48341 100644 --- a/components/Notebook/PublishingForm/index.tsx +++ b/components/Notebook/PublishingForm/index.tsx @@ -193,6 +193,9 @@ const dropZeroCents = (amount: string): string => amount.replace(/\.0+$/, ''); /** Loads the Details this draft has already saved on the server. */ const populateNoteDetails = (note: NoteWithContent, setValue: (name: any, value: any) => void) => { + if (note.image || note.previewImage) { + setValue('coverImage', { file: null, key: note.image, url: note.previewImage }); + } if (note.topics?.length) { setValue( 'topics', @@ -244,10 +247,6 @@ const populateRegisteredReportPrefill = ( ) => { const { topicIds = [], authorIds = [] } = note.registeredReportPrefill ?? {}; - if (note.previewImage && !getValues('coverImage')) { - setValue('coverImage', { file: null, url: note.previewImage }); - } - if (topicIds.length > 0 && getValues('topics').length === 0) { setValue( 'topics', @@ -274,6 +273,11 @@ const buildDetailsUpdate = ( switch (field) { case 'articleType': return { documentType: ARTICLE_TYPE_API_MAP[values.articleType] }; + case 'coverImage': + // A file is still uploading, and the key it produces triggers this again. + if (values.coverImage?.file) return null; + // The API clears an image with a blank string; null is rejected. + return { image: values.coverImage?.key ?? '', previewImage: values.coverImage?.url ?? '' }; case 'authors': return { authorIds: mapOptionsToIds(values.authors) }; case 'topics': @@ -294,13 +298,13 @@ const buildDetailsUpdate = ( : null; case 'isPublic': return isProposal ? { preregistrationSettings: { isPublic: values.isPublic } } : null; - case 'budget': - // An empty box is a half-typed amount, not a decision to clear the saved one. - if (!values.budget) return null; - if (isGrant) return { grantSettings: { amount: values.budget, currency: 'USD' } }; + case 'budget': { + const amount = values.budget || null; + if (isGrant) return { grantSettings: { amount, currency: 'USD' } }; return isProposal - ? { preregistrationSettings: { goalAmount: values.budget, goalCurrency: 'USD' } } + ? { preregistrationSettings: { goalAmount: amount, goalCurrency: 'USD' } } : null; + } default: return null; } @@ -549,8 +553,10 @@ export function PublishingForm({ formData.articleType === 'preregistration' || formData.articleType === 'grant' || formData.articleType === 'registered_report'; - const file = needsImage ? formData.coverImage?.file : null; - if (!file) return null; + if (!needsImage) return null; + + const file = formData.coverImage?.file; + if (!file) return formData.coverImage?.key ?? null; try { const result = await uploadAsset(file, 'post'); diff --git a/components/Notebook/PublishingForm/schema.ts b/components/Notebook/PublishingForm/schema.ts index 3bacdc33f..dd3259836 100644 --- a/components/Notebook/PublishingForm/schema.ts +++ b/components/Notebook/PublishingForm/schema.ts @@ -54,6 +54,7 @@ export const publishingFormSchema = z coverImage: z .object({ file: z.instanceof(File).nullable().optional(), + key: z.string().nullable().optional(), url: z.string().nullable().optional(), }) .nullable() diff --git a/types/note.ts b/types/note.ts index 830f6b51e..ebd71e10d 100644 --- a/types/note.ts +++ b/types/note.ts @@ -92,7 +92,7 @@ export interface Note { /** Grant fields the Note API accepts; an omitted key keeps its saved value. */ export interface NoteGrantSettingsUpdate { - amount?: string; + amount?: string | null; currency?: Currency; organization?: string; description?: string; @@ -103,7 +103,7 @@ export interface NoteGrantSettingsUpdate { /** Proposal funding fields the Note API accepts. */ export interface NotePreregistrationSettingsUpdate { - goalAmount?: string; + goalAmount?: string | null; goalCurrency?: Currency; durationDays?: number; isPublic?: boolean; @@ -115,6 +115,8 @@ export interface NoteDetailsUpdate { documentType?: string; authorIds?: number[]; hubIds?: number[]; + image?: string; + previewImage?: string; grantSettings?: NoteGrantSettingsUpdate; preregistrationSettings?: NotePreregistrationSettingsUpdate; } @@ -125,6 +127,8 @@ const NOTE_FIELD_KEYS: Record = { documentType: 'document_type', authorIds: 'author_ids', hubIds: 'hub_ids', + image: 'image', + previewImage: 'preview_img', }; const GRANT_SETTINGS_KEYS: Record = { @@ -357,12 +361,12 @@ export const transformNote = createTransformer((raw) => { documentType, proposalId, registeredReportPrefill: transformRegisteredReportPrefill(raw.registered_report_prefill), - image: raw.registered_report_prefill?.image || raw.registered_report_prefill?.image_url || null, + image: raw.image || null, previewImage: + raw.preview_img || raw.registered_report_prefill?.preview_img || raw.registered_report_prefill?.image_url || null, - // Saved values first, so a Registered Report prefill only fills the gaps. topics: transformTopicsFromSources( raw.hubs, raw.topics, From 419dc69728953e4e821a094bf3c60d4808bf6ac2 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Tue, 1 Sep 2026 18:18:28 -0400 Subject: [PATCH 3/9] [Notebook] Checkbox unset fix --- components/modals/ConfirmPublishModal.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/components/modals/ConfirmPublishModal.tsx b/components/modals/ConfirmPublishModal.tsx index 172948808..1d0bfeeb6 100644 --- a/components/modals/ConfirmPublishModal.tsx +++ b/components/modals/ConfirmPublishModal.tsx @@ -1,5 +1,5 @@ import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; -import { Fragment, useEffect, useState } from 'react'; +import { Fragment, useState } from 'react'; import { Button } from '@/components/ui/Button'; import { Checkbox } from '@/components/ui/form/Checkbox'; import { GraduationCap, Scale, Users, FileText, type LucideIcon } from 'lucide-react'; @@ -76,11 +76,6 @@ export function ConfirmPublishModal({ const resolvedDocumentLabel = documentLabel ?? (variant === 'rfp' ? 'request for proposal' : 'research proposal'); - useEffect(() => { - setTitle(initialTitle); - setHasAgreed(false); - }, [initialTitle]); - const handleTitleChange = (e: React.ChangeEvent) => { const newTitle = e.target.value; setTitle(newTitle); From bf73413fbe520f5f0d5f55959ad9d3ab71010707 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Tue, 1 Sep 2026 18:26:34 -0400 Subject: [PATCH 4/9] [Notebook] Small fix for update to selected RFP from RFP page to notebook --- types/grant.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/grant.ts b/types/grant.ts index 4328cd87c..5b00765ca 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -52,7 +52,7 @@ export interface SelectedGrantData { export const transformSelectedGrant = createTransformer((raw) => ({ id: raw.id.toString(), - shortTitle: raw.short_title || '', + shortTitle: raw.short_title || raw.title || '', imageUrl: raw.image_url || '', fundingAmount: raw.amount?.usd ?? 0, organization: raw.organization || '', From 57d3bb78c4a54dda79582dfc80ba3d0fa3ff3686 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Tue, 1 Sep 2026 18:53:29 -0400 Subject: [PATCH 5/9] [Notebook] Small fix for update to selected RFP from RFP page to notebook 2 --- app/notebook/[orgSlug]/page.tsx | 10 ++++++++-- components/Notebook/PublishingForm/index.tsx | 18 +++++++++++++++--- components/modals/ApplyToGrantModal.tsx | 14 +++++++++++--- types/grant.ts | 2 +- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/notebook/[orgSlug]/page.tsx b/app/notebook/[orgSlug]/page.tsx index fb1bc6b0a..eab20e4fc 100644 --- a/app/notebook/[orgSlug]/page.tsx +++ b/app/notebook/[orgSlug]/page.tsx @@ -42,6 +42,7 @@ export default function OrganizationPage() { const grantSource = searchParams.get('grantSource'); const proposalSource = searchParams.get('proposalSource'); const selectedGrantId = searchParams.get('selectedGrantId') ?? undefined; + const selectedGrantTitle = searchParams.get('selectedGrantTitle'); const createNoteWithContent = async ( orgSlug: string, @@ -76,9 +77,14 @@ export default function OrganizationPage() { plainText: getTemplatePlainText(template), }); - const queryString = queryParam && queryValue ? `?${queryParam}=${queryValue}` : ''; + // The RFP title is not stored on the note, so it follows it to the editor. + const params = new URLSearchParams( + queryParam && queryValue ? { [queryParam]: queryValue } : {} + ); + if (selectedGrantTitle) params.set('selectedGrantTitle', selectedGrantTitle); + refreshNotes(); - router.replace(`/notebook/${orgSlug}/${newNote.id}${queryString}`); + router.replace(`/notebook/${orgSlug}/${newNote.id}${params.size ? `?${params}` : ''}`); } } catch (err) { console.error('Failed to create note:', err); diff --git a/components/Notebook/PublishingForm/index.tsx b/components/Notebook/PublishingForm/index.tsx index e68e48341..ddef22bff 100644 --- a/components/Notebook/PublishingForm/index.tsx +++ b/components/Notebook/PublishingForm/index.tsx @@ -192,7 +192,12 @@ const mapOptionsToIds = (options: SelectOption[]): number[] => const dropZeroCents = (amount: string): string => amount.replace(/\.0+$/, ''); /** Loads the Details this draft has already saved on the server. */ -const populateNoteDetails = (note: NoteWithContent, setValue: (name: any, value: any) => void) => { +const populateNoteDetails = ( + note: NoteWithContent, + setValue: (name: any, value: any) => void, + /** The RFP's title, which the note records by id alone. */ + selectedGrantTitle?: string +) => { if (note.image || note.previewImage) { setValue('coverImage', { file: null, key: note.image, url: note.previewImage }); } @@ -209,7 +214,10 @@ const populateNoteDetails = (note: NoteWithContent, setValue: (name: any, value: ); } if (note.selectedGrant) { - setValue('selectedGrant', note.selectedGrant); + setValue('selectedGrant', { + ...note.selectedGrant, + shortTitle: note.selectedGrant.shortTitle || selectedGrantTitle || '', + }); } const { grantSettings, preregistrationSettings } = note; @@ -416,7 +424,11 @@ export function PublishingForm({ if (note.post) { populateFromPost(note.post, methods.setValue); } else { - populateNoteDetails(note, methods.setValue); + populateNoteDetails( + note, + methods.setValue, + searchParams?.get('selectedGrantTitle') ?? undefined + ); if (isRegisteredReport) { populateRegisteredReportPrefill(note, methods.getValues, methods.setValue); diff --git a/components/modals/ApplyToGrantModal.tsx b/components/modals/ApplyToGrantModal.tsx index 808a29868..b1d46e559 100644 --- a/components/modals/ApplyToGrantModal.tsx +++ b/components/modals/ApplyToGrantModal.tsx @@ -71,10 +71,16 @@ export const ApplyToGrantModal: React.FC = ({ setDraftNewSelected(false); }; - // The new note is created against this RFP, so its Details read back from the note. + // The note stores the RFP by id alone, so its title rides along for the card. + const grantQuery = () => + new URLSearchParams(grantTitle ? { selectedGrantTitle: grantTitle } : {}); + const handleDraftNew = () => { onClose(); - router.push(`/notebook?newFunding=true&selectedGrantId=${encodeURIComponent(grantId)}`); + const params = grantQuery(); + params.set('newFunding', 'true'); + params.set('selectedGrantId', grantId); + router.push(`/notebook?${params}`); }; const handleContinueWithDraft = async () => { @@ -87,8 +93,10 @@ export const ApplyToGrantModal: React.FC = ({ selectedGrantId: grantId, }); onClose(); + const params = grantQuery(); + params.set('tab', 'details'); router.push( - `/notebook/${selectedDraftNote.organization.slug}/${selectedDraftNote.id}?tab=details` + `/notebook/${selectedDraftNote.organization.slug}/${selectedDraftNote.id}?${params}` ); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to select RFP'); diff --git a/types/grant.ts b/types/grant.ts index 5b00765ca..4328cd87c 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -52,7 +52,7 @@ export interface SelectedGrantData { export const transformSelectedGrant = createTransformer((raw) => ({ id: raw.id.toString(), - shortTitle: raw.short_title || raw.title || '', + shortTitle: raw.short_title || '', imageUrl: raw.image_url || '', fundingAmount: raw.amount?.usd ?? 0, organization: raw.organization || '', From dee74e03b40762558e1e0c20738251280dcf372b Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Tue, 1 Sep 2026 19:22:01 -0400 Subject: [PATCH 6/9] Linter Fixes + consolidating PATCH API calls --- components/Notebook/NoteEditorLayout.tsx | 2 + components/Notebook/PublishingForm/index.tsx | 48 ++++++++++++-------- contexts/NotebookContext.tsx | 12 +++++ hooks/useNote.ts | 37 ++++++--------- hooks/useNoteDetailsSaver.ts | 36 ++++++++++++--- services/note.service.ts | 17 +------ types/note.ts | 11 ++++- 7 files changed, 97 insertions(+), 66 deletions(-) diff --git a/components/Notebook/NoteEditorLayout.tsx b/components/Notebook/NoteEditorLayout.tsx index fad9b7625..d0a93558a 100644 --- a/components/Notebook/NoteEditorLayout.tsx +++ b/components/Notebook/NoteEditorLayout.tsx @@ -86,6 +86,7 @@ export function NoteEditorLayout({ onAgentChatDockedChange }: NoteEditorLayoutPr noteError, setEditor, updateNoteTitle, + saveDetailsSoon, activeNoteId, editor, } = useNotebookContext(); @@ -212,6 +213,7 @@ export function NoteEditorLayout({ onAgentChatDockedChange }: NoteEditorLayoutPr }, [note, noteError, isLoadingNote]); const [, updateNote, saveNoteNow] = useUpdateNote(note?.id, { + saveTitle: (title) => saveDetailsSoon({ title }), onTitleUpdate: updateNoteTitle, registeredReportProposalId: note?.proposalId, // While an assistant review is open the editor holds a merged document; diff --git a/components/Notebook/PublishingForm/index.tsx b/components/Notebook/PublishingForm/index.tsx index ddef22bff..b85a83326 100644 --- a/components/Notebook/PublishingForm/index.tsx +++ b/components/Notebook/PublishingForm/index.tsx @@ -51,7 +51,7 @@ import { } from '@/types/note'; import type { NonprofitOrg } from '@/types/nonprofit'; import { NonprofitService } from '@/services/nonprofit.service'; -import { useNoteDetailsSaver, type NoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; +import type { NoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; import { getAvailableNotebookWorkTypes } from '@/components/Notebook/NotebookPrimaryNavigation'; const FEATURE_FLAG_RESEARCH_COIN = false; @@ -321,7 +321,8 @@ const buildDetailsUpdate = ( /** Saves the nonprofit under the id the Note API stores, not its Endaoment one. */ const saveSelectedNonprofit = async ( nonprofit: NonprofitOrg | null, - saveDetailsSoon: NoteDetailsSaver['saveDetailsSoon'] + saveDetailsSoon: NoteDetailsSaver['saveDetailsSoon'], + getValues: (name: any) => any ) => { if (!nonprofit) { saveDetailsSoon({ preregistrationSettings: { nonprofitId: null } }); @@ -335,6 +336,8 @@ const saveSelectedNonprofit = async ( ein: nonprofit.ein, baseWalletAddress: nonprofit.baseWalletAddress, }); + // A newer choice may have replaced this one while it was being created. + if (getValues('selectedNonprofit') !== nonprofit) return; saveDetailsSoon({ preregistrationSettings: { nonprofitId: saved.id } }); } catch (error) { console.error('Error saving selected nonprofit:', error); @@ -347,28 +350,29 @@ const applyGrantDefaults = (getValues: any, setValue: (name: any, value: any) => } }; +/** Names the field it defaulted, which the caller has to save like any edit. */ const autoAddCurrentUser = ( getValues: any, setValue: (name: any, value: any) => void, currentUser: any -) => { +): 'authors' | 'contacts' | null => { const articleType = getValues('articleType'); - if (!currentUser || articleType === 'registered_report') return; + if (!currentUser || articleType === 'registered_report') return null; const isGrant = articleType === 'grant'; const field = isGrant ? 'contacts' : 'authors'; - - if (getValues(field).length === 0) { - const profile = currentUser.authorProfile; - setValue(field, [ - { - value: isGrant - ? currentUser.id.toString() - : profile?.id?.toString() || currentUser.id.toString(), - label: currentUser.fullName || currentUser.email || 'Unknown User', - }, - ]); - } + if (getValues(field).length > 0) return null; + + const profile = currentUser.authorProfile; + setValue(field, [ + { + value: isGrant + ? currentUser.id.toString() + : profile?.id?.toString() || currentUser.id.toString(), + label: currentUser.fullName || currentUser.email || 'Unknown User', + }, + ]); + return field; }; const resolveArticleType = ( @@ -397,7 +401,7 @@ export function PublishingForm({ onBountyClick, readOnly = false, }: Readonly) { - const { currentNote: note, editor } = useNotebookContext(); + const { currentNote: note, editor, saveDetailsSoon, saveDetailsNow } = useNotebookContext(); const { user: currentUser } = useUser(); const searchParams = useSearchParams(); const [isRedirecting, setIsRedirecting] = useState(false); @@ -413,7 +417,6 @@ export function PublishingForm({ const noteId = note?.id; const isPublished = Boolean(note?.post); - const { saveDetailsSoon, saveDetailsNow } = useNoteDetailsSaver(noteId); useEffect(() => { if (!note) return; @@ -448,7 +451,12 @@ export function PublishingForm({ } applyGrantDefaults(methods.getValues, methods.setValue); - autoAddCurrentUser(methods.getValues, methods.setValue, currentUser); + + // Hydration runs with the watcher detached, so a default the form generates + // has to be saved here or the draft would never record who is on it. + const defaultedField = autoAddCurrentUser(methods.getValues, methods.setValue, currentUser); + const defaults = defaultedField && buildDetailsUpdate(defaultedField, methods.getValues()); + if (defaults && !isPublished) saveDetailsSoon(defaults); // A proposal answering a private Request for Proposal cannot be public. if (methods.getValues('selectedGrant')?.applicationVisibility === 'PRIVATE') { @@ -467,7 +475,7 @@ export function PublishingForm({ const values = methods.getValues(); if (name === 'selectedNonprofit') { - void saveSelectedNonprofit(values.selectedNonprofit, saveDetailsSoon); + void saveSelectedNonprofit(values.selectedNonprofit, saveDetailsSoon, methods.getValues); return; } diff --git a/contexts/NotebookContext.tsx b/contexts/NotebookContext.tsx index e259b2228..d72746ee3 100644 --- a/contexts/NotebookContext.tsx +++ b/contexts/NotebookContext.tsx @@ -15,6 +15,7 @@ import type { Note, NoteWithContent } from '@/types/note'; import type { ID } from '@/types/root'; import type { OrganizationUsers } from '@/types/organization'; import { useOrganizationContext } from './OrganizationContext'; +import { useNoteDetailsSaver, type NoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; import { Editor } from '@tiptap/core'; import { useParams } from 'next/navigation'; @@ -43,6 +44,13 @@ interface NotebookContextType { loadNote: (noteId: string) => Promise; updateNoteTitle: (newTitle: string, noteId: ID) => void; + /** + * The one writer for the current note's own fields. Shared so the editor's + * title and the publishing form's Details cannot patch the note at once. + */ + saveDetailsSoon: NoteDetailsSaver['saveDetailsSoon']; + saveDetailsNow: NoteDetailsSaver['saveDetailsNow']; + // Editor state editor: Editor | null; setEditor: (editor: Editor | null) => void; @@ -94,6 +102,8 @@ export function NotebookProvider({ children, noteId: explicitNoteId }: NotebookP // Editor state const [editor, setEditor] = useState(null); + const { saveDetailsSoon, saveDetailsNow } = useNoteDetailsSaver(currentNote?.id); + const fetchNotes = useCallback(async (slug?: string) => { if (!slug) { setNotesError(new Error('No organization slug provided')); @@ -316,6 +326,8 @@ export function NotebookProvider({ children, noteId: explicitNoteId }: NotebookP noteError, loadNote, updateNoteTitle, + saveDetailsSoon, + saveDetailsNow, editor, setEditor, isLoading, diff --git a/hooks/useNote.ts b/hooks/useNote.ts index 03f223956..cbc65669a 100644 --- a/hooks/useNote.ts +++ b/hooks/useNote.ts @@ -285,6 +285,11 @@ interface UseUpdateNoteState { } interface UpdateNoteOptions { + /** + * Persists the document's title, which is a note field rather than note + * content and so belongs to whichever writer owns the note record. + */ + saveTitle?: (newTitle: string) => void; /** * Reports the note the title belongs to: a save can complete (or a pending * autosave flush) after the user moved to another note, so consumers must @@ -347,32 +352,20 @@ export const useUpdateNote = (noteId: ID, options: UpdateNoteOptions = {}): UseU setError(null); try { - const promises: Promise[] = []; - - // Only update title if it changed + // The title goes to the note record's own writer, which queues it and + // retries it on its own; only a change to it is worth sending. if (payload.title !== titleRef.current) { titleRef.current = payload.title; - promises.push( - NoteService.updateNoteTitle({ - noteId, - title: payload.title, - }).then(() => { - options.onTitleUpdate?.(payload.title, noteId); - }) - ); + options.saveTitle?.(payload.title); + options.onTitleUpdate?.(payload.title, noteId); } - // Always update content - promises.push( - NoteService.updateNoteContent({ - note: noteId, - full_src: payload.html || '', - plain_text: payload.plainText || '', - full_json: JSON.stringify(payload.json), - }) - ); - - await Promise.all(promises); + await NoteService.updateNoteContent({ + note: noteId, + full_src: payload.html || '', + plain_text: payload.plainText || '', + full_json: JSON.stringify(payload.json), + }); return true; } catch (err) { const errorMsg = err instanceof NoteError ? err.message : 'Failed to update note'; diff --git a/hooks/useNoteDetailsSaver.ts b/hooks/useNoteDetailsSaver.ts index 3a6b76b64..328d4ac85 100644 --- a/hooks/useNoteDetailsSaver.ts +++ b/hooks/useNoteDetailsSaver.ts @@ -21,14 +21,30 @@ interface QueuedNoteDetails { } /** - * The single writer for a notebook draft's Details. A burst of edits becomes - * one request, and requests run in the order they were made so a slow save - * cannot land on top of the edit that followed it. + * The single writer for a note's own fields — the editor's title as much as the + * publishing form's Details, which is why one instance is shared through + * NotebookContext. A burst of edits becomes one request, and requests run in the + * order they were made so a slow save cannot land on top of the edit after it. */ export const useNoteDetailsSaver = (noteId?: number): NoteDetailsSaver => { const queuedDetailsRef = useRef(null); const lastSaveRef = useRef>(Promise.resolve()); + /** + * Puts a failed edit back under anything queued since, so the next flush + * retries it. Only an edit to the same field supersedes it, and an edit that + * moved to another note has to be dropped: the two cannot share a request. + */ + const requeueFailedDetails = useCallback((failed: QueuedNoteDetails) => { + const queued = queuedDetailsRef.current; + if (queued && queued.noteId !== failed.noteId) return; + + queuedDetailsRef.current = { + noteId: failed.noteId, + details: mergeNoteDetailsUpdates(failed.details, queued?.details ?? {}), + }; + }, []); + const sendQueuedDetails = useCallback((): Promise => { const queued = queuedDetailsRef.current; queuedDetailsRef.current = null; @@ -38,13 +54,13 @@ export const useNoteDetailsSaver = (noteId?: number): NoteDetailsSaver => { try { await NoteService.updateNote({ noteId: queued.noteId, details: queued.details }); } catch (error) { - // The form still holds the value, so the next edit to it saves again. console.error('Error saving note details:', error); + requeueFailedDetails(queued); } }); lastSaveRef.current = save; return save; - }, []); + }, [requeueFailedDetails]); const sendQueuedDetailsSoon = useRef( debounce(() => void sendQueuedDetails(), DEBOUNCE_MS) @@ -73,9 +89,17 @@ export const useNoteDetailsSaver = (noteId?: number): NoteDetailsSaver => { return sendQueuedDetails(); }, [sendQueuedDetails, sendQueuedDetailsSoon]); - // Leaving the notebook must not cost the user the edits still inside the debounce. + // Leaving the notebook must not cost the user the edits still inside the + // debounce. A closing or backgrounded tab never runs the cleanup below, so it + // is flushed while the page is still alive enough to send the request. useEffect(() => { + const flushOnHide = () => { + if (document.visibilityState === 'hidden') void saveDetailsNow(); + }; + + document.addEventListener('visibilitychange', flushOnHide); return () => { + document.removeEventListener('visibilitychange', flushOnHide); void saveDetailsNow(); }; }, [saveDetailsNow]); diff --git a/services/note.service.ts b/services/note.service.ts index 0fff7d9fc..6efa7d7f9 100644 --- a/services/note.service.ts +++ b/services/note.service.ts @@ -51,16 +51,10 @@ export interface UpdateNoteContentParams { export interface UpdateNoteParams { noteId: ID; - title?: string; selectedGrantId?: ID; details?: NoteDetailsUpdate; } -export interface UpdateNoteTitleParams { - noteId: ID; - title: string; -} - export interface GetOrganizationNotesParams { status?: 'DRAFT' | 'PUBLISHED'; documentType?: 'PREREGISTRATION' | 'GRANT' | 'DISCUSSION' | 'REGISTERED_REPORT'; @@ -296,9 +290,8 @@ export class NoteService { throw new NoteError('Missing note ID', 'INVALID_PARAMS'); } - const { noteId, selectedGrantId, details, ...fields } = params; + const { noteId, selectedGrantId, details } = params; const payload = { - ...fields, ...(selectedGrantId === undefined ? {} : { selected_grant: selectedGrantId }), ...(details && buildNoteDetailsPayload(details)), }; @@ -315,14 +308,6 @@ export class NoteService { } } - static async updateNoteTitle(params: UpdateNoteTitleParams): Promise { - return this.updateNote({ - noteId: params.noteId, - title: params.title, - selectedGrantId: undefined, - }); - } - /** * Makes a note private * @param noteId - The ID of the note to make private diff --git a/types/note.ts b/types/note.ts index ebd71e10d..4ccc9255a 100644 --- a/types/note.ts +++ b/types/note.ts @@ -110,8 +110,12 @@ export interface NotePreregistrationSettingsUpdate { nonprofitId?: string | null; } -/** A partial update to the Details a notebook draft saves before it is published. */ +/** + * A partial update to a note's own fields: the title the editor derives from + * the document, plus the Details a draft fills in before it is published. + */ export interface NoteDetailsUpdate { + title?: string; documentType?: string; authorIds?: number[]; hubIds?: number[]; @@ -124,6 +128,7 @@ export interface NoteDetailsUpdate { type NoteDetailsFields = Omit; const NOTE_FIELD_KEYS: Record = { + title: 'title', documentType: 'document_type', authorIds: 'author_ids', hubIds: 'hub_ids', @@ -361,7 +366,9 @@ export const transformNote = createTransformer((raw) => { documentType, proposalId, registeredReportPrefill: transformRegisteredReportPrefill(raw.registered_report_prefill), - image: raw.image || null, + // Saved values first, so a Registered Report prefill only fills the gaps. + // `image` holds a storage key, which the prefill's `image_url` is not. + image: raw.image || raw.registered_report_prefill?.image || null, previewImage: raw.preview_img || raw.registered_report_prefill?.preview_img || From 7138ec6400864a4453b0920727492fd86fea963e Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Wed, 2 Sep 2026 11:59:56 -0400 Subject: [PATCH 7/9] [Notebook] Removed sessionStorage setup for RFP link to proposal from work page --- app/notebook/[orgSlug]/page.tsx | 16 +++++++--------- components/Funding/DocumentUploadStep.tsx | 1 - components/Notebook/PublishingForm/index.tsx | 18 +++--------------- components/modals/ApplyToGrantModal.tsx | 13 ++----------- hooks/useNote.ts | 3 --- services/note.service.ts | 9 +-------- 6 files changed, 13 insertions(+), 47 deletions(-) diff --git a/app/notebook/[orgSlug]/page.tsx b/app/notebook/[orgSlug]/page.tsx index eab20e4fc..046727ae6 100644 --- a/app/notebook/[orgSlug]/page.tsx +++ b/app/notebook/[orgSlug]/page.tsx @@ -12,6 +12,7 @@ import { getTemplatePlainText, } from '@/components/Editor/lib/utils/documentTitle'; import { useCreateNote, useNoteContent } from '@/hooks/useNote'; +import { NoteService } from '@/services/note.service'; import { NoteCreationPopover } from '@/components/Notebook/NoteCreationPopover'; import { useUser } from '@/contexts/UserContext'; import type { ID } from '@/types/root'; @@ -42,7 +43,6 @@ export default function OrganizationPage() { const grantSource = searchParams.get('grantSource'); const proposalSource = searchParams.get('proposalSource'); const selectedGrantId = searchParams.get('selectedGrantId') ?? undefined; - const selectedGrantTitle = searchParams.get('selectedGrantTitle'); const createNoteWithContent = async ( orgSlug: string, @@ -67,24 +67,22 @@ export default function OrganizationPage() { title, grouping: 'WORKSPACE', documentType, - selectedGrantId, }); if (newNote) { + if (selectedGrantId) { + await NoteService.updateNote({ noteId: newNote.id, selectedGrantId }); + } + await updateNoteContent({ note: newNote.id, fullJson: JSON.stringify(template), plainText: getTemplatePlainText(template), }); - // The RFP title is not stored on the note, so it follows it to the editor. - const params = new URLSearchParams( - queryParam && queryValue ? { [queryParam]: queryValue } : {} - ); - if (selectedGrantTitle) params.set('selectedGrantTitle', selectedGrantTitle); - + const queryString = queryParam && queryValue ? `?${queryParam}=${queryValue}` : ''; refreshNotes(); - router.replace(`/notebook/${orgSlug}/${newNote.id}${params.size ? `?${params}` : ''}`); + router.replace(`/notebook/${orgSlug}/${newNote.id}${queryString}`); } } catch (err) { console.error('Failed to create note:', err); diff --git a/components/Funding/DocumentUploadStep.tsx b/components/Funding/DocumentUploadStep.tsx index 861785c55..46c2aed11 100644 --- a/components/Funding/DocumentUploadStep.tsx +++ b/components/Funding/DocumentUploadStep.tsx @@ -66,7 +66,6 @@ export const DocumentUploadStep = ({ title: result.title, grouping: 'WORKSPACE', documentType, - selectedGrantId: undefined, }); await updateNoteContent({ note: newNote.id, diff --git a/components/Notebook/PublishingForm/index.tsx b/components/Notebook/PublishingForm/index.tsx index b85a83326..0e628d817 100644 --- a/components/Notebook/PublishingForm/index.tsx +++ b/components/Notebook/PublishingForm/index.tsx @@ -192,12 +192,7 @@ const mapOptionsToIds = (options: SelectOption[]): number[] => const dropZeroCents = (amount: string): string => amount.replace(/\.0+$/, ''); /** Loads the Details this draft has already saved on the server. */ -const populateNoteDetails = ( - note: NoteWithContent, - setValue: (name: any, value: any) => void, - /** The RFP's title, which the note records by id alone. */ - selectedGrantTitle?: string -) => { +const populateNoteDetails = (note: NoteWithContent, setValue: (name: any, value: any) => void) => { if (note.image || note.previewImage) { setValue('coverImage', { file: null, key: note.image, url: note.previewImage }); } @@ -214,10 +209,7 @@ const populateNoteDetails = ( ); } if (note.selectedGrant) { - setValue('selectedGrant', { - ...note.selectedGrant, - shortTitle: note.selectedGrant.shortTitle || selectedGrantTitle || '', - }); + setValue('selectedGrant', note.selectedGrant); } const { grantSettings, preregistrationSettings } = note; @@ -427,11 +419,7 @@ export function PublishingForm({ if (note.post) { populateFromPost(note.post, methods.setValue); } else { - populateNoteDetails( - note, - methods.setValue, - searchParams?.get('selectedGrantTitle') ?? undefined - ); + populateNoteDetails(note, methods.setValue); if (isRegisteredReport) { populateRegisteredReportPrefill(note, methods.getValues, methods.setValue); diff --git a/components/modals/ApplyToGrantModal.tsx b/components/modals/ApplyToGrantModal.tsx index b1d46e559..ea0eedb6c 100644 --- a/components/modals/ApplyToGrantModal.tsx +++ b/components/modals/ApplyToGrantModal.tsx @@ -71,16 +71,9 @@ export const ApplyToGrantModal: React.FC = ({ setDraftNewSelected(false); }; - // The note stores the RFP by id alone, so its title rides along for the card. - const grantQuery = () => - new URLSearchParams(grantTitle ? { selectedGrantTitle: grantTitle } : {}); - const handleDraftNew = () => { onClose(); - const params = grantQuery(); - params.set('newFunding', 'true'); - params.set('selectedGrantId', grantId); - router.push(`/notebook?${params}`); + router.push(`/notebook?newFunding=true&selectedGrantId=${encodeURIComponent(grantId)}`); }; const handleContinueWithDraft = async () => { @@ -93,10 +86,8 @@ export const ApplyToGrantModal: React.FC = ({ selectedGrantId: grantId, }); onClose(); - const params = grantQuery(); - params.set('tab', 'details'); router.push( - `/notebook/${selectedDraftNote.organization.slug}/${selectedDraftNote.id}?${params}` + `/notebook/${selectedDraftNote.organization.slug}/${selectedDraftNote.id}?tab=details` ); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to select RFP'); diff --git a/hooks/useNote.ts b/hooks/useNote.ts index cbc65669a..9da2a5b0d 100644 --- a/hooks/useNote.ts +++ b/hooks/useNote.ts @@ -156,7 +156,6 @@ interface CreateNoteInput { grouping: NoteAccess; organizationSlug: string; documentType?: string; - selectedGrantId: ID; } interface UseCreateNoteState { @@ -183,7 +182,6 @@ export const useCreateNote = (): UseCreateNoteReturn => { grouping: params.grouping, organization_slug: params.organizationSlug, document_type: params.documentType, - selectedGrantId: params.selectedGrantId, }); setNote(response); return response; @@ -576,7 +574,6 @@ export const useDuplicateNote = (): UseDuplicateNoteReturn => { grouping: originalNote.access, organization_slug: organizationSlug, document_type: isChangelogNote(originalNote) ? 'DISCUSSION' : undefined, - selectedGrantId: undefined, }); // 3. Copy the content to the new note diff --git a/services/note.service.ts b/services/note.service.ts index 6efa7d7f9..9a6a77982 100644 --- a/services/note.service.ts +++ b/services/note.service.ts @@ -39,7 +39,6 @@ export interface CreateNoteParams { grouping: NoteAccess; organization_slug: string; document_type?: string; - selectedGrantId: ID; } export interface UpdateNoteContentParams { @@ -202,14 +201,8 @@ export class NoteService { throw new NoteError('Missing organization slug', 'INVALID_PARAMS'); } - const { selectedGrantId, ...fields } = params; - const payload = { - ...fields, - ...(selectedGrantId === undefined ? {} : { selected_grant: selectedGrantId }), - }; - try { - const response = await ApiClient.post(`${this.BASE_PATH}/note/`, payload); + const response = await ApiClient.post(`${this.BASE_PATH}/note/`, params); return transformNote(response); } catch (error) { throw new NoteError( From e4052d58579a81643f6c912e1c3773f2a8a6224c Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Wed, 2 Sep 2026 14:17:06 -0400 Subject: [PATCH 8/9] [Notebook] Small selected grant title update --- types/grant.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/grant.ts b/types/grant.ts index 4328cd87c..5b00765ca 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -52,7 +52,7 @@ export interface SelectedGrantData { export const transformSelectedGrant = createTransformer((raw) => ({ id: raw.id.toString(), - shortTitle: raw.short_title || '', + shortTitle: raw.short_title || raw.title || '', imageUrl: raw.image_url || '', fundingAmount: raw.amount?.usd ?? 0, organization: raw.organization || '', From 4373a7dca0760bc1e8ab5d6c8edfb6c053779b39 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Wed, 2 Sep 2026 15:27:19 -0400 Subject: [PATCH 9/9] Feedback Updates --- .../components/FundingSection.tsx | 6 +++--- .../components/WorkImageSection.tsx | 5 +++-- components/Notebook/PublishingForm/index.tsx | 17 ++++++++++------- .../modals/SelectFundingOpportunityModal.tsx | 4 ++-- types/grant.ts | 4 ++-- types/note.ts | 4 ++-- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/components/Notebook/PublishingForm/components/FundingSection.tsx b/components/Notebook/PublishingForm/components/FundingSection.tsx index 44aeecfb4..9c6b5d171 100644 --- a/components/Notebook/PublishingForm/components/FundingSection.tsx +++ b/components/Notebook/PublishingForm/components/FundingSection.tsx @@ -13,7 +13,7 @@ import { useNonprofitByFundraiseId } from '@/hooks/useNonprofitByFundraiseId'; import { useNonprofitSearch } from '@/hooks/useNonprofitSearch'; import { SelectFundingOpportunityModal } from '@/components/modals/SelectFundingOpportunityModal'; import { formatCompactAmount } from '@/utils/currency'; -import { GRANT_IMAGE_FALLBACK_GRADIENT, type SelectedGrantData } from '@/types/grant'; +import { GRANT_IMAGE_FALLBACK_GRADIENT, type SelectedGrantDetails } from '@/types/grant'; import { NoteService } from '@/services/note.service'; interface FundingSectionProps { @@ -24,12 +24,12 @@ const FEATURE_FLAG_NFT_REWARDS = false; function FundingOpportunitySection({ note }: Readonly) { const { watch, setValue } = useFormContext(); - const selectedGrant: SelectedGrantData | null = watch('selectedGrant'); + const selectedGrant: SelectedGrantDetails | null = watch('selectedGrant'); const workId = watch('workId'); const [isModalOpen, setIsModalOpen] = useState(false); const [isSavingGrant, setIsSavingGrant] = useState(false); - const saveSelectedGrant = async (grant: SelectedGrantData | null) => { + const saveSelectedGrant = async (grant: SelectedGrantDetails | null) => { setIsSavingGrant(true); try { await NoteService.updateNote({ diff --git a/components/Notebook/PublishingForm/components/WorkImageSection.tsx b/components/Notebook/PublishingForm/components/WorkImageSection.tsx index 879e701d0..399049494 100644 --- a/components/Notebook/PublishingForm/components/WorkImageSection.tsx +++ b/components/Notebook/PublishingForm/components/WorkImageSection.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useFormContext, Controller } from 'react-hook-form'; +import { toast } from 'react-hot-toast'; import { Image as ImageIcon, Plus, X } from 'lucide-react'; import { SectionHeader } from './SectionHeader'; import { PublishingFormData } from '../schema'; @@ -47,12 +48,12 @@ export function WorkImageSection() { return null; }); - // A newer pick may have replaced this one while it uploaded. + // Ignore this upload if its selection was removed or replaced while it ran. if (getValues('coverImage')?.file !== selected) return; if (!uploaded) { field.onChange(previousCover); - setError('Failed to upload image. Please try again.'); + toast.error('Failed to upload image. Please try again.'); return; } field.onChange({ file: null, key: uploaded.objectKey, url: uploaded.absoluteUrl }); diff --git a/components/Notebook/PublishingForm/index.tsx b/components/Notebook/PublishingForm/index.tsx index 0e628d817..ccd241ca2 100644 --- a/components/Notebook/PublishingForm/index.tsx +++ b/components/Notebook/PublishingForm/index.tsx @@ -191,8 +191,11 @@ const mapOptionsToIds = (options: SelectOption[]): number[] => /** Both amount inputs accept digits only, so a saved `5000.00` reads back as `5000`. */ const dropZeroCents = (amount: string): string => amount.replace(/\.0+$/, ''); -/** Loads the Details this draft has already saved on the server. */ -const populateNoteDetails = (note: NoteWithContent, setValue: (name: any, value: any) => void) => { +/** Populates the form from this draft's saved Details. */ +const populateFormFromNoteDetails = ( + note: NoteWithContent, + setValue: (name: any, value: any) => void +) => { if (note.image || note.previewImage) { setValue('coverImage', { file: null, key: note.image, url: note.previewImage }); } @@ -262,8 +265,8 @@ const populateRegisteredReportPrefill = ( } }; -/** Maps one changed Details field to the update that saves it on the note. */ -const buildDetailsUpdate = ( +/** Builds a Note Details update for one changed form field. */ +const buildNoteDetailsUpdate = ( field: string, values: PublishingFormData ): NoteDetailsUpdate | null => { @@ -419,7 +422,7 @@ export function PublishingForm({ if (note.post) { populateFromPost(note.post, methods.setValue); } else { - populateNoteDetails(note, methods.setValue); + populateFormFromNoteDetails(note, methods.setValue); if (isRegisteredReport) { populateRegisteredReportPrefill(note, methods.getValues, methods.setValue); @@ -443,7 +446,7 @@ export function PublishingForm({ // Hydration runs with the watcher detached, so a default the form generates // has to be saved here or the draft would never record who is on it. const defaultedField = autoAddCurrentUser(methods.getValues, methods.setValue, currentUser); - const defaults = defaultedField && buildDetailsUpdate(defaultedField, methods.getValues()); + const defaults = defaultedField && buildNoteDetailsUpdate(defaultedField, methods.getValues()); if (defaults && !isPublished) saveDetailsSoon(defaults); // A proposal answering a private Request for Proposal cannot be public. @@ -467,7 +470,7 @@ export function PublishingForm({ return; } - const update = buildDetailsUpdate(name, values); + const update = buildNoteDetailsUpdate(name, values); if (update) saveDetailsSoon(update); }); diff --git a/components/modals/SelectFundingOpportunityModal.tsx b/components/modals/SelectFundingOpportunityModal.tsx index efdb4eb43..f12fba0c0 100644 --- a/components/modals/SelectFundingOpportunityModal.tsx +++ b/components/modals/SelectFundingOpportunityModal.tsx @@ -8,12 +8,12 @@ import { GrantService } from '@/services/grant.service'; import { FeedEntry, FeedGrantContent } from '@/types/feed'; import { Loader2 } from 'lucide-react'; import { formatCompactAmount } from '@/utils/currency'; -import { GRANT_IMAGE_FALLBACK_GRADIENT, type SelectedGrantData } from '@/types/grant'; +import { GRANT_IMAGE_FALLBACK_GRADIENT, type SelectedGrantDetails } from '@/types/grant'; interface SelectFundingOpportunityModalProps { isOpen: boolean; onClose: () => void; - onSelect: (grant: SelectedGrantData) => void; + onSelect: (grant: SelectedGrantDetails) => void; } const GrantCardSkeleton = () => ( diff --git a/types/grant.ts b/types/grant.ts index 5b00765ca..1dcfadbb4 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -41,7 +41,7 @@ export interface GrantAmount { } /** The Request for Proposal a notebook draft is answering, as its card draws it. */ -export interface SelectedGrantData { +export interface SelectedGrantDetails { id: string; shortTitle: string; imageUrl: string; @@ -50,7 +50,7 @@ export interface SelectedGrantData { applicationVisibility?: GrantApplicationVisibility; } -export const transformSelectedGrant = createTransformer((raw) => ({ +export const transformSelectedGrant = createTransformer((raw) => ({ id: raw.id.toString(), shortTitle: raw.short_title || raw.title || '', imageUrl: raw.image_url || '', diff --git a/types/note.ts b/types/note.ts index 4ccc9255a..4f512d6f9 100644 --- a/types/note.ts +++ b/types/note.ts @@ -9,7 +9,7 @@ import { Topic, transformTopic } from './topic'; import { Grant, GrantApplicationVisibility, - SelectedGrantData, + SelectedGrantDetails, transformGrant, transformSelectedGrant, } from './grant'; @@ -86,7 +86,7 @@ export interface Note { authors?: Author[]; grantSettings?: NoteGrantSettings | null; preregistrationSettings?: NotePreregistrationSettings | null; - selectedGrant?: SelectedGrantData | null; + selectedGrant?: SelectedGrantDetails | null; registeredReportPrefill?: RegisteredReportPrefill | null; }