From 95e18fab8414847f28f213622095e190865a2e85 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Mon, 21 Sep 2026 20:59:35 -0400 Subject: [PATCH] Split the publishing form into a provider, the publish action and the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The form's state and its publish flow now live in PublishingFormProvider and usePublishAction, exposed through usePublishingController(), so a publish button or a single section can render anywhere inside the provider — the AI Mode workspace header needs both. PublishingForm keeps only the sections and the footer. The pure note<->form mappings move to formMapping.ts and are exported. Both hosts (the notebook page and the AI Mode document pane) wrap the form explicitly; readOnly moves onto the provider. One behaviour change: publishing is refused while there is no editor, since it would otherwise publish an empty work. Verified with type-check, eslint and prettier only. Co-Authored-By: Claude Fable 5.1 --- components/AIMode/DocumentPane.tsx | 6 +- components/Notebook/NoteEditorLayout.tsx | 6 +- .../Notebook/PublishingForm/PublishButton.tsx | 39 + .../PublishingForm/PublishingFormProvider.tsx | 258 +++++ .../Notebook/PublishingForm/formMapping.ts | 290 +++++ components/Notebook/PublishingForm/index.tsx | 1012 +++-------------- components/Notebook/PublishingForm/schema.ts | 1 + .../PublishingForm/usePublishAction.ts | 380 +++++++ 8 files changed, 1108 insertions(+), 884 deletions(-) create mode 100644 components/Notebook/PublishingForm/PublishButton.tsx create mode 100644 components/Notebook/PublishingForm/PublishingFormProvider.tsx create mode 100644 components/Notebook/PublishingForm/formMapping.ts create mode 100644 components/Notebook/PublishingForm/usePublishAction.ts diff --git a/components/AIMode/DocumentPane.tsx b/components/AIMode/DocumentPane.tsx index f99dd8a7b..91948771f 100644 --- a/components/AIMode/DocumentPane.tsx +++ b/components/AIMode/DocumentPane.tsx @@ -6,7 +6,7 @@ import { ExternalLink } from 'lucide-react'; import { BlockEditorClientWrapper } from '@/components/Editor/components/BlockEditor/components/BlockEditorClientWrapper'; import { NoteReviewBanner } from '@/components/Notebook/NoteReview/NoteReviewBanner'; import { NotebookTabs, type NotebookTab } from '@/components/Notebook/NotebookTabs'; -import { PublishingForm } from '@/components/Notebook/PublishingForm'; +import { PublishingForm, PublishingFormProvider } from '@/components/Notebook/PublishingForm'; import { PublishingHostProvider, type PublishingDefaultArticleType, @@ -231,7 +231,9 @@ export function DocumentPane({ {tab === 'details' && (
- + + +
)} diff --git a/components/Notebook/NoteEditorLayout.tsx b/components/Notebook/NoteEditorLayout.tsx index ec3a61f45..123d05a48 100644 --- a/components/Notebook/NoteEditorLayout.tsx +++ b/components/Notebook/NoteEditorLayout.tsx @@ -14,7 +14,7 @@ import { NotebookTour } from './NotebookTour'; import { NotebookTabs, type NotebookTab } from './NotebookTabs'; import { NotesMenu } from './NotesMenu'; import { PublishedStatusSection } from './PublishingForm/components/PublishedStatusSection'; -import { PublishingForm } from '@/components/Notebook/PublishingForm'; +import { PublishingForm, PublishingFormProvider } from '@/components/Notebook/PublishingForm'; import { ABOVE_MOBILE_NAV } from './mobileBarOffsets'; import { AgentChatPanel, type NoteReviewHandle } from '@/components/Notebook/AgentChatPanel'; @@ -353,7 +353,9 @@ export function NoteEditorLayout({ onAgentChatDockedChange }: NoteEditorLayoutPr
{renderEditor()}
{showTabs && (
- + + +
)} diff --git a/components/Notebook/PublishingForm/PublishButton.tsx b/components/Notebook/PublishingForm/PublishButton.tsx new file mode 100644 index 000000000..24cda156f --- /dev/null +++ b/components/Notebook/PublishingForm/PublishButton.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { cn } from '@/utils/styles'; +import { usePublishingController, type PublishingController } from './PublishingFormProvider'; + +const publishLabel = ({ + readOnly, + isUpserting, + isLinkingNonprofit, + isRedirecting, +}: PublishingController): string => { + if (readOnly) return 'Published'; + if (isUpserting) return 'Publishing...'; + if (isLinkingNonprofit) return 'Linking nonprofit...'; + if (isRedirecting) return 'Redirecting...'; + return 'Publish'; +}; + +interface PublishButtonProps { + readonly className?: string; +} + +/** Starts the publish flow for the note the surrounding provider holds. */ +export function PublishButton({ className }: PublishButtonProps) { + const controller = usePublishingController(); + + return ( + + ); +} diff --git a/components/Notebook/PublishingForm/PublishingFormProvider.tsx b/components/Notebook/PublishingForm/PublishingFormProvider.tsx new file mode 100644 index 000000000..91ebc892c --- /dev/null +++ b/components/Notebook/PublishingForm/PublishingFormProvider.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { createContext, useContext, useEffect, type ReactNode } from 'react'; +import { useSearchParams } from 'next/navigation'; +import type { Editor } from '@tiptap/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + getDocumentTitleFromEditor, + setDocumentTitle, +} from '@/components/Editor/lib/utils/documentTitle'; +import { ConfirmPublishModal } from '@/components/modals/ConfirmPublishModal'; +import { NonprofitConfirmModal } from '@/components/Nonprofit'; +import { usePublishingHost } from '@/contexts/PublishingHostContext'; +import { useUser } from '@/contexts/UserContext'; +import { isChangelogNote, isRegisteredReportNote, type NoteWithContent } from '@/types/note'; +import { + applyGrantDefaults, + autoAddCurrentUser, + buildNoteDetailsUpdate, + FORM_DEFAULTS, + mapDocumentTypeToArticleType, + populateFormFromNoteDetails, + populateFromPost, + populateRegisteredReportPrefill, + resolveArticleType, + saveSelectedNonprofit, +} from './formMapping'; +import { publishingFormSchema, type ArticleType, type PublishingFormData } from './schema'; +import { + CHANGELOG_PUBLISH_ERROR_MESSAGE, + usePublishAction, + type PublishAction, +} from './usePublishAction'; + +export interface PublishingController extends PublishAction { + readonly note: NoteWithContent | null; + readonly editor: Editor | null; + /** The form can be read but not changed or published. */ + readonly readOnly: boolean; + readonly articleType: ArticleType | undefined; + /** Set once the note has been published, and on the work it became. */ + readonly workId: string | undefined; + readonly isPublished: boolean; + readonly isChangelog: boolean; + /** A declined Request for Proposal: shown, but no longer editable. */ + readonly isDeclined: boolean; + /** Publishing is allowed right now; the footer button follows this. */ + readonly canPublish: boolean; + /** Why publishing is refused, when the reason is worth showing next to the button. */ + readonly blockedMessage: string | null; +} + +const PublishingControllerContext = createContext(null); + +interface PublishingFormProviderProps { + readonly readOnly?: boolean; + readonly children: ReactNode; +} + +/** + * Owns the publishing form for the note the host provides: its values, how + * they load from the note and save back to it, and the publish flow. Anything + * that reads or drives the form — the form body, a publish button placed + * elsewhere, a single section shown on its own — renders inside it and asks + * `usePublishingController()`. + */ +export function PublishingFormProvider({ + readOnly = false, + children, +}: PublishingFormProviderProps) { + const { note, editor, saveDetailsSoon, saveDetailsNow, defaultArticleType } = usePublishingHost(); + const { user: currentUser } = useUser(); + const searchParams = useSearchParams(); + + const methods = useForm({ + defaultValues: FORM_DEFAULTS, + resolver: zodResolver(publishingFormSchema), + mode: 'onChange', + }); + + const noteId = note?.id; + const isPublished = Boolean(note?.post); + + useEffect(() => { + if (!note) return; + + methods.reset(FORM_DEFAULTS); + const isRegisteredReport = isRegisteredReportNote(note); + + if (note.post) { + populateFromPost(note.post, methods.setValue); + } else { + populateFormFromNoteDetails(note, methods.setValue); + + if (isRegisteredReport) { + populateRegisteredReportPrefill(note, methods.getValues, methods.setValue); + } + + const articleType = + (note.documentType ? mapDocumentTypeToArticleType(note.documentType) : null) ?? + resolveArticleType(searchParams) ?? + defaultArticleType ?? + null; + + if (articleType) { + methods.setValue('articleType', articleType); + } + } + + if (isRegisteredReport) { + methods.setValue('articleType', 'registered_report'); + } + + applyGrantDefaults(methods.getValues, methods.setValue); + + // 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); + + // 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 || isPublished) return; + + 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, isPublished, methods, saveDetailsSoon]); + + const { watch, clearErrors } = methods; + const articleType = watch('articleType'); + const workId = watch('workId'); + const selectedNonprofit = watch('selectedNonprofit'); + const isPublicValue = watch('isPublic'); + const selectedGrantValue = watch('selectedGrant'); + + useEffect(() => { + clearErrors(); + }, [articleType, clearErrors]); + + const isDeclined = note?.post?.grant?.status === 'DECLINED'; + const isModerator = !!currentUser?.isModerator; + const isChangelog = isChangelogNote(note); + const isNewPreprint = articleType === 'discussion' && !workId && !isChangelog; + const canPublishChangelog = !isChangelog || isModerator; + const canPublishRegisteredReport = articleType !== 'registered_report' || isModerator; + const isLockedPrivate = selectedGrantValue?.applicationVisibility === 'PRIVATE'; + const showPrivateWarning = !isLockedPrivate && isPublicValue === false && !selectedGrantValue; + + const action = usePublishAction({ + methods, + note, + editor, + saveDetailsNow, + readOnly, + isNewPreprint, + isChangelog, + canPublishChangelog, + canPublishRegisteredReport, + }); + + const canPublish = + Boolean(articleType) && + !readOnly && + !isNewPreprint && + canPublishChangelog && + !action.isPublishing && + !isDeclined && + !showPrivateWarning && + canPublishRegisteredReport && + // Publishing reads the document from the editor; without one it would + // publish an empty work. + editor != null; + + const blockedMessage = !canPublishRegisteredReport + ? 'Only moderators can publish Registered Reports.' + : !canPublishChangelog + ? CHANGELOG_PUBLISH_ERROR_MESSAGE + : null; + + const controller: PublishingController = { + ...action, + note, + editor, + readOnly, + articleType, + workId, + isPublished, + isChangelog, + isDeclined, + canPublish, + blockedMessage, + }; + + return ( + + + {children} + + {action.pendingConfirmation === 'nonprofit' && selectedNonprofit && ( + + )} + + {action.pendingConfirmation === 'publish' && ( + setDocumentTitle(editor, title)} + variant={articleType === 'grant' ? 'rfp' : 'default'} + documentLabel={isChangelog ? 'ChangeLog entry' : undefined} + /> + )} + + + ); +} + +export function usePublishingController(): PublishingController { + const controller = useContext(PublishingControllerContext); + if (!controller) { + throw new Error('usePublishingController must be used within a PublishingFormProvider'); + } + return controller; +} diff --git a/components/Notebook/PublishingForm/formMapping.ts b/components/Notebook/PublishingForm/formMapping.ts new file mode 100644 index 000000000..7e66f87f7 --- /dev/null +++ b/components/Notebook/PublishingForm/formMapping.ts @@ -0,0 +1,290 @@ +/** + * Pure mappings between a note, the publishing form's values, and the API: + * how a draft's saved Details fill the form, how one changed field becomes a + * Details update, and where a published work lives. Nothing here touches + * React or the network except `saveSelectedNonprofit`, which needs the + * nonprofit stored before its id can be saved. + */ +import type { NoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; +import { NonprofitService } from '@/services/nonprofit.service'; +import { ARTICLE_TYPE_API_MAP } from '@/services/post.service'; +import type { NonprofitOrg } from '@/types/nonprofit'; +import type { NoteDetailsUpdate, NoteWithContent } from '@/types/note'; +import { buildRegisteredReportUrl } from '@/utils/registeredReportRoute'; +import { buildWorkUrl } from '@/utils/url'; +import type { ArticleType, PublishingFormData, SelectOption } from './schema'; + +export const DEFAULT_FUNDRAISE_END_DAYS = '60'; + +export const FORM_DEFAULTS = { + authors: [], + contacts: [], + rewardFunders: false, + nftSupply: '1000', + budget: '', + coverImage: null, + selectedNonprofit: null, + selectedGrant: null, + departmentLabName: '', + shortDescription: '', + organization: '', + applicationDeadline: null, + fundraiseEndDays: DEFAULT_FUNDRAISE_END_DAYS as '60', + applicationVisibility: 'OPTIONAL' as const, + isPublic: true, +}; + +type SetValue = (name: any, value: any) => void; +type GetValues = (name: any) => any; + +const mapContentTypeToArticleType = (contentType: string): ArticleType => { + if (contentType === 'preregistration') return 'preregistration'; + if (contentType === 'funding_request') return 'grant'; + return 'discussion'; +}; + +export const mapDocumentTypeToArticleType = (documentType: string): ArticleType | null => { + const map: Record = { + DISCUSSION: 'discussion', + GRANT: 'grant', + PREREGISTRATION: 'preregistration', + REGISTERED_REPORT: 'registered_report', + }; + return map[documentType] ?? null; +}; + +const populateGrantFields = (grant: any, setValue: SetValue) => { + if (!grant) return; + if (grant.endDate) setValue('applicationDeadline', new Date(grant.endDate)); + if (grant.description) setValue('shortDescription', grant.description); + if (grant.organization) setValue('organization', grant.organization); + if (grant.amount) setValue('budget', grant.amount.usd.toString()); + if (grant.contacts?.length > 0) { + setValue( + 'contacts', + grant.contacts.map((c: any) => ({ + value: c.id.toString(), + label: c.authorProfile?.fullName || c.name, + })) + ); + } + if (grant.applicationVisibility) { + setValue('applicationVisibility', grant.applicationVisibility); + } +}; + +/** Populates the form from the work this note was published as. */ +export const populateFromPost = (post: any, setValue: SetValue) => { + setValue('workId', post.id.toString()); + setValue( + 'articleType', + (post.documentType && mapDocumentTypeToArticleType(post.documentType)) ?? + mapContentTypeToArticleType(post.contentType) + ); + + if (post.contentType === 'preregistration') { + setValue('budget', post.fundraise?.goalAmount.usd.toString()); + } + if (post.contentType === 'funding_request') { + populateGrantFields(post.grant, setValue); + } + if (post.image) { + setValue('coverImage', { file: null, url: post.image }); + } + if (post.authors?.length > 0) { + setValue( + 'authors', + post.authors.map((a: any) => ({ value: a.authorId.toString(), label: a.name })) + ); + } +}; + +export 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. */ +export const populateFormFromNoteDetails = (note: NoteWithContent, setValue: SetValue) => { + if (note.image || note.previewImage) { + setValue('coverImage', { file: null, key: note.image, url: note.previewImage }); + } + 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 author gap from a Registered Report's proposal. */ +export const populateRegisteredReportPrefill = ( + note: NoteWithContent, + getValues: GetValues, + setValue: SetValue +) => { + const { authorIds = [] } = note.registeredReportPrefill ?? {}; + + if (authorIds.length > 0 && getValues('authors').length === 0) { + setValue( + 'authors', + authorIds.map((id) => ({ value: id.toString(), label: `Author ${id}` })) + ); + } +}; + +/** Builds a Note Details update for one changed form field. */ +export 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 '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; + } +}; + +/** Saves the nonprofit under the id the Note API stores, not its Endaoment one. */ +export const saveSelectedNonprofit = async ( + nonprofit: NonprofitOrg | null, + saveDetailsSoon: NoteDetailsSaver['saveDetailsSoon'], + getValues: GetValues +) => { + 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); + } +}; + +export const applyGrantDefaults = (getValues: GetValues, setValue: SetValue) => { + if (getValues('articleType') === 'grant') { + setValue('applicationDeadline', new Date('2029-12-31')); + } +}; + +/** Names the field it defaulted, which the caller has to save like any edit. */ +export const autoAddCurrentUser = ( + getValues: GetValues, + setValue: SetValue, + currentUser: any +): 'authors' | 'contacts' | null => { + const articleType = getValues('articleType'); + if (!currentUser || articleType === 'registered_report') return null; + + const isGrant = articleType === 'grant'; + const field = isGrant ? 'contacts' : 'authors'; + 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; +}; + +/** The work type a notebook route asked for (`?newFunding=true`, `?template=grant`…). */ +export const resolveArticleType = ( + params: { get(key: string): string | null } | null +): ArticleType | null => { + if (params?.get('newFunding') === 'true') return 'preregistration'; + if (params?.get('newChangelog') === 'true') return 'discussion'; + if (params?.get('newGrant') === 'true') return 'grant'; + + const template = params?.get('template'); + if (template === 'preregistration') return 'preregistration'; + if (template === 'grant') return 'grant'; + return null; +}; + +/** Where a work of this type lives once published. */ +export const getWorkPath = (articleType: string, workId: string, slug: string): string => { + if (articleType === 'preregistration') return `/proposal/${workId}/${slug}?new=true`; + if (articleType === 'grant') + return buildWorkUrl({ id: workId, slug, contentType: 'funding_request' }); + if (articleType === 'registered_report') return buildRegisteredReportUrl(workId, slug); + return `/post/${workId}/${slug}`; +}; diff --git a/components/Notebook/PublishingForm/index.tsx b/components/Notebook/PublishingForm/index.tsx index 7459beaab..56f592732 100644 --- a/components/Notebook/PublishingForm/index.tsx +++ b/components/Notebook/PublishingForm/index.tsx @@ -1,916 +1,168 @@ -import { useForm, FormProvider } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { publishingFormSchema } from './schema'; -import type { PublishingFormData, SelectOption } from './schema'; -import { WorkImageSection } from './components/WorkImageSection'; -import { FundingSection } from './components/FundingSection'; +'use client'; + +import { useFormContext } from 'react-hook-form'; +import { Loader2 } from 'lucide-react'; +import { getAvailableNotebookWorkTypes } from '@/components/Notebook/NotebookPrimaryNavigation'; +import { PublishingFormSkeleton } from '@/components/skeletons/PublishingFormSkeleton'; +import { Button } from '@/components/ui/Button'; +import { DOISection } from '@/components/work/components/DOISection'; +import { cn } from '@/utils/styles'; import { AuthorsSection } from './components/AuthorsSection'; import { ContactsSection } from './components/ContactsSection'; +import { EndDateSection } from './components/EndDateSection'; +import { FundingSection } from './components/FundingSection'; +import { GrantApplicationVisibilitySection } from './components/GrantApplicationVisibilitySection'; import { GrantDescriptionSection } from './components/GrantDescriptionSection'; -import { GrantOrganizationSection } from './components/GrantOrganizationSection'; import { GrantFundingAmountSection } from './components/GrantFundingAmountSection'; -import { GrantApplicationVisibilitySection } from './components/GrantApplicationVisibilitySection'; +import { GrantOrganizationSection } from './components/GrantOrganizationSection'; import { - PreregistrationPrivacySection, PreregistrationPrivacyLockedAlert, + PreregistrationPrivacySection, } from './components/PreregistrationPrivacySection'; -import { Button } from '@/components/ui/Button'; -import { cn } from '@/utils/styles'; -import { buildWorkUrl } from '@/utils/url'; -import { useState, useEffect } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useUpsertPost } from '@/hooks/useDocument'; -import { ConfirmPublishModal } from '@/components/modals/ConfirmPublishModal'; -import { - getDocumentTitleFromEditor, - setDocumentTitle, -} from '@/components/Editor/lib/utils/documentTitle'; import { ResearchCoinSection } from './components/ResearchCoinSection'; -import { EndDateSection } from './components/EndDateSection'; -import { toast } from 'react-hot-toast'; -import { PublishingFormSkeleton } from '@/components/skeletons/PublishingFormSkeleton'; -import { Loader2 } from 'lucide-react'; -import { DOISection } from '@/components/work/components/DOISection'; -import { getFieldErrorMessage } from '@/utils/form'; -import { usePublishingHost } from '@/contexts/PublishingHostContext'; -import { useUser } from '@/contexts/UserContext'; -import { useAssetUpload } from '@/hooks/useAssetUpload'; -import { useNonprofitLink } from '@/hooks/useNonprofitLink'; -import { NonprofitConfirmModal } from '@/components/Nonprofit'; -import { ApiError } from '@/services/types'; -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 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'; +import { WorkImageSection } from './components/WorkImageSection'; +import { PublishButton } from './PublishButton'; +import { usePublishingController } from './PublishingFormProvider'; +import type { PublishingFormData } from './schema'; -const FEATURE_FLAG_RESEARCH_COIN = false; -const DEFAULT_FUNDRAISE_END_DAYS = '60'; -const CHANGELOG_PUBLISH_ERROR_MESSAGE = 'Cannot publish changelog'; +export { PublishingFormProvider, usePublishingController } from './PublishingFormProvider'; +export { PublishButton } from './PublishButton'; -const PUBLISH_LABEL: Record = { - preregistration: 'Proposal', - grant: 'Request for Proposal', - registered_report: 'Registered Report', -}; +const FEATURE_FLAG_RESEARCH_COIN = false; const PUBLISHING_FORM_WORK_TYPES = getAvailableNotebookWorkTypes(false); interface PublishingFormProps { bountyAmount?: number | null; onBountyClick?: () => void; - readOnly?: boolean; } -const getButtonText = ({ - isLoadingUpsert, - isRedirecting, - isLinkingNonprofit, - hasWorkId, -}: { - isLoadingUpsert: boolean; - isRedirecting: boolean; - isLinkingNonprofit: boolean; - hasWorkId: boolean; -}) => { - switch (true) { - case isLoadingUpsert: - return 'Publishing...'; - case isLinkingNonprofit: - return 'Linking nonprofit...'; - case isRedirecting: - return 'Redirecting...'; - case hasWorkId: - return 'Publish'; - default: - return 'Publish'; - } -}; - -const FORM_DEFAULTS = { - authors: [], - contacts: [], - rewardFunders: false, - nftSupply: '1000', - budget: '', - coverImage: null, - selectedNonprofit: null, - selectedGrant: null, - departmentLabName: '', - shortDescription: '', - organization: '', - applicationDeadline: null, - fundraiseEndDays: DEFAULT_FUNDRAISE_END_DAYS as '60', - applicationVisibility: 'OPTIONAL' as const, - isPublic: true, -}; - -const mapContentTypeToArticleType = (contentType: string): PublishingFormData['articleType'] => { - if (contentType === 'preregistration') return 'preregistration'; - if (contentType === 'funding_request') return 'grant'; - return 'discussion'; -}; - -const mapDocumentTypeToArticleType = ( - documentType: string -): PublishingFormData['articleType'] | null => { - const map: Record = { - DISCUSSION: 'discussion', - GRANT: 'grant', - PREREGISTRATION: 'preregistration', - REGISTERED_REPORT: 'registered_report', - }; - return map[documentType] ?? null; -}; - -const populateGrantFields = (grant: any, setValue: (name: any, value: any) => void) => { - if (!grant) return; - if (grant.endDate) setValue('applicationDeadline', new Date(grant.endDate)); - if (grant.description) setValue('shortDescription', grant.description); - if (grant.organization) setValue('organization', grant.organization); - if (grant.amount) setValue('budget', grant.amount.usd.toString()); - if (grant.contacts?.length > 0) { - setValue( - 'contacts', - grant.contacts.map((c: any) => ({ - value: c.id.toString(), - label: c.authorProfile?.fullName || c.name, - })) - ); - } - if (grant.applicationVisibility) { - setValue('applicationVisibility', grant.applicationVisibility); - } -}; - -const populateFromPost = (post: any, setValue: (name: any, value: any) => void) => { - setValue('workId', post.id.toString()); - setValue( - 'articleType', - (post.documentType && mapDocumentTypeToArticleType(post.documentType)) ?? - mapContentTypeToArticleType(post.contentType) - ); - - if (post.contentType === 'preregistration') { - setValue('budget', post.fundraise?.goalAmount.usd.toString()); - } - if (post.contentType === 'funding_request') { - populateGrantFields(post.grant, setValue); - } - if (post.image) { - setValue('coverImage', { file: null, url: post.image }); - } - if (post.authors?.length > 0) { - setValue( - 'authors', - post.authors.map((a: any) => ({ value: a.authorId.toString(), label: a.name })) - ); - } -}; - -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, - setValue: (name: any, value: any) => void -) => { - if (note.image || note.previewImage) { - setValue('coverImage', { file: null, key: note.image, url: note.previewImage }); - } - 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 author gap from a Registered Report's proposal. */ -const populateRegisteredReportPrefill = ( - note: NoteWithContent, - getValues: (name: any) => any, - setValue: (name: any, value: any) => void -) => { - const { authorIds = [] } = note.registeredReportPrefill ?? {}; - - if (authorIds.length > 0 && getValues('authors').length === 0) { - setValue( - 'authors', - authorIds.map((id) => ({ value: id.toString(), label: `Author ${id}` })) - ); - } -}; - -/** 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 '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; - } -}; - -/** 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 -) => { - 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); - } -}; - -const applyGrantDefaults = (getValues: any, setValue: (name: any, value: any) => void) => { - if (getValues('articleType') === 'grant') { - setValue('applicationDeadline', new Date('2029-12-31')); - } -}; - -/** 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 null; - - const isGrant = articleType === 'grant'; - const field = isGrant ? 'contacts' : 'authors'; - 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 = ( - params: { get(key: string): string | null } | null -): PublishingFormData['articleType'] | null => { - if (params?.get('newFunding') === 'true') return 'preregistration'; - if (params?.get('newChangelog') === 'true') return 'discussion'; - if (params?.get('newGrant') === 'true') return 'grant'; - - const template = params?.get('template'); - if (template === 'preregistration') return 'preregistration'; - if (template === 'grant') return 'grant'; - return null; -}; - -const getRedirectPath = (articleType: string, responseId: string, slug: string): string => { - if (articleType === 'preregistration') return `/proposal/${responseId}/${slug}?new=true`; - if (articleType === 'grant') - return buildWorkUrl({ id: responseId, slug, contentType: 'funding_request' }); - if (articleType === 'registered_report') return buildRegisteredReportUrl(responseId, slug); - return `/post/${responseId}/${slug}`; -}; - -export function PublishingForm({ - bountyAmount, - onBountyClick, - readOnly = false, -}: Readonly) { - const { note, editor, saveDetailsSoon, saveDetailsNow, defaultArticleType } = usePublishingHost(); - const { user: currentUser } = useUser(); - const searchParams = useSearchParams(); - const [isRedirecting, setIsRedirecting] = useState(false); - const [{ loading: isUploadingImage }, uploadAsset] = useAssetUpload(); - const { linkNonprofitToFundraise, isLoading: isLinkingNonprofit } = useNonprofitLink(); - const [showNonprofitConfirmModal, setShowNonprofitConfirmModal] = useState(false); - - const methods = useForm({ - defaultValues: FORM_DEFAULTS, - resolver: zodResolver(publishingFormSchema), - mode: 'onChange', - }); - - const noteId = note?.id; - const isPublished = Boolean(note?.post); - - useEffect(() => { - if (!note) return; - - methods.reset(FORM_DEFAULTS); - const isRegisteredReport = isRegisteredReportNote(note); - - if (note.post) { - populateFromPost(note.post, methods.setValue); - } else { - populateFormFromNoteDetails(note, methods.setValue); - - if (isRegisteredReport) { - populateRegisteredReportPrefill(note, methods.getValues, methods.setValue); - } - - const articleType = - (note.documentType ? mapDocumentTypeToArticleType(note.documentType) : null) ?? - resolveArticleType(searchParams) ?? - defaultArticleType ?? - null; - - if (articleType) { - methods.setValue('articleType', articleType); - } - } - - if (isRegisteredReport) { - methods.setValue('articleType', 'registered_report'); - } - - applyGrantDefaults(methods.getValues, methods.setValue); - - // 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); - - // 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 || isPublished) return; - - 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, isPublished, methods, saveDetailsSoon]); - - const { watch, clearErrors } = methods; - const articleType = watch('articleType'); - const workId = watch('workId'); - const selectedNonprofit = watch('selectedNonprofit'); - - const [{ isLoading: isLoadingUpsert }, upsertPost] = useUpsertPost(); - - const [showConfirmModal, setShowConfirmModal] = useState(false); - const router = useRouter(); - - const isDeclined = note?.post?.grant?.status === 'DECLINED'; - const isModerator = !!currentUser?.isModerator; - const isChangelog = isChangelogNote(note); - const isNewPreprint = articleType === 'discussion' && !workId && !isChangelog; - const canPublishChangelog = !isChangelog || isModerator; - const isPublishing = isLoadingUpsert || isRedirecting || isLinkingNonprofit || isUploadingImage; - const canPublishRegisteredReport = articleType !== 'registered_report' || isModerator; - const isPublicValue = watch('isPublic'); - const selectedGrantValue = watch('selectedGrant'); - const isLockedPrivate = selectedGrantValue?.applicationVisibility === 'PRIVATE'; - const showPrivateWarning = !isLockedPrivate && isPublicValue === false && !selectedGrantValue; - - useEffect(() => { - clearErrors(); - }, [articleType, clearErrors]); - - const handlePublishClick = async () => { - if (readOnly) return; - - if (isNewPreprint) { - toast.error('Preprints can no longer be created in the notebook.'); - return; - } - - if (!canPublishRegisteredReport) { - toast.error('Only moderators can publish Registered Reports.'); - return; - } - - const result = await methods.trigger(); - - if (!result) { - const errors = methods.formState.errors; - - if (Object.keys(errors).length > 0) { - Object.entries(errors).forEach(([field, error]) => { - const errorMessage = getFieldErrorMessage(error, `Invalid ${field}`); - if (errorMessage) { - toast.error(errorMessage, { - style: { width: '300px' }, - }); - } - }); - } else { - toast.error('Unable to publish. Please check all fields and try again.', { - style: { width: '300px' }, - }); - } - return; - } - - if ( - articleType !== 'preregistration' && - articleType !== 'discussion' && - articleType !== 'grant' && - articleType !== 'registered_report' - ) { - return; - } - - if (selectedNonprofit) { - setShowNonprofitConfirmModal(true); - } else { - setShowConfirmModal(true); - } - }; - - const handleNonprofitConfirm = () => { - setShowNonprofitConfirmModal(false); - setShowConfirmModal(true); - }; - - const uploadCoverImage = async (formData: PublishingFormData): Promise => { - const needsImage = - formData.articleType === 'preregistration' || - formData.articleType === 'grant' || - formData.articleType === 'registered_report'; - if (!needsImage) return null; - - const file = formData.coverImage?.file; - if (!file) return formData.coverImage?.key ?? null; - - try { - const result = await uploadAsset(file, 'post'); - return result.objectKey; - } catch (error) { - console.error('Error uploading image:', error); - toast.error('Failed to upload image. Please try again.'); - return false; - } - }; - - const tryLinkNonprofit = async ( - formData: PublishingFormData, - fundraiseId: string | number | undefined - ): Promise => { - if (!formData.selectedNonprofit || !fundraiseId || formData.articleType !== 'preregistration') { - return true; - } - - try { - await linkNonprofitToFundraise( - { - name: formData.selectedNonprofit.name, - ein: formData.selectedNonprofit.ein, - endaomentOrgId: - formData.selectedNonprofit.endaomentOrgId || formData.selectedNonprofit.id, - baseWalletAddress: formData.selectedNonprofit.baseWalletAddress, - }, - fundraiseId, - formData.departmentLabName || '' - ); - return true; - } catch (error: unknown) { - console.error('Error linking nonprofit:', error); - if (error instanceof Error && error.message.includes('Fundraise not found')) { - toast.error('The fundraise was not found. Please try publishing again.'); - return false; - } - toast.error('Nonprofit organization was not linked successfully.'); - return true; - } - }; - - const handleConfirmPublish = async (editedTitle: string) => { - if (readOnly || !note) return; - - if (isNewPreprint) { - toast.error('Preprints can no longer be created in the notebook.'); - return; - } - - if (!canPublishChangelog) { - toast.error(CHANGELOG_PUBLISH_ERROR_MESSAGE); - return; - } - - 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(); - const formData = methods.getValues(); - - if (formData.articleType === 'registered_report' && editedTitle.trim().length < 20) { - toast.error('Registered Report titles must be at least 20 characters.'); - return; - } - - if (formData.articleType === 'registered_report' && (text?.trim().length ?? 0) < 50) { - toast.error('Registered Report content must be at least 50 characters.'); - return; - } - - const imagePath = await uploadCoverImage(formData); - if (imagePath === false) { - setShowConfirmModal(false); - return; - } - - let budgetValue = '0'; - if (formData.articleType === 'preregistration' || formData.articleType === 'grant') { - budgetValue = formData.budget || '0'; - } - - const isNewProposal = formData.articleType === 'preregistration' && !formData.workId; - const grantId = isNewProposal ? (formData.selectedGrant?.id ?? null) : null; - const proposalId = note.proposalId; - - if (formData.articleType === 'registered_report' && proposalId == null) { - toast.error('This Registered Report draft is missing its proposal link.'); - return; - } - - const fullJSON = JSON.stringify( - formData.articleType === 'registered_report' - ? mergeRegisteredReportPrefill(json, proposalId) - : json - ); - - const response = await upsertPost( - { - budget: budgetValue, - rewardFunders: formData.rewardFunders, - nftSupply: formData.nftSupply || '1000', - title: editedTitle, - noteId: note.id.toString(), - proposalId, - renderableText: text || '', - fullJSON, - fullSrc: html || '', - assignDOI: !formData.workId, - authors: mapOptionsToIds(formData.authors), - contacts: mapOptionsToIds(formData.contacts), - articleType: ARTICLE_TYPE_API_MAP[formData.articleType] ?? 'DISCUSSION', - image: imagePath, - previewImg: - formData.articleType === 'registered_report' && !formData.coverImage?.file - ? (formData.coverImage?.url ?? note.previewImage ?? null) - : undefined, - editorType: formData.articleType === 'registered_report' ? 'CK_EDITOR' : undefined, - organization: formData.organization, - description: formData.shortDescription, - applicationDeadline: (() => { - if (formData.articleType === 'grant') return new Date('2029-12-31'); - if (isNewProposal) { - const days = parseInt(formData.fundraiseEndDays ?? DEFAULT_FUNDRAISE_END_DAYS, 10); - const date = new Date(); - date.setDate(date.getDate() + days); - return date; - } - return formData.applicationDeadline; - })(), - grantId, - applicationVisibility: - formData.articleType === 'grant' ? formData.applicationVisibility : undefined, - isPublic: isNewProposal ? formData.isPublic : undefined, - }, - formData.workId - ); - - const fundraiseId = response.fundraiseId || note?.post?.fundraise?.id || undefined; - const linked = await tryLinkNonprofit(formData, fundraiseId); - if (!linked) { - setIsRedirecting(false); - setShowConfirmModal(false); - return; - } - - setIsRedirecting(true); - const publishLabel = isChangelog - ? 'ChangeLog' - : (PUBLISH_LABEL[formData.articleType] ?? 'Post'); - const isNewGrant = formData.articleType === 'grant' && !formData.workId; - const isNewGrantPending = isNewGrant && response.note?.post?.grant?.status !== 'OPEN'; - - if (isNewGrantPending) { - toast.success( - 'Your Request for Proposal has been submitted and is pending moderator review.', - { - duration: 5000, - } - ); - } else if (!isNewGrant && response.moderationStatus === 'PENDING') { - toast.success(`Your ${publishLabel} has been submitted and is pending moderator review.`, { - duration: 5000, - }); - } else { - toast.success(`${publishLabel} published successfully!`); - } - router.push(getRedirectPath(formData.articleType, String(response.id), response.slug)); - } catch (error: unknown) { - const fallback = 'Error publishing. Please try again.'; - if ( - error instanceof ApiError && - articleType === 'registered_report' && - (error.status === 401 || error.status === 403) - ) { - toast.error('Only moderators can publish Registered Reports.'); - } else if (error instanceof ApiError) { - const errorData = error.errors as Record | undefined; - toast.error( - errorData?.msg || errorData?.message || extractApiErrorMessage(error, fallback) - ); - } else { - toast.error(fallback); - } - console.error('Error publishing:', error); - } finally { - setShowConfirmModal(false); - } - }; +/** + * The publishing details for a note: its work type, then the sections that + * type needs, with the publish button in a sticky footer. Renders inside a + * `PublishingFormProvider`, which holds the values and the publish flow. + */ +export function PublishingForm({ bountyAmount, onBountyClick }: Readonly) { + const { + note, + readOnly, + articleType, + workId, + isDeclined, + isPublishing, + isLinkingNonprofit, + isRedirecting, + blockedMessage, + } = usePublishingController(); + const { setValue } = useFormContext(); if (!note) { return ; } return ( - -
- {isPublishing && ( -
- - {isLinkingNonprofit && ( -

Linking nonprofit organization...

- )} -
- )} +
+ {isPublishing && ( +
+ + {isLinkingNonprofit && ( +

Linking nonprofit organization...

+ )} +
+ )} -
+
-
- {!articleType ? ( -
-

Select work type

-

- Choose how you want to publish this note. -

-
- {PUBLISHING_FORM_WORK_TYPES.map((option) => ( - - ))} -
+ + {option.description} + + + + ))}
- ) : ( - <> - {(articleType === 'preregistration' || - articleType === 'grant' || - articleType === 'registered_report') && } - {articleType === 'grant' && ( - <> - - - - )} - {articleType === 'grant' ? : } - {note.post?.doi && ( -
- -
- )} - {articleType === 'grant' && } - {articleType === 'grant' && } - {articleType === 'preregistration' && } - {articleType === 'preregistration' && !workId && ( -
- -
+
+ ) : ( + <> + {(articleType === 'preregistration' || + articleType === 'grant' || + articleType === 'registered_report') && } + {articleType === 'grant' && ( + <> + + + + )} + {articleType === 'grant' ? : } + {note.post?.doi && ( +
+ +
+ )} + {articleType === 'grant' && } + {articleType === 'grant' && } + {articleType === 'preregistration' && } + {articleType === 'preregistration' && !workId && ( +
+ +
+ )} + {articleType === 'preregistration' && !workId && } + {FEATURE_FLAG_RESEARCH_COIN && + articleType !== 'preregistration' && + articleType !== 'grant' && ( + {})} + /> )} - {articleType === 'preregistration' && !workId && } - {FEATURE_FLAG_RESEARCH_COIN && - articleType !== 'preregistration' && - articleType !== 'grant' && ( - {})} - /> - )} - - )} - -
+ + )} + +
-
-
- {articleType === 'preregistration' && !workId && } - {articleType === 'registered_report' && !canPublishRegisteredReport && ( -

- Only moderators can publish Registered Reports. -

- )} - {!canPublishChangelog && ( -

{CHANGELOG_PUBLISH_ERROR_MESSAGE}

- )} - -
+
+
+ {articleType === 'preregistration' && !workId && } + {blockedMessage &&

{blockedMessage}

} +
- - {showNonprofitConfirmModal && selectedNonprofit && ( - setShowNonprofitConfirmModal(false)} - onConfirm={handleNonprofitConfirm} - nonprofitName={selectedNonprofit.name} - ein={selectedNonprofit.ein} - /> - )} - - {showConfirmModal && ( - setShowConfirmModal(false)} - onConfirm={handleConfirmPublish} - title={ - getDocumentTitleFromEditor(editor) || - (isChangelog ? 'Untitled ChangeLog' : 'Untitled Research') - } - isPublishing={isPublishing} - isUpdate={Boolean(workId)} - onTitleChange={(title) => setDocumentTitle(editor, title)} - variant={articleType === 'grant' ? 'rfp' : 'default'} - documentLabel={isChangelog ? 'ChangeLog entry' : undefined} - /> - )} - +
); } diff --git a/components/Notebook/PublishingForm/schema.ts b/components/Notebook/PublishingForm/schema.ts index 1c633b171..33ace9ba2 100644 --- a/components/Notebook/PublishingForm/schema.ts +++ b/components/Notebook/PublishingForm/schema.ts @@ -91,4 +91,5 @@ export const publishingFormSchema = z }); export type PublishingFormData = z.infer; +export type ArticleType = PublishingFormData['articleType']; export type SelectOption = z.infer; diff --git a/components/Notebook/PublishingForm/usePublishAction.ts b/components/Notebook/PublishingForm/usePublishAction.ts new file mode 100644 index 000000000..22fe512b5 --- /dev/null +++ b/components/Notebook/PublishingForm/usePublishAction.ts @@ -0,0 +1,380 @@ +import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import type { Editor } from '@tiptap/react'; +import type { UseFormReturn } from 'react-hook-form'; +import { toast } from 'react-hot-toast'; +import { setDocumentTitle } from '@/components/Editor/lib/utils/documentTitle'; +import { useAssetUpload } from '@/hooks/useAssetUpload'; +import { useUpsertPost } from '@/hooks/useDocument'; +import { useNonprofitLink } from '@/hooks/useNonprofitLink'; +import type { NoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; +import { extractApiErrorMessage } from '@/services/lib/serviceUtils'; +import { ARTICLE_TYPE_API_MAP } from '@/services/post.service'; +import { ApiError } from '@/services/types'; +import type { NoteWithContent } from '@/types/note'; +import { getFieldErrorMessage } from '@/utils/form'; +import { mergeRegisteredReportPrefill } from '@/utils/registeredReportPrefill'; +import { DEFAULT_FUNDRAISE_END_DAYS, getWorkPath, mapOptionsToIds } from './formMapping'; +import type { PublishingFormData } from './schema'; + +export const CHANGELOG_PUBLISH_ERROR_MESSAGE = 'Cannot publish changelog'; +const REGISTERED_REPORT_MODERATOR_MESSAGE = 'Only moderators can publish Registered Reports.'; +const PREPRINT_MESSAGE = 'Preprints can no longer be created in the notebook.'; + +const PUBLISH_LABEL: Record = { + preregistration: 'Proposal', + grant: 'Request for Proposal', + registered_report: 'Registered Report', +}; + +/** Which confirmation, if any, is waiting on the user before the work is published. */ +export type PendingConfirmation = 'nonprofit' | 'publish' | null; + +interface PublishActionOptions { + readonly methods: UseFormReturn; + readonly note: NoteWithContent | null; + readonly editor: Editor | null; + readonly saveDetailsNow: NoteDetailsSaver['saveDetailsNow']; + readonly readOnly: boolean; + readonly isNewPreprint: boolean; + readonly isChangelog: boolean; + readonly canPublishChangelog: boolean; + readonly canPublishRegisteredReport: boolean; +} + +export interface PublishAction { + /** Validates the form and, if it passes, asks the user to confirm. */ + readonly requestPublish: () => Promise; + readonly pendingConfirmation: PendingConfirmation; + readonly dismissConfirmation: () => void; + /** The nonprofit was confirmed; the publish confirmation comes next. */ + readonly acceptNonprofit: () => void; + /** Publishes under the title the user confirmed. */ + readonly confirmPublish: (title: string) => Promise; + /** Any step of publishing is in flight. */ + readonly isPublishing: boolean; + /** The image upload or the post itself is being written. */ + readonly isUpserting: boolean; + readonly isLinkingNonprofit: boolean; + readonly isRedirecting: boolean; +} + +/** + * The publish flow: validate, confirm (the nonprofit first when one is + * selected), then write the work from the editor and the form and leave for + * its page. Errors surface as toasts; the caller only renders the modals. + */ +export function usePublishAction({ + methods, + note, + editor, + saveDetailsNow, + readOnly, + isNewPreprint, + isChangelog, + canPublishChangelog, + canPublishRegisteredReport, +}: PublishActionOptions): PublishAction { + const router = useRouter(); + const [pendingConfirmation, setPendingConfirmation] = useState(null); + const [isRedirecting, setIsRedirecting] = useState(false); + const [{ loading: isUploadingImage }, uploadAsset] = useAssetUpload(); + const [{ isLoading: isLoadingUpsert }, upsertPost] = useUpsertPost(); + const { linkNonprofitToFundraise, isLoading: isLinkingNonprofit } = useNonprofitLink(); + + const dismissConfirmation = useCallback(() => setPendingConfirmation(null), []); + const acceptNonprofit = useCallback(() => setPendingConfirmation('publish'), []); + + const requestPublish = useCallback(async () => { + if (readOnly) return; + + if (isNewPreprint) { + toast.error(PREPRINT_MESSAGE); + return; + } + + if (!canPublishRegisteredReport) { + toast.error(REGISTERED_REPORT_MODERATOR_MESSAGE); + return; + } + + const result = await methods.trigger(); + + if (!result) { + const errors = methods.formState.errors; + + if (Object.keys(errors).length > 0) { + Object.entries(errors).forEach(([field, error]) => { + const errorMessage = getFieldErrorMessage(error, `Invalid ${field}`); + if (errorMessage) { + toast.error(errorMessage, { + style: { width: '300px' }, + }); + } + }); + } else { + toast.error('Unable to publish. Please check all fields and try again.', { + style: { width: '300px' }, + }); + } + return; + } + + const { articleType, selectedNonprofit } = methods.getValues(); + if ( + articleType !== 'preregistration' && + articleType !== 'discussion' && + articleType !== 'grant' && + articleType !== 'registered_report' + ) { + return; + } + + setPendingConfirmation(selectedNonprofit ? 'nonprofit' : 'publish'); + }, [readOnly, isNewPreprint, canPublishRegisteredReport, methods]); + + const uploadCoverImage = useCallback( + async (formData: PublishingFormData): Promise => { + const needsImage = + formData.articleType === 'preregistration' || + formData.articleType === 'grant' || + formData.articleType === 'registered_report'; + if (!needsImage) return null; + + const file = formData.coverImage?.file; + if (!file) return formData.coverImage?.key ?? null; + + try { + const result = await uploadAsset(file, 'post'); + return result.objectKey; + } catch (error) { + console.error('Error uploading image:', error); + toast.error('Failed to upload image. Please try again.'); + return false; + } + }, + [uploadAsset] + ); + + const tryLinkNonprofit = useCallback( + async ( + formData: PublishingFormData, + fundraiseId: string | number | undefined + ): Promise => { + if ( + !formData.selectedNonprofit || + !fundraiseId || + formData.articleType !== 'preregistration' + ) { + return true; + } + + try { + await linkNonprofitToFundraise( + { + name: formData.selectedNonprofit.name, + ein: formData.selectedNonprofit.ein, + endaomentOrgId: + formData.selectedNonprofit.endaomentOrgId || formData.selectedNonprofit.id, + baseWalletAddress: formData.selectedNonprofit.baseWalletAddress, + }, + fundraiseId, + formData.departmentLabName || '' + ); + return true; + } catch (error: unknown) { + console.error('Error linking nonprofit:', error); + if (error instanceof Error && error.message.includes('Fundraise not found')) { + toast.error('The fundraise was not found. Please try publishing again.'); + return false; + } + toast.error('Nonprofit organization was not linked successfully.'); + return true; + } + }, + [linkNonprofitToFundraise] + ); + + const confirmPublish = useCallback( + async (editedTitle: string) => { + if (readOnly || !note) return; + + if (isNewPreprint) { + toast.error(PREPRINT_MESSAGE); + return; + } + + if (!canPublishChangelog) { + toast.error(CHANGELOG_PUBLISH_ERROR_MESSAGE); + return; + } + + 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(); + const formData = methods.getValues(); + + if (formData.articleType === 'registered_report' && editedTitle.trim().length < 20) { + toast.error('Registered Report titles must be at least 20 characters.'); + return; + } + + if (formData.articleType === 'registered_report' && (text?.trim().length ?? 0) < 50) { + toast.error('Registered Report content must be at least 50 characters.'); + return; + } + + const imagePath = await uploadCoverImage(formData); + if (imagePath === false) return; + + let budgetValue = '0'; + if (formData.articleType === 'preregistration' || formData.articleType === 'grant') { + budgetValue = formData.budget || '0'; + } + + const isNewProposal = formData.articleType === 'preregistration' && !formData.workId; + const grantId = isNewProposal ? (formData.selectedGrant?.id ?? null) : null; + const proposalId = note.proposalId; + + if (formData.articleType === 'registered_report' && proposalId == null) { + toast.error('This Registered Report draft is missing its proposal link.'); + return; + } + + const fullJSON = JSON.stringify( + formData.articleType === 'registered_report' + ? mergeRegisteredReportPrefill(json, proposalId) + : json + ); + + const response = await upsertPost( + { + budget: budgetValue, + rewardFunders: formData.rewardFunders, + nftSupply: formData.nftSupply || '1000', + title: editedTitle, + noteId: note.id.toString(), + proposalId, + renderableText: text || '', + fullJSON, + fullSrc: html || '', + assignDOI: !formData.workId, + authors: mapOptionsToIds(formData.authors), + contacts: mapOptionsToIds(formData.contacts), + articleType: ARTICLE_TYPE_API_MAP[formData.articleType] ?? 'DISCUSSION', + image: imagePath, + previewImg: + formData.articleType === 'registered_report' && !formData.coverImage?.file + ? (formData.coverImage?.url ?? note.previewImage ?? null) + : undefined, + editorType: formData.articleType === 'registered_report' ? 'CK_EDITOR' : undefined, + organization: formData.organization, + description: formData.shortDescription, + applicationDeadline: (() => { + if (formData.articleType === 'grant') return new Date('2029-12-31'); + if (isNewProposal) { + const days = parseInt(formData.fundraiseEndDays ?? DEFAULT_FUNDRAISE_END_DAYS, 10); + const date = new Date(); + date.setDate(date.getDate() + days); + return date; + } + return formData.applicationDeadline; + })(), + grantId, + applicationVisibility: + formData.articleType === 'grant' ? formData.applicationVisibility : undefined, + isPublic: isNewProposal ? formData.isPublic : undefined, + }, + formData.workId + ); + + const fundraiseId = response.fundraiseId || note.post?.fundraise?.id || undefined; + const linked = await tryLinkNonprofit(formData, fundraiseId); + if (!linked) { + setIsRedirecting(false); + return; + } + + setIsRedirecting(true); + const publishLabel = isChangelog + ? 'ChangeLog' + : (PUBLISH_LABEL[formData.articleType] ?? 'Post'); + const isNewGrant = formData.articleType === 'grant' && !formData.workId; + const isNewGrantPending = isNewGrant && response.note?.post?.grant?.status !== 'OPEN'; + + if (isNewGrantPending) { + toast.success( + 'Your Request for Proposal has been submitted and is pending moderator review.', + { + duration: 5000, + } + ); + } else if (!isNewGrant && response.moderationStatus === 'PENDING') { + toast.success( + `Your ${publishLabel} has been submitted and is pending moderator review.`, + { + duration: 5000, + } + ); + } else { + toast.success(`${publishLabel} published successfully!`); + } + router.push(getWorkPath(formData.articleType, String(response.id), response.slug)); + } catch (error: unknown) { + const fallback = 'Error publishing. Please try again.'; + if ( + error instanceof ApiError && + methods.getValues('articleType') === 'registered_report' && + (error.status === 401 || error.status === 403) + ) { + toast.error(REGISTERED_REPORT_MODERATOR_MESSAGE); + } else if (error instanceof ApiError) { + const errorData = error.errors as Record | undefined; + toast.error( + errorData?.msg || errorData?.message || extractApiErrorMessage(error, fallback) + ); + } else { + toast.error(fallback); + } + console.error('Error publishing:', error); + } finally { + setPendingConfirmation(null); + } + }, + [ + readOnly, + note, + isNewPreprint, + canPublishChangelog, + editor, + saveDetailsNow, + methods, + uploadCoverImage, + upsertPost, + tryLinkNonprofit, + isChangelog, + router, + ] + ); + + const isUpserting = isLoadingUpsert || isUploadingImage; + + return { + requestPublish, + pendingConfirmation, + dismissConfirmation, + acceptNonprofit, + confirmPublish, + isPublishing: isUpserting || isRedirecting || isLinkingNonprofit, + isUpserting, + isLinkingNonprofit, + isRedirecting, + }; +}