Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 55 additions & 27 deletions frontend/packages/app/src/pages/project-details/index.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,82 @@
/**
* External dependencies.
*/
import { useParams, useSearchParams } from "react-router-dom";
import { Tabs } from "@rtcamp/frappe-ui-react";
import { Suspense } from "react";
import {
Outlet,
useLocation,
useMatch,
useNavigate,
useParams,
} from "react-router-dom";
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import { mergeClassNames } from "@next-pms/design-system";
import { Spinner } from "@next-pms/design-system/components";
import { TabList } from "@rtcamp/frappe-ui-react";

/**
* Internal dependencies.
*/
import { ROUTES } from "@/lib/constant";
import { AboutThisProject } from "./about";
import { ProjectDetailHeader } from "./header";
import { ProjectDetailProvider } from "./provider";
import { TAB_KEYS, TABS, type TabKey } from "./tabs";
import { TAB_KEYS, TAB_NAV, type TabKey } from "./tabs/constants";
import { NotesProvider } from "./tabs/notes/provider";

const TAB_PARAM = "tab";
const DEFAULT_TAB: TabKey = TAB_KEYS[0];

function ProjectDetail() {
const { projectId = "" } = useParams<{ projectId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const navigate = useNavigate();
const newMatch = useMatch(`${ROUTES.project}/:projectId/notes/new`);
const editMatch = useMatch(`${ROUTES.project}/:projectId/notes/:noteId/edit`);
const editorMatch = newMatch ?? editMatch;

const paramTab = searchParams.get(TAB_PARAM) as TabKey | null;
const activeKey: TabKey =
paramTab && TAB_KEYS.includes(paramTab) ? paramTab : DEFAULT_TAB;
const activeTab = TAB_KEYS.indexOf(activeKey);
const afterProjectId =
pathname.split(`${ROUTES.project}/${projectId}/`)[1] ?? "";
const tabSegment = afterProjectId.split("/")[0] as TabKey;
const activeTab = TAB_KEYS.includes(tabSegment)
? TAB_KEYS.indexOf(tabSegment)
: TAB_KEYS.indexOf(DEFAULT_TAB);

const handleTabChange = (index: number) => {
const key = TAB_KEYS[index];
setSearchParams((prev) => {
if (!key || key === DEFAULT_TAB) prev.delete(TAB_PARAM);
else prev.set(TAB_PARAM, key);
return prev;
});
if (key) navigate(`${ROUTES.project}/${projectId}/${key}`);
};

return (
<ProjectDetailProvider projectId={projectId}>
<div className="h-full flex flex-col">
<ProjectDetailHeader />
<div className="flex flex-1 min-h-0">
<Tabs
tabListClassName="h-10"
tabPanelClassName="overflow-auto scrollbar-thin"
className="w-3/4 border-0 rounded-none border-r"
tabs={TABS}
tabIndex={activeTab}
onTabChange={handleTabChange}
/>
<AboutThisProject className="w-1/4" />
<NotesProvider>
<div className="h-full flex flex-col">
<ProjectDetailHeader />
<div className="flex flex-1 min-h-0">
<div
className={mergeClassNames(
"flex flex-col border-r",
editorMatch ? "w-full" : "w-3/4",
)}
>
<BaseTabs.Root
value={TAB_NAV[activeTab]?.label ?? TAB_NAV[0].label}
onValueChange={(value) => {
const index = TAB_NAV.findIndex((t) => t.label === value);
if (index !== -1) handleTabChange(index);
}}
>
<TabList tabs={TAB_NAV} className="h-10" />
</BaseTabs.Root>
<Suspense fallback={<Spinner className="py-10" />}>
<div className="overflow-auto scrollbar-thin flex-1 px-5 py-4">
<Outlet />
</div>
</Suspense>
</div>
{!editorMatch && <AboutThisProject className="w-1/4" />}
</div>
</div>
</div>
</NotesProvider>
</ProjectDetailProvider>
);
}
Expand Down
26 changes: 26 additions & 0 deletions frontend/packages/app/src/pages/project-details/tabs/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ComponentProps } from "react";
import { TabList } from "@rtcamp/frappe-ui-react";

export const TAB_KEYS = [
"overview",
"calendar",
"tracking",
"risks",
"notes",
"email",
"to-do",
"feedback",
] as const;

export type TabKey = (typeof TAB_KEYS)[number];

export const TAB_NAV: ComponentProps<typeof TabList>["tabs"] = [
{ label: "Overview", content: null },
{ label: "Calendar", content: null },
{ label: "Tracking", content: null },
{ label: "Risks", content: null },
{ label: "Notes", content: null },
{ label: "Email", content: null },
{ label: "To-do", content: null },
{ label: "Feedback", content: null },
];
35 changes: 0 additions & 35 deletions frontend/packages/app/src/pages/project-details/tabs/index.tsx

This file was deleted.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sidebar is not visible for this page. Update about/index to look at current location for rendering

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e88cf7e — editor%2Findex.tsx (sidebar self-hides via useMatch in about/index.tsx).

Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* External dependencies.
*/
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Spinner } from "@next-pms/design-system/components";
import {
Avatar,
Button,
ErrorMessage,
TextEditor,
useToasts,
} from "@rtcamp/frappe-ui-react";
import { useForm } from "@tanstack/react-form";
import {
FrappeError,
useFrappeGetCall,
useFrappePostCall,
} from "frappe-react-sdk";

/**
* Internal dependencies.
*/
import { ROUTES } from "@/lib/constant";
import { parseFrappeErrorMsg } from "@/lib/utils";
import { useProjectDetail } from "@/pages/project-details/context";
import { useUser } from "@/providers/user";
import { noteFormSchema } from "./schema";
import { useNotes } from "../context";

export function NoteEditor() {
const navigate = useNavigate();
const { projectId: routeProjectId = "", noteId } = useParams<{
projectId: string;
noteId?: string;
}>();
const mode: "edit" | "new" = noteId ? "edit" : "new";
const userName = useUser((s) => s.state.userName);
const userImage = useUser((s) => s.state.image);
const projectId = useProjectDetail((s) => s.projectId);
const refresh = useNotes((s) => s.actions.refresh);
const toast = useToasts();
const [isFormInitialized, setIsFormInitialized] = useState(false);

const { call: createNote, loading: isCreating } = useFrappePostCall(
"next_pms.timesheet.api.project_status_update.create_project_status_update",
);
const { call: updateNote, loading: isUpdating } = useFrappePostCall(
"next_pms.timesheet.api.project_status_update.update_project_status_update",
);
const { data: noteData, isLoading: isNoteLoading } = useFrappeGetCall(
"next_pms.timesheet.api.project_status_update.get_project_status_update",
{ name: noteId },
mode === "edit" && noteId ? undefined : null,
);

const form = useForm({
defaultValues: {
project: projectId,
title: "",
description: "",
status: "Publish",
},
validators: {
onSubmit: noteFormSchema,
},
onSubmit: async ({ value }) => {
try {
const payload = {
title: value.title,
description: value.description,
status: value.status,
};

if (mode === "new") {
await createNote({
project: value.project,
...payload,
});
} else {
await updateNote({
name: noteId,
...payload,
});
}

toast.success("Note saved");
await refresh();
navigate(`${ROUTES.project}/${routeProjectId}/notes`);
} catch (err) {
const error = parseFrappeErrorMsg(err as FrappeError);
toast.error(error);
}
},
});

useEffect(() => {
if (mode === "edit" && noteData?.message) {
form.setFieldValue("title", noteData.message.title);
form.setFieldValue("description", noteData.message.description);
setIsFormInitialized(true);
} else if (mode === "new") {
form.reset({
project: projectId,
title: "",
description: "",
status: "Publish",
});
setIsFormInitialized(true);
}
}, [noteData, mode, form, projectId]);

const isInputDisabled = isCreating || isUpdating || isNoteLoading;

return (
<div className="flex justify-center">
{isNoteLoading || !isFormInitialized ? (
<Spinner className="py-10" />
) : (
<div className="max-w-200 w-full p-4">
<div className="flex items-center justify-between gap-8">
<div className="flex items-center gap-2">
<Avatar
size="xs"
shape="circle"
label={userName}
image={userImage || undefined}
/>
<span className="truncate text-base font-medium text-ink-gray-7">
{userName}
</span>
</div>
<form.Subscribe selector={(state) => state.isDirty}>
{(isDirty) => (
<Button
variant="solid"
theme="gray"
size="sm"
label="Save note"
loading={isCreating || isUpdating}
disabled={isInputDisabled || !isDirty}
onClick={() => form.handleSubmit()}
/>
)}
</form.Subscribe>
</div>
<div className="flex flex-col gap-2 pt-4">
<form.Field
name="title"
children={(field) => {
return (
<>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
disabled={isInputDisabled}
placeholder="Add note title"
aria-label="Note title"
className="w-full resize-none border-0 bg-transparent text-3xl font-semibold leading-tight text-ink-gray-8 placeholder:text-ink-gray-4 focus:outline-none"
/>
{!field.state.meta.isValid && (
<ErrorMessage
message={field.state.meta.errors[0]?.message}
/>
)}
</>
);
}}
/>

<form.Field
name="description"
children={(field) => {
return (
<>
<TextEditor
content={field.state.value}
onChange={(value) => field.handleChange(value)}
placeholder="Type a note description..."
editable={!isInputDisabled}
fixedMenu={false}
editorClass="prose prose-sm max-w-none min-h-[400px] text-ink-gray-8 focus:outline-none"
/>
{!field.state.meta.isValid && (
<ErrorMessage
message={field.state.meta.errors[0]?.message}
/>
)}
</>
);
}}
/>
</div>
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from "zod";
import { NOTE_STATUS } from "../types";

export const noteFormSchema = z.object({
project: z
.string({ required_error: "Project Id is required" })
.trim()
.min(1, { message: "Project Id is required" }),
title: z
.string({ required_error: "Title is required" })
.trim()
.min(1, { message: "Title is required" }),
description: z
.string({ required_error: "Description is required" })
.trim()
.min(1, { message: "Description is required" }),
status: z.enum(NOTE_STATUS, { required_error: "Status is required" }),
});

export type NoteFormValues = z.infer<typeof noteFormSchema>;
Loading
Loading