diff --git a/components/Notebook/NoteEditorLayout.tsx b/components/Notebook/NoteEditorLayout.tsx index 123d05a48..eb196626d 100644 --- a/components/Notebook/NoteEditorLayout.tsx +++ b/components/Notebook/NoteEditorLayout.tsx @@ -31,9 +31,10 @@ import { useDismissableFeature } from '@/hooks/useDismissableFeature'; import { FeatureFlag, isFeatureEnabled } from '@/utils/featureFlags'; import { LegacyNoteBanner } from '@/components/LegacyNoteBanner'; import { + getNoteKind, isChangelogNote, isPublishedRegisteredReportNote, - isRegisteredReportNote, + NOTE_KIND_LABELS, } from '@/types/note'; // Persisted (per-user) flag so the guided tour auto-runs only once — the very @@ -45,30 +46,6 @@ const NOTEBOOK_TOUR_FEATURE = 'notebook_tour'; // one), which is the only moment we want to auto-launch the tour. const NEW_NOTE_PARAMS = ['newChangelog', 'newGrant', 'newFunding', 'template']; -// Friendly label for the note's work type, shown at the top-left of the doc. -function getWorkTypeLabel( - documentType?: string | null, - contentType?: string | null, - isRegisteredReport?: boolean -): string | undefined { - if (isRegisteredReport) { - return 'Registered Report'; - } - - switch (documentType) { - case 'GRANT': - return 'Request for Proposal'; - case 'PREREGISTRATION': - return 'Proposal'; - case 'DISCUSSION': - return 'Preprint'; - } - if (contentType === 'funding_request') return 'Request for Proposal'; - if (contentType === 'preregistration') return 'Proposal'; - if (contentType) return 'Preprint'; - return undefined; -} - interface NoteEditorLayoutProps { /** * Fires when the assistant docks or undocks. Docking is decided here — it @@ -238,9 +215,12 @@ export function NoteEditorLayout({ onAgentChatDockedChange }: NoteEditorLayoutPr const isPublishedRegisteredReport = isPublishedRegisteredReportNote(note); const isEditorReadOnly = isPublishedRegisteredReport || (isLegacyNote && isFeatureEnabled(FeatureFlag.LegacyNoteBanner)); + const noteKind = getNoteKind(note); const workTypeLabel = isChangelog ? 'ChangeLog' - : getWorkTypeLabel(note?.documentType, note?.post?.contentType, isRegisteredReportNote(note)); + : noteKind === 'other' + ? undefined + : NOTE_KIND_LABELS[noteKind]; const renderEditor = () => { // No note is targeted (notebook home) — render the landing view directly so diff --git a/contexts/NotebookContext.tsx b/contexts/NotebookContext.tsx index 048bccc08..d5e76d8ec 100644 --- a/contexts/NotebookContext.tsx +++ b/contexts/NotebookContext.tsx @@ -18,6 +18,7 @@ import type { ID } from '@/types/root'; import type { OrganizationUsers } from '@/types/organization'; import { useOrganizationContext } from './OrganizationContext'; import { useNoteDetailsSaver, type NoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; +import { useOrganizationNotes } from '@/hooks/useOrganizationNotes'; import { Editor } from '@tiptap/core'; import { useParams } from 'next/navigation'; @@ -68,9 +69,6 @@ interface NotebookContextType { const NotebookContext = createContext(null); -const mergeNotesById = (notes: Note[], otherNotes: Note[]): Note[] => - Array.from(new Map([...notes, ...otherNotes].map((note) => [note.id, note])).values()); - interface NotebookProviderProps { readonly children: ReactNode; readonly noteId?: string; @@ -82,13 +80,9 @@ export function NotebookProvider({ children, noteId: explicitNoteId }: NotebookP const { selectedOrg, isLoading: isLoadingOrg } = useOrganizationContext(); - // Notes list state - const [notes, setNotes] = useState([]); - const [isLoadingNotes, setIsLoadingNotes] = useState(true); - const [isLoadingMoreNotes, setIsLoadingMoreNotes] = useState(false); - const [notesError, setNotesError] = useState(null); - const [totalCount, setTotalCount] = useState(0); - const [nextPageUrls, setNextPageUrls] = useState([]); + // The organization's notes, loaded as soon as the organization is known. + const notesList = useOrganizationNotes(selectedOrg?.slug, { waiting: isLoadingOrg }); + const { setNotes, refresh: refreshNotes } = notesList; // Organization users state const [users, setUsers] = useState(null); @@ -106,40 +100,6 @@ export function NotebookProvider({ children, noteId: explicitNoteId }: NotebookP const { saveDetailsSoon, saveDetailsNow } = useNoteDetailsSaver(currentNote?.id); - const fetchNotes = useCallback(async (slug?: string) => { - if (!slug) { - setNotesError(new Error('No organization slug provided')); - return; - } - - setIsLoadingNotes(true); - setIsLoadingMoreNotes(false); - setNotesError(null); - setNextPageUrls([]); - - try { - const [organizationNotes, registeredReports] = await Promise.all([ - NoteService.getOrganizationNotes(slug), - NoteService.getOrganizationNotes(slug, { - documentType: 'REGISTERED_REPORT', - }), - ]); - const mergedNotes = mergeNotesById(organizationNotes.results, registeredReports.results); - - setNotes(mergedNotes); - setTotalCount(Math.max(organizationNotes.count, mergedNotes.length)); - setNextPageUrls( - [organizationNotes.next, registeredReports.next].filter((url) => url !== null) - ); - } catch (err) { - setNotesError(err instanceof Error ? err : new Error('Failed to load notes')); - setNotes([]); - setTotalCount(0); - } finally { - setIsLoadingNotes(false); - } - }, []); - const fetchUsers = useCallback(async (orgId: string, silently = false) => { if (!silently) { setIsLoadingUsers(true); @@ -173,51 +133,6 @@ export function NotebookProvider({ children, noteId: explicitNoteId }: NotebookP [selectedOrg?.id, fetchUsers] ); - const refreshNotes = useCallback(async () => { - if (!selectedOrg?.slug) { - setNotesError(new Error('No organization slug provided')); - return; - } - await fetchNotes(selectedOrg.slug); - }, [selectedOrg?.slug, fetchNotes]); - - const loadMoreNotes = () => { - if (isLoadingMoreNotes || nextPageUrls.length === 0) return; - - setNotesError(null); - setIsLoadingMoreNotes(true); - }; - - useEffect(() => { - const slug = selectedOrg?.slug; - if (!slug || !isLoadingMoreNotes || nextPageUrls.length === 0) return; - - let cancelled = false; - - const fetchNextNotes = async () => { - try { - const nextPages = await Promise.all( - nextPageUrls.map((nextUrl) => NoteService.getOrganizationNotes(slug, { nextUrl })) - ); - if (cancelled) return; - - const newNotes = nextPages.flatMap((page) => page.results); - setNotes((currentNotes) => mergeNotesById(currentNotes, newNotes)); - setNextPageUrls(nextPages.flatMap(({ next }) => (next ? [next] : []))); - } catch (err) { - if (cancelled) return; - setNotesError(err instanceof Error ? err : new Error('Failed to load more notes')); - } finally { - if (!cancelled) setIsLoadingMoreNotes(false); - } - }; - - void fetchNextNotes(); - return () => { - cancelled = true; - }; - }, [isLoadingMoreNotes, nextPageUrls, selectedOrg?.slug]); - const loadNote = useCallback(async (noteId: string) => { if (noteId === lastLoadedNoteIdRef.current) { return; @@ -266,38 +181,30 @@ export function NotebookProvider({ children, noteId: explicitNoteId }: NotebookP const refreshAll = useCallback(async () => { if (!selectedOrg?.slug || !selectedOrg?.id) return; - const promises = [fetchNotes(selectedOrg.slug), fetchUsers(selectedOrg.id.toString())]; + const promises = [refreshNotes(), fetchUsers(selectedOrg.id.toString())]; if (activeNoteId) { promises.push(loadNote(activeNoteId)); } await Promise.all(promises); - }, [selectedOrg?.slug, selectedOrg?.id, activeNoteId, fetchNotes, fetchUsers, loadNote]); + }, [selectedOrg?.slug, selectedOrg?.id, activeNoteId, refreshNotes, fetchUsers, loadNote]); - // Initial data loading when organization changes + // Users load when the organization changes; the notes list does the same on its own. useEffect(() => { if (isLoadingOrg) { - setIsLoadingNotes(true); setIsLoadingUsers(true); return; } if (!selectedOrg) { - setNotes([]); - setTotalCount(0); - setIsLoadingMoreNotes(false); - setNextPageUrls([]); setUsers(null); - setNotesError(null); setUsersError(null); - setIsLoadingNotes(false); setIsLoadingUsers(false); return; } - fetchNotes(selectedOrg.slug); fetchUsers(selectedOrg.id.toString()); - }, [selectedOrg?.slug, selectedOrg?.id, isLoadingOrg, fetchNotes, fetchUsers]); + }, [selectedOrg?.id, isLoadingOrg, fetchUsers]); useEffect(() => { if (activeNoteId) { @@ -306,19 +213,18 @@ export function NotebookProvider({ children, noteId: explicitNoteId }: NotebookP }, [activeNoteId, loadNote]); // Calculate overall loading state ignoring isLoadingNote - const isLoading = isLoadingNotes || isLoadingUsers || isLoadingOrg; - const hasMoreNotes = nextPageUrls.length > 0; + const isLoading = notesList.isLoading || isLoadingUsers || isLoadingOrg; const value = { - notes, + notes: notesList.notes, setNotes, - isLoadingNotes, - isLoadingMoreNotes, - notesError, - totalCount, - hasMoreNotes, + isLoadingNotes: notesList.isLoading, + isLoadingMoreNotes: notesList.isLoadingMore, + notesError: notesList.error, + totalCount: notesList.totalCount, + hasMoreNotes: notesList.hasMore, refreshNotes, - loadMoreNotes, + loadMoreNotes: notesList.loadMore, users, isLoadingUsers, usersError, diff --git a/hooks/useOrganizationNotes.ts b/hooks/useOrganizationNotes.ts new file mode 100644 index 000000000..d638b4eb7 --- /dev/null +++ b/hooks/useOrganizationNotes.ts @@ -0,0 +1,144 @@ +'use client'; + +import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'; +import { NoteService } from '@/services/note.service'; +import type { Note } from '@/types/note'; + +export interface OrganizationNotes { + readonly notes: Note[]; + /** For a host that patches a row in place, e.g. a title just saved. */ + readonly setNotes: Dispatch>; + readonly isLoading: boolean; + readonly isLoadingMore: boolean; + readonly error: Error | null; + readonly totalCount: number; + readonly hasMore: boolean; + readonly refresh: () => Promise; + readonly loadMore: () => void; +} + +interface UseOrganizationNotesOptions { + /** + * The organization is still being resolved: report loading and fetch + * nothing yet, rather than showing an empty list that is about to fill. + */ + readonly waiting?: boolean; +} + +const mergeNotesById = (notes: Note[], otherNotes: Note[]): Note[] => + Array.from(new Map([...notes, ...otherNotes].map((note) => [note.id, note])).values()); + +/** + * The notes of an organization, as the notebook lists them: every note the + * user can see, with the Registered Reports the plain listing leaves out + * fetched alongside and merged in. Both streams page independently, so + * loading more advances whichever still has pages. + */ +export function useOrganizationNotes( + orgSlug: string | null | undefined, + { waiting = false }: UseOrganizationNotesOptions = {} +): OrganizationNotes { + const [notes, setNotes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [totalCount, setTotalCount] = useState(0); + const [nextPageUrls, setNextPageUrls] = useState([]); + + const fetchNotes = useCallback(async (slug: string) => { + setIsLoading(true); + setIsLoadingMore(false); + setError(null); + setNextPageUrls([]); + + try { + const [organizationNotes, registeredReports] = await Promise.all([ + NoteService.getOrganizationNotes(slug), + NoteService.getOrganizationNotes(slug, { documentType: 'REGISTERED_REPORT' }), + ]); + const mergedNotes = mergeNotesById(organizationNotes.results, registeredReports.results); + + setNotes(mergedNotes); + setTotalCount(Math.max(organizationNotes.count, mergedNotes.length)); + setNextPageUrls( + [organizationNotes.next, registeredReports.next].filter((url) => url !== null) + ); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to load notes')); + setNotes([]); + setTotalCount(0); + } finally { + setIsLoading(false); + } + }, []); + + const refresh = useCallback(async () => { + if (!orgSlug) { + setError(new Error('No organization slug provided')); + return; + } + await fetchNotes(orgSlug); + }, [orgSlug, fetchNotes]); + + const loadMore = useCallback(() => { + if (isLoadingMore || nextPageUrls.length === 0) return; + setError(null); + setIsLoadingMore(true); + }, [isLoadingMore, nextPageUrls.length]); + + useEffect(() => { + if (!orgSlug || !isLoadingMore || nextPageUrls.length === 0) return; + + let cancelled = false; + + const fetchNextNotes = async () => { + try { + const nextPages = await Promise.all( + nextPageUrls.map((nextUrl) => NoteService.getOrganizationNotes(orgSlug, { nextUrl })) + ); + if (cancelled) return; + + const newNotes = nextPages.flatMap((page) => page.results); + setNotes((currentNotes) => mergeNotesById(currentNotes, newNotes)); + setNextPageUrls(nextPages.flatMap(({ next }) => (next ? [next] : []))); + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err : new Error('Failed to load more notes')); + } finally { + if (!cancelled) setIsLoadingMore(false); + } + }; + + void fetchNextNotes(); + return () => { + cancelled = true; + }; + }, [isLoadingMore, nextPageUrls, orgSlug]); + + // Load when the organization is known; clear when there is none. + useEffect(() => { + if (waiting) return; + if (!orgSlug) { + setNotes([]); + setTotalCount(0); + setIsLoadingMore(false); + setNextPageUrls([]); + setError(null); + setIsLoading(false); + return; + } + void fetchNotes(orgSlug); + }, [orgSlug, waiting, fetchNotes]); + + return { + notes, + setNotes, + isLoading: waiting || isLoading, + isLoadingMore, + error, + totalCount, + hasMore: nextPageUrls.length > 0, + refresh, + loadMore, + }; +} diff --git a/types/note.ts b/types/note.ts index aded49712..70aecbc23 100644 --- a/types/note.ts +++ b/types/note.ts @@ -325,6 +325,9 @@ const isRegisteredReportDocumentType = (documentType?: string | null): boolean = const isGrantDocumentType = (documentType?: string | null): boolean => documentType?.trim().toUpperCase() === 'GRANT'; +const isPreregistrationDocumentType = (documentType?: string | null): boolean => + documentType?.trim().toUpperCase() === 'PREREGISTRATION'; + const serializeNoteJson = (value: unknown): string | undefined => { if (typeof value === 'string') return value; if (!value || typeof value !== 'object') return undefined; @@ -428,6 +431,37 @@ export const isRfpNote = (note?: ClassifiableNote | null): boolean => isGrantDocumentType(note?.post?.documentType) || note?.post?.contentType === 'funding_request'; +/** A research proposal — the researcher's ask, which may answer an RFP. */ +export const isProposalNote = (note?: ClassifiableNote | null): boolean => + isPreregistrationDocumentType(note?.documentType) || + isPreregistrationDocumentType(note?.post?.documentType) || + note?.post?.contentType === 'preregistration'; + +/** + * What a note is, for anything that shows notes by kind. A Registered Report + * comes first because it carries a proposal's type underneath; `preprint` + * is a note that was published as an ordinary post; `other` has no type + * yet. ChangeLogs need the note's date and stay with `isChangelogNote`. + */ +export type NoteKind = 'proposal' | 'rfp' | 'registered_report' | 'preprint' | 'other'; + +export const getNoteKind = (note?: ClassifiableNote | null): NoteKind => { + if (!note) return 'other'; + if (isRegisteredReportNote(note)) return 'registered_report'; + if (isRfpNote(note)) return 'rfp'; + if (isProposalNote(note)) return 'proposal'; + if (note.documentType?.trim().toUpperCase() === 'DISCUSSION' || note.post) return 'preprint'; + return 'other'; +}; + +/** The kind's name as the editor shows it above the document. */ +export const NOTE_KIND_LABELS: Record, string> = { + proposal: 'Proposal', + rfp: 'Request for Proposal', + registered_report: 'Registered Report', + preprint: 'Preprint', +}; + /** Uses exact legacy IDs because ordinary preprints also used DISCUSSION before rollout. */ export const isChangelogNote = (note?: ClassifiableChangelogNote | null): boolean => { if (!note || isRegisteredReportNote(note)) return false;