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..046727ae6 100644 --- a/app/notebook/[orgSlug]/page.tsx +++ b/app/notebook/[orgSlug]/page.tsx @@ -12,9 +12,9 @@ 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 { 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 +42,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, @@ -66,10 +67,13 @@ 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), @@ -103,7 +107,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 +135,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/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/NoteEditorLayout.tsx b/components/Notebook/NoteEditorLayout.tsx index 2e407969b..660334a2c 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/components/FundingSection.tsx b/components/Notebook/PublishingForm/components/FundingSection.tsx index 2b3f16f98..0177d43f2 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 SelectedGrantDetails } from '@/types/grant'; import { NoteService } from '@/services/note.service'; interface FundingSectionProps { @@ -25,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 34db6d9e1..a6115afa9 100644 --- a/components/Notebook/PublishingForm/components/WorkImageSection.tsx +++ b/components/Notebook/PublishingForm/components/WorkImageSection.tsx @@ -1,8 +1,10 @@ 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'; +import { useAssetUpload } from '@/hooks/useAssetUpload'; const ACCEPT = ['image/jpeg', 'image/png']; const MAX_SIZE_MB = 10; @@ -13,10 +15,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 +37,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; + }); + + // Ignore this upload if its selection was removed or replaced while it ran. + if (getValues('coverImage')?.file !== selected) return; + + if (!uploaded) { + field.onChange(previousCover); + toast.error('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 3cea3c4f1..c068d6449 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 type { NoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; import { getAvailableNotebookWorkTypes } from '@/components/Notebook/NotebookPrimaryNavigation'; const FEATURE_FLAG_RESEARCH_COIN = false; @@ -183,50 +185,157 @@ 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+$/, ''); + +/** Populates the form from this draft's saved Details. */ +const populateFormFromNoteDetails = ( 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, + if (note.image || note.previewImage) { + setValue('coverImage', { file: null, key: note.image, url: note.previewImage }); + } + 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, })) - : (note.registeredReportPrefill?.authorIds ?? []).map((id) => ({ - value: id.toString(), - label: `Author ${id}`, - })); + ); + } + } + 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 { 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', + topicIds.map((id) => ({ value: id.toString(), label: `Topic ${id}` })) + ); } - if (topicOptions.length > 0 && getValues('topics').length === 0) { - setValue('topics', topicOptions); + 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); +/** Builds a Note Details update for one changed form field. */ +const buildNoteDetailsUpdate = ( + 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 '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': + 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': { + const amount = values.budget || null; + if (isGrant) return { grantSettings: { amount, currency: 'USD' } }; + return isProposal + ? { preregistrationSettings: { goalAmount: amount, 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'], + getValues: (name: any) => any ) => { - 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, + }); + // 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); } }; @@ -236,28 +345,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 = ( @@ -286,7 +396,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); @@ -301,6 +411,7 @@ export function PublishingForm({ }); const noteId = note?.id; + const isPublished = Boolean(note?.post); useEffect(() => { if (!note) return; @@ -311,17 +422,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); - } + populateFormFromNoteDetails(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); @@ -335,33 +442,40 @@ 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(); - } + // 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 && buildNoteDetailsUpdate(defaultedField, methods.getValues()); + if (defaults && !isPublished) saveDetailsSoon(defaults); - savePublishingFormToStorage( - note.id.toString(), - methods.getValues() as Partial - ); + // A proposal answering a private Request for Proposal cannot be public. + if (methods.getValues('selectedGrant')?.applicationVisibility === 'PRIVATE') { + methods.setValue('isPublic', false); + } // 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, methods.getValues); + return; + } + + const update = buildNoteDetailsUpdate(name, values); + if (update) saveDetailsSoon(update); }); return () => subscription.unsubscribe(); - }, [noteId, methods]); + }, [noteId, isPublished, methods, saveDetailsSoon]); const { watch, clearErrors } = methods; const articleType = watch('articleType'); @@ -450,8 +564,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'); @@ -511,6 +627,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 +685,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/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/components/modals/ApplyToGrantModal.tsx b/components/modals/ApplyToGrantModal.tsx index 2e7a14355..dd7710749 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); @@ -92,9 +72,8 @@ export const ApplyToGrantModal: React.FC = ({ }; const handleDraftNew = () => { - setPendingGrantForGrant(); onClose(); - router.push('/notebook?newFunding=true'); + router.push(`/notebook?newFunding=true&selectedGrantId=${encodeURIComponent(grantId)}`); }; const handleContinueWithDraft = async () => { @@ -106,7 +85,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/ConfirmPublishModal.tsx b/components/modals/ConfirmPublishModal.tsx index 7fb40fdce..e7db2f5a4 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); diff --git a/components/modals/SelectFundingOpportunityModal.tsx b/components/modals/SelectFundingOpportunityModal.tsx index a4647efbe..f12fba0c0 100644 --- a/components/modals/SelectFundingOpportunityModal.tsx +++ b/components/modals/SelectFundingOpportunityModal.tsx @@ -8,13 +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 { SelectedGrantData } from '@/components/Editor/lib/utils/publishingFormStorage'; -import { GRANT_IMAGE_FALLBACK_GRADIENT } 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/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 382b013b8..e89d410ec 100644 --- a/components/work/WorkHeader/WorkHeaderGrant.tsx +++ b/components/work/WorkHeader/WorkHeaderGrant.tsx @@ -165,8 +165,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/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..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; @@ -285,6 +283,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 +350,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'; @@ -583,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/hooks/useNoteDetailsSaver.ts b/hooks/useNoteDetailsSaver.ts new file mode 100644 index 000000000..328d4ac85 --- /dev/null +++ b/hooks/useNoteDetailsSaver.ts @@ -0,0 +1,108 @@ +'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 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; + if (!queued) return lastSaveRef.current; + + const save = lastSaveRef.current.then(async () => { + try { + await NoteService.updateNote({ noteId: queued.noteId, details: queued.details }); + } catch (error) { + console.error('Error saving note details:', error); + requeueFailedDetails(queued); + } + }); + lastSaveRef.current = save; + return save; + }, [requeueFailedDetails]); + + 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. 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]); + + return { saveDetailsSoon, saveDetailsNow }; +}; diff --git a/services/note.service.ts b/services/note.service.ts index 36b9dc9c7..9a6a77982 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'; @@ -28,7 +39,6 @@ export interface CreateNoteParams { grouping: NoteAccess; organization_slug: string; document_type?: string; - selectedGrantId: ID; } export interface UpdateNoteContentParams { @@ -40,14 +50,8 @@ export interface UpdateNoteContentParams { export interface UpdateNoteParams { noteId: ID; - title?: string; - document_type?: string; - selectedGrantId: ID; -} - -export interface UpdateNoteTitleParams { - noteId: ID; - title: string; + selectedGrantId?: ID; + details?: NoteDetailsUpdate; } export interface GetOrganizationNotesParams { @@ -197,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( @@ -285,10 +283,10 @@ export class NoteService { throw new NoteError('Missing note ID', 'INVALID_PARAMS'); } - const { noteId, selectedGrantId, ...fields } = params; + const { noteId, selectedGrantId, details } = params; const payload = { - ...fields, ...(selectedGrantId === undefined ? {} : { selected_grant: selectedGrantId }), + ...(details && buildNoteDetailsPayload(details)), }; try { @@ -303,14 +301,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/grant.ts b/types/grant.ts index 8840d1ad0..1dcfadbb4 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 SelectedGrantDetails { + 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 || raw.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..4f512d6f9 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, + SelectedGrantDetails, + 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,113 @@ export interface Note { previewImage?: string | null; topics?: Topic[]; authors?: Author[]; + grantSettings?: NoteGrantSettings | null; + preregistrationSettings?: NotePreregistrationSettings | null; + selectedGrant?: SelectedGrantDetails | null; registeredReportPrefill?: RegisteredReportPrefill | null; } +/** Grant fields the Note API accepts; an omitted key keeps its saved value. */ +export interface NoteGrantSettingsUpdate { + amount?: string | null; + 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 | null; + goalCurrency?: Currency; + durationDays?: number; + isPublic?: boolean; + nonprofitId?: string | null; +} + +/** + * 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[]; + image?: string; + previewImage?: string; + grantSettings?: NoteGrantSettingsUpdate; + preregistrationSettings?: NotePreregistrationSettingsUpdate; +} + +type NoteDetailsFields = Omit; + +const NOTE_FIELD_KEYS: Record = { + title: 'title', + documentType: 'document_type', + authorIds: 'author_ids', + hubIds: 'hub_ids', + image: 'image', + previewImage: 'preview_img', +}; + +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 +243,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 +258,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) @@ -212,23 +366,33 @@ 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, + // 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 || raw.registered_report_prefill?.image_url || null, 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, }; });