From d41d015ffaed8f989d30917c768c46687582e40a Mon Sep 17 00:00:00 2001 From: rtBot <43742164+rtBot@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:56:46 +0000 Subject: [PATCH 1/6] feat(notes): add blank note editor with create + edit flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the "New blank note" dropdown option to a full-page editor view that creates Project Status Updates via the BE `create_project_status_update` endpoint, and re-instates the per-card ellipsis dropdown with an "Edit" option that opens the same editor in update mode (`update_project_status_update`). The editor hides the "About this project" sidebar and the project tabs strip for a distraction-free authoring experience, leaving only the breadcrumb + sub-header (avatar + name + "Save note") and a TextEditor workspace with placeholder text. Also fixes a pre-existing tab-order mismatch in `tabs/index.tsx` where TAB_KEYS had "notes" at index 3 but TABS had Risks at index 3 — `?tab=notes` rendered Risks content and clicks on the Notes tab generated `?tab=risks` URLs. Reordering TABS to match TAB_KEYS unbreaks the redirect target this PR depends on and the view-selector dropdown on the Risks tab. Fixes #1097 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../project-details/note-editor/constants.ts | 13 ++ .../project-details/note-editor/index.tsx | 139 ++++++++++++++++++ .../project-details/note-editor/subHeader.tsx | 42 ++++++ .../project-details/note-editor/types.ts | 6 + .../note-editor/useNoteDetail.ts | 27 ++++ .../note-editor/useNoteMutation.ts | 45 ++++++ .../project-details/note-editor/workspace.tsx | 64 ++++++++ .../src/pages/project-details/tabs/index.tsx | 5 +- .../project-details/tabs/notes/noteCard.tsx | 32 +++- frontend/packages/app/src/route.tsx | 10 +- 10 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 frontend/packages/app/src/pages/project-details/note-editor/constants.ts create mode 100644 frontend/packages/app/src/pages/project-details/note-editor/index.tsx create mode 100644 frontend/packages/app/src/pages/project-details/note-editor/subHeader.tsx create mode 100644 frontend/packages/app/src/pages/project-details/note-editor/types.ts create mode 100644 frontend/packages/app/src/pages/project-details/note-editor/useNoteDetail.ts create mode 100644 frontend/packages/app/src/pages/project-details/note-editor/useNoteMutation.ts create mode 100644 frontend/packages/app/src/pages/project-details/note-editor/workspace.tsx diff --git a/frontend/packages/app/src/pages/project-details/note-editor/constants.ts b/frontend/packages/app/src/pages/project-details/note-editor/constants.ts new file mode 100644 index 000000000..7c2d86c42 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/note-editor/constants.ts @@ -0,0 +1,13 @@ +import { ROUTES } from "@/lib/constant"; + +export const NOTE_EDITOR_NEW_PATH = `${ROUTES.project}/:projectId/notes/new`; +export const NOTE_EDITOR_EDIT_PATH = `${ROUTES.project}/:projectId/notes/:noteId/edit`; + +export const buildNewNotePath = (projectId: string) => + `${ROUTES.project}/${projectId}/notes/new`; + +export const buildEditPath = (projectId: string, noteId: string) => + `${ROUTES.project}/${projectId}/notes/${encodeURIComponent(noteId)}/edit`; + +export const buildNotesGridPath = (projectId: string) => + `${ROUTES.project}/${projectId}?tab=notes`; diff --git a/frontend/packages/app/src/pages/project-details/note-editor/index.tsx b/frontend/packages/app/src/pages/project-details/note-editor/index.tsx new file mode 100644 index 000000000..b76add9d4 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/note-editor/index.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ErrorFallback, Spinner } from "@next-pms/design-system/components"; +import { useToasts } from "@rtcamp/frappe-ui-react"; +import type { FrappeError } from "frappe-react-sdk"; + +import { parseFrappeErrorMsg } from "@/lib/utils"; +import { ProjectDetailHeader } from "@/pages/project-details/header"; +import { ProjectDetailProvider } from "@/pages/project-details/provider"; +import { useUser } from "@/providers/user"; +import { buildNotesGridPath } from "./constants"; +import { NoteEditorSubHeader } from "./subHeader"; +import type { NoteEditorMode } from "./types"; +import { useNoteDetail } from "./useNoteDetail"; +import { useNoteMutation } from "./useNoteMutation"; +import { NoteEditorWorkspace } from "./workspace"; + +type NoteEditorProps = { + mode: NoteEditorMode; +}; + +export function NoteEditor({ mode }: NoteEditorProps) { + const { projectId = "", noteId } = useParams<{ + projectId: string; + noteId?: string; + }>(); + + return ( + +
+ +
+
+ + + +
+
+
+
+ ); +} + +type NoteEditorBodyProps = { + mode: NoteEditorMode; + projectId: string; + noteId: string | undefined; +}; + +/** + * Defers Workspace mount until edit-mode data has arrived so the underlying + * TextEditor captures the loaded description on its first render (tiptap only + * reads `content` on mount; subsequent prop changes are ignored). + */ +function NoteEditorGate({ mode, projectId, noteId }: NoteEditorBodyProps) { + const { note, isLoading, error } = useNoteDetail( + mode === "edit" ? noteId : undefined, + ); + + if (error) throw error; + + if (mode === "edit" && (isLoading || !note)) { + return ; + } + + return ( + + ); +} + +type NoteEditorBodyInnerProps = NoteEditorBodyProps & { + initialTitle: string; + initialDescription: string; +}; + +function NoteEditorBody({ + mode, + projectId, + noteId, + initialTitle, + initialDescription, +}: NoteEditorBodyInnerProps) { + const navigate = useNavigate(); + const toast = useToasts(); + const userName = useUser((s) => s.state.userName); + const userImage = useUser((s) => s.state.image); + + const [title, setTitle] = useState(initialTitle); + const [description, setDescription] = useState(initialDescription); + + const { save, isSubmitting } = useNoteMutation( + mode === "edit" && noteId + ? { mode: "edit", projectId, noteId } + : { mode: "new", projectId }, + ); + + const canSave = title.trim().length > 0; + + const handleSave = async () => { + try { + await save({ title, description }); + toast.success("Note saved"); + navigate(buildNotesGridPath(projectId)); + } catch (err) { + toast.error(parseFrappeErrorMsg(err as FrappeError)); + } + }; + + return ( +
+ + +
+ ); +} + +export default NoteEditor; diff --git a/frontend/packages/app/src/pages/project-details/note-editor/subHeader.tsx b/frontend/packages/app/src/pages/project-details/note-editor/subHeader.tsx new file mode 100644 index 000000000..7900ba171 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/note-editor/subHeader.tsx @@ -0,0 +1,42 @@ +import { Avatar, Button } from "@rtcamp/frappe-ui-react"; + +type NoteEditorSubHeaderProps = { + userName: string; + userImage: string; + canSave: boolean; + isSubmitting: boolean; + onSave: () => void; +}; + +export function NoteEditorSubHeader({ + userName, + userImage, + canSave, + isSubmitting, + onSave, +}: NoteEditorSubHeaderProps) { + return ( +
+
+ + + {userName} + +
+
+ ); +} diff --git a/frontend/packages/app/src/pages/project-details/note-editor/types.ts b/frontend/packages/app/src/pages/project-details/note-editor/types.ts new file mode 100644 index 000000000..a047e7bdb --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/note-editor/types.ts @@ -0,0 +1,6 @@ +export type NoteEditorMode = "new" | "edit"; + +export type NoteDraft = { + title: string; + description: string; +}; diff --git a/frontend/packages/app/src/pages/project-details/note-editor/useNoteDetail.ts b/frontend/packages/app/src/pages/project-details/note-editor/useNoteDetail.ts new file mode 100644 index 000000000..efd03c404 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/note-editor/useNoteDetail.ts @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import { useFrappeGetCall } from "frappe-react-sdk"; + +import type { Note } from "@/pages/project-details/tabs/notes/types"; + +const NOTE_DETAIL_API = + "next_pms.timesheet.api.project_status_update.get_project_status_update"; + +export function useNoteDetail(noteId: string | undefined) { + const params = useMemo(() => ({ name: noteId ?? "" }), [noteId]); + const swrKey = useMemo( + () => (noteId ? `note-detail-${noteId}` : null), + [noteId], + ); + + const { data, isLoading, error } = useFrappeGetCall<{ message: Note }>( + NOTE_DETAIL_API, + params, + swrKey, + ); + + return { + note: data?.message, + isLoading: noteId ? isLoading : false, + error, + }; +} diff --git a/frontend/packages/app/src/pages/project-details/note-editor/useNoteMutation.ts b/frontend/packages/app/src/pages/project-details/note-editor/useNoteMutation.ts new file mode 100644 index 000000000..e85d940b1 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/note-editor/useNoteMutation.ts @@ -0,0 +1,45 @@ +import { useCallback } from "react"; +import { useFrappePostCall } from "frappe-react-sdk"; + +import type { Note } from "@/pages/project-details/tabs/notes/types"; +import type { NoteDraft, NoteEditorMode } from "./types"; + +const CREATE_API = + "next_pms.timesheet.api.project_status_update.create_project_status_update"; +const UPDATE_API = + "next_pms.timesheet.api.project_status_update.update_project_status_update"; + +type Params = + | { mode: "new"; projectId: string; noteId?: undefined } + | { mode: "edit"; projectId: string; noteId: string }; + +type SaveResult = { message: Note }; + +export function useNoteMutation({ mode, projectId, noteId }: Params) { + const { call: createCall, loading: isCreating } = + useFrappePostCall(CREATE_API); + const { call: updateCall, loading: isUpdating } = + useFrappePostCall(UPDATE_API); + + const save = useCallback( + async (draft: NoteDraft): Promise => { + const payload = { + title: draft.title.trim(), + description: draft.description, + }; + if (mode === "new") { + const res = await createCall({ + project: projectId, + ...payload, + status: "Publish", + }); + return res.message; + } + const res = await updateCall({ name: noteId, ...payload }); + return res.message; + }, + [mode, projectId, noteId, createCall, updateCall], + ); + + return { save, isSubmitting: isCreating || isUpdating }; +} diff --git a/frontend/packages/app/src/pages/project-details/note-editor/workspace.tsx b/frontend/packages/app/src/pages/project-details/note-editor/workspace.tsx new file mode 100644 index 000000000..0e5e4be65 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/note-editor/workspace.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from "react"; +import { TextEditor } from "@rtcamp/frappe-ui-react"; + +import type { NoteEditorMode } from "./types"; + +type NoteEditorWorkspaceProps = { + mode: NoteEditorMode; + title: string; + description: string; + onTitleChange: (value: string) => void; + onDescriptionChange: (value: string) => void; +}; + +export function NoteEditorWorkspace({ + mode, + title, + description, + onTitleChange, + onDescriptionChange, +}: NoteEditorWorkspaceProps) { + const titleRef = useRef(null); + + useEffect(() => { + if (mode === "new" && titleRef.current) { + titleRef.current.focus(); + } + }, [mode]); + + useEffect(() => { + const el = titleRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [title]); + + return ( +
+