From c7a6cb5f8e1defce57caca76964662d840ca2265 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Mon, 21 Sep 2026 20:59:35 -0400 Subject: [PATCH 01/46] 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, + }; +} From ddac2b8b72ced64b9000604e96778501f9ee7b2a Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Mon, 21 Sep 2026 21:10:20 -0400 Subject: [PATCH 02/46] Replace the AI Mode document tabs with detail blocks and header publish controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document pane's Document / Publish tabs become a strip of detail blocks — Applying to, Funding goal, Authors, Cover image for a proposal; Funding amount, Short description, Contacts, Cover image for an RFP — each opening a popover with just its section of the form (or, for the RFP picker, the existing modal). The strip shows as many blocks as the pane is wide enough for, in a fixed order, and folds the rest into the "All details" block, which opens the full form and says how many it holds (hooks/useFittingCount). The workspace header gains the document's title, a "N remaining" pill and a Publish button that stays disabled until nothing required is missing; a published document shows a Published pill and an Update button. The form now lives as long as the note does, so switching views no longer rehydrates it from stale content. Below the tablet breakpoint the drawer trades the strip for a Document | Details switch, with the remaining count on Details and the publish controls inline. Supporting changes: completion.ts derives what is missing from the zod schema without touching form errors; PublishStatusPill; PublishButton gains requireComplete and reads Update for published works; the funding section is split into FundingOpportunityField, FundingGoalField and a FundingGoalSection with a shared useSelectedGrant; sections take a className so they render bare in a popover; PublishingForm gains showFooter; NotebookTabs loses its labels prop and the header its "Esc to close" hint. Verified with type-check, eslint and prettier only. Co-Authored-By: Claude Fable 5.1 --- components/AIMode/AIModeOverlay.tsx | 38 ++- components/AIMode/DocumentPane.tsx | 265 ++++++++++++------ .../AIMode/document/PublishControls.tsx | 22 ++ .../document/details/AllDetailsBlock.tsx | 64 +++++ .../AIMode/document/details/DetailBlock.tsx | 139 +++++++++ .../document/details/DetailBlockStrip.tsx | 96 +++++++ .../details/SelectGrantForNoteModal.tsx | 24 ++ .../AIMode/document/details/detailBlocks.ts | 138 +++++++++ components/Notebook/NotebookTabs.tsx | 6 +- .../Notebook/PublishingForm/PublishButton.tsx | 20 +- .../PublishingForm/PublishStatusPill.tsx | 93 ++++++ .../Notebook/PublishingForm/completion.ts | 43 +++ .../components/AuthorsSection.tsx | 6 +- .../components/ContactsSection.tsx | 6 +- .../components/FundingGoalField.tsx | 40 +++ .../components/FundingGoalSection.tsx | 15 + .../components/FundingOpportunityField.tsx | 93 ++++++ .../components/FundingSection.tsx | 139 +-------- .../components/GrantDescriptionSection.tsx | 6 +- .../components/GrantFundingAmountSection.tsx | 6 +- .../PublishingForm/components/SectionProps.ts | 4 + .../components/WorkImageSection.tsx | 6 +- components/Notebook/PublishingForm/index.tsx | 24 +- .../PublishingForm/useSelectedGrant.ts | 34 +++ hooks/useFittingCount.ts | 69 +++++ 25 files changed, 1135 insertions(+), 261 deletions(-) create mode 100644 components/AIMode/document/PublishControls.tsx create mode 100644 components/AIMode/document/details/AllDetailsBlock.tsx create mode 100644 components/AIMode/document/details/DetailBlock.tsx create mode 100644 components/AIMode/document/details/DetailBlockStrip.tsx create mode 100644 components/AIMode/document/details/SelectGrantForNoteModal.tsx create mode 100644 components/AIMode/document/details/detailBlocks.ts create mode 100644 components/Notebook/PublishingForm/PublishStatusPill.tsx create mode 100644 components/Notebook/PublishingForm/completion.ts create mode 100644 components/Notebook/PublishingForm/components/FundingGoalField.tsx create mode 100644 components/Notebook/PublishingForm/components/FundingGoalSection.tsx create mode 100644 components/Notebook/PublishingForm/components/FundingOpportunityField.tsx create mode 100644 components/Notebook/PublishingForm/components/SectionProps.ts create mode 100644 components/Notebook/PublishingForm/useSelectedGrant.ts create mode 100644 hooks/useFittingCount.ts diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx index c72baac15..b49391ec0 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/AIModeOverlay.tsx @@ -12,9 +12,8 @@ import { useAIMode } from './AIModeContext'; import { ChatPane } from './ChatPane'; import { ConversationList } from './ConversationList'; import { DocumentCard } from './DocumentCard'; -import { DocumentPane } from './DocumentPane'; +import { DocumentPane, type DocumentPaneView } from './DocumentPane'; import { useAIModeChat } from './useAIModeChat'; -import type { NotebookTab } from '@/components/Notebook/NotebookTabs'; import type { PublishingDefaultArticleType } from '@/contexts/PublishingHostContext'; import { useAIModeDocument } from './useAIModeDocument'; import { AI_MODE_NAME } from './copy'; @@ -92,8 +91,8 @@ export function AIModeOverlay() { anchor: 'left', }); // Document or details in the right pane; details wants a wider floor. - const [documentTab, setDocumentTab] = useState('document'); - const documentMinWidth = documentTab === 'details' ? DETAILS_MIN_WIDTH : DOCUMENT_MIN_WIDTH; + const [documentView, setDocumentView] = useState('document'); + const documentMinWidth = documentView === 'details' ? DETAILS_MIN_WIDTH : DOCUMENT_MIN_WIDTH; // The document may grow until the chat is down to its minimum column. const documentMaxWidth = Math.max( documentMinWidth, @@ -117,7 +116,7 @@ export function AIModeOverlay() { const [documentOpen, setDocumentOpen] = useState(false); useEffect(() => { setDocumentOpen(noteId != null && !isBelowTabletRef.current); - setDocumentTab('document'); + setDocumentView('document'); }, [noteId]); // What the conversation set out to write, from its opening message, so the @@ -132,6 +131,11 @@ export function AIModeOverlay() { const openDocument = useCallback(() => setDocumentOpen(true), []); const closeDocument = useCallback(() => setDocumentOpen(false), []); const showDocument = noteId != null && documentOpen; + const documentTitle = doc.content?.title?.trim() || state.note?.title?.trim() || 'Document'; + + // The document column puts its publish controls up in the header, where + // they read as the workspace's, not the pane's. It renders into this slot. + const [publishControlsSlot, setPublishControlsSlot] = useState(null); // The turn that created the document, for seating its card in the transcript. const documentCardExecutionId = useMemo(() => { @@ -151,7 +155,7 @@ export function AIModeOverlay() { const documentCard = noteId != null ? (