Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrated Excalidraw for drawing Files #10680

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
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
13 changes: 12 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"sort-locales": "node ./scripts/sort-locales.js"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.17.6",
"@fontsource/figtree": "^5.1.1",
"@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^4.0.0",
Expand Down Expand Up @@ -115,7 +116,7 @@
"react-phone-number-input": "^3.4.11",
"react-webcam": "^7.2.0",
"recharts": "^2.15.0",
"sonner": "^1.7.2",
"sonner": "^1.7.4",
"tailwind-merge": "^3.0.0",
"tailwindcss-animate": "^1.0.7",
"use-keyboard-shortcut": "^1.1.6",
Expand Down
5 changes: 5 additions & 0 deletions public/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@
"add_consultation_update": "Add Consultation Update",
"add_department_team": "Add Department/Team",
"add_details_of_patient": "Add Details of Patient",
"add_drawings": "Add Drawings",
"add_exception": "Add Exception",
"add_facility": "Add Facility",
"add_files": "Add Files",
Expand Down Expand Up @@ -982,8 +983,10 @@
"error_fetching_user_details": "Error while fetching user details: ",
"error_fetching_users_data": "Failed to load user data. Please try again later.",
"error_generating_discharge_summary": "Error generating discharge summary",
"error_in_createUpload": "Error in createUpload",
"error_loading_questionnaire_response": "Error loading questionnaire response",
"error_updating_encounter": "Error to Updating Encounter",
"error_uploading_file": "Error uploading file",
"error_verifying_otp": "Error while verifying OTP, Please request a new OTP",
"error_while_deleting_record": "Error while deleting record",
"escape": "Escape",
Expand Down Expand Up @@ -1068,6 +1071,7 @@
"file_name_changed_successfully": "File name changed successfully",
"file_preview": "File Preview",
"file_preview_not_supported": "Can't preview this file. Try downloading it.",
"file_success__upload_complete": "File upload complete",
"file_type": "File Type",
"file_upload_error": "Error uploading file",
"file_upload_success": "File uploaded successfully",
Expand Down Expand Up @@ -1663,6 +1667,7 @@
"please_check_your_messages": "Please check your messages",
"please_confirm_password": "Please confirm your new password.",
"please_enter_a_name": "Please enter a name!",
"please_enter_a_name_for_the_drawing.": "Please enter a name for the drawing.",
"please_enter_a_reason_for_the_shift": "Please enter a reason for the shift.",
"please_enter_a_valid_reason": "Please enter a valid reason!",
"please_enter_confirm_password": "Please confirm your new password",
Expand Down
22 changes: 22 additions & 0 deletions src/Routers/routes/ConsultationRoutes.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { Suspense, lazy } from "react";

import Loading from "@/components/Common/Loading";
import QuestionnaireResponseView from "@/components/Facility/ConsultationDetails/QuestionnaireResponseView";
import EncounterQuestionnaire from "@/components/Patient/EncounterQuestionnaire";
import TreatmentSummary from "@/components/Patient/TreatmentSummary";
Expand All @@ -6,6 +9,11 @@ import { AppRoutes } from "@/Routers/AppRouter";
import { EncounterShow } from "@/pages/Encounters/EncounterShow";
import { PrintPrescription } from "@/pages/Encounters/PrintPrescription";

const ExcalidrawEditor = lazy(
() => import("@/components/Files/ExcalidrawEditor"),
);
const ExcalidrawView = lazy(() => import("@/components/Files/ExcalidrawView"));

const consultationRoutes: AppRoutes = {
"/facility/:facilityId/patient/:patientId/encounter/:encounterId/prescriptions/print":
({ facilityId, encounterId, patientId }) => (
Expand All @@ -27,6 +35,20 @@ const consultationRoutes: AppRoutes = {
patientId={patientId}
/>
),
"/facility/:facilityId/patient/:patientId/encounter/:encounterId/drawings": ({
encounterId,
}) => (
<Suspense fallback={<Loading />}>
<ExcalidrawEditor associatingId={encounterId} fileType="encounter" />
</Suspense>
),
"/facility/:facilityId/patient/:patientId/encounter/:encounterId/drawings/:drawingId":
({ drawingId }) => (
<Suspense fallback={<Loading />}>
<ExcalidrawView drawingId={drawingId} />
</Suspense>
),
Comment on lines +45 to +50
Copy link
Member

Choose a reason for hiding this comment

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

@bodhish marking this for hold since backend would not allow editing a file due to audit reasons.

@vigneshhari suggests making a plug and writing the elements to the DB itself instead of a file upload.

Copy link
Member

Choose a reason for hiding this comment

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

Buckets have versions what stops us from editing 🤔


"/facility/:facilityId/patient/:patientId/encounter/:encounterId/questionnaire/:slug":
({ facilityId, encounterId, slug, patientId }) => (
<EncounterQuestionnaire
Expand Down
25 changes: 25 additions & 0 deletions src/Routers/routes/PatientRoutes.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { Suspense, lazy } from "react";

import Loading from "@/components/Common/Loading";
import {
facilityPatientTabs,
patientTabs,
Expand All @@ -10,6 +13,11 @@ import { AppRoutes } from "@/Routers/AppRouter";
import { EncounterList } from "@/pages/Encounters/EncounterList";
import VerifyPatient from "@/pages/Patients/VerifyPatient";

const ExcalidrawEditor = lazy(
() => import("@/components/Files/ExcalidrawEditor"),
);
const ExcalidrawView = lazy(() => import("@/components/Files/ExcalidrawView"));

const PatientRoutes: AppRoutes = {
"/facility/:facilityId/patients": ({ facilityId }) => (
<PatientIndex facilityId={facilityId} />
Expand Down Expand Up @@ -44,6 +52,23 @@ const PatientRoutes: AppRoutes = {
"/facility/:facilityId/patient/:id/update": ({ facilityId, id }) => (
<PatientRegistration facilityId={facilityId} patientId={id} />
),
"/facility/:facilityId/patient/:id/drawings": ({ id }) => {
return (
<Suspense fallback={<Loading />}>
<ExcalidrawEditor associatingId={id} fileType="patient" />
</Suspense>
);
},

"/facility/:facilityId/patient/:patientId/drawings/:drawingId": ({
drawingId,
}) => {
return (
<Suspense fallback={<Loading />}>
<ExcalidrawView drawingId={drawingId} />
</Suspense>
);
},
};

export default PatientRoutes;
159 changes: 159 additions & 0 deletions src/components/Files/ExcalidrawEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Excalidraw } from "@excalidraw/excalidraw";
import { type ExcalidrawElement } from "@excalidraw/excalidraw/types/element/types";
import { useMutation } from "@tanstack/react-query";
import { t } from "i18next";
import { navigate } from "raviger";
import { useEffect, useState } from "react";
import { toast } from "sonner";

import { debounce } from "@/lib/utils";

import CareIcon from "@/CAREUI/icons/CareIcon";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

import Loading from "@/components/Common/Loading";
import { CreateFileResponse } from "@/components/Patient/models";

import routes from "@/Utils/request/api";
import mutate from "@/Utils/request/mutate";

type Props = {
associatingId: string;
fileType: string;
};

export default function ExcalidrawEditor({ associatingId, fileType }: Props) {
const [elements, setElements] = useState<readonly ExcalidrawElement[] | null>(
[],
);
const [name, setName] = useState("");
const [id, setId] = useState("");
const [isDirty, setIsDirty] = useState(false);

useEffect(() => {
setIsDirty(!!elements?.length);
}, [elements?.length]);

const { mutateAsync: markUploadComplete, error: markUploadCompleteError } =
useMutation({
mutationFn: mutate(routes.markUploadCompleted, {
pathParams: { id: id || "" },
}),
});

const { mutateAsync: createUpload } = useMutation({
mutationFn: mutate(routes.createUpload),
onSuccess: (response: CreateFileResponse) => {
setId(response.id);
},
});

Comment on lines +39 to +52
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Enhance mutation hooks with better error handling and retry logic.

The mutation hooks could benefit from:

  1. Error handling for createUpload
  2. Retry configuration for transient failures
  3. Loading states for better UX
   const { mutateAsync: markUploadComplete, error: markUploadCompleteError } =
     useMutation({
       mutationFn: mutate(routes.markUploadCompleted, {
         pathParams: { id: id || "" },
       }),
+      retry: 3,
+      retryDelay: 1000,
     });

-  const { mutateAsync: createUpload } = useMutation({
+  const { mutateAsync: createUpload, error: createUploadError, isLoading: isUploading } = useMutation({
     mutationFn: mutate(routes.createUpload),
     onSuccess: (response: CreateFileResponse) => {
       setId(response.id);
     },
+    onError: (error) => {
+      toast.error(t("error_in_createUpload"));
+    },
+    retry: 3,
+    retryDelay: 1000,
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { mutateAsync: markUploadComplete, error: markUploadCompleteError } =
useMutation({
mutationFn: mutate(routes.markUploadCompleted, {
pathParams: { id: id || "" },
}),
});
const { mutateAsync: createUpload } = useMutation({
mutationFn: mutate(routes.createUpload),
onSuccess: (response: CreateFileResponse) => {
setId(response.id);
},
});
const { mutateAsync: markUploadComplete, error: markUploadCompleteError } =
useMutation({
mutationFn: mutate(routes.markUploadCompleted, {
pathParams: { id: id || "" },
}),
retry: 3,
retryDelay: 1000,
});
const { mutateAsync: createUpload, error: createUploadError, isLoading: isUploading } = useMutation({
mutationFn: mutate(routes.createUpload),
onSuccess: (response: CreateFileResponse) => {
setId(response.id);
},
onError: (error) => {
toast.error(t("error_in_createUpload"));
},
retry: 3,
retryDelay: 1000,
});

const handleSave = async () => {
if (!name.trim()) {
toast.error(t("please_enter_a_name_for_the_drawing"));
return;
}

const obj = {
type: "excalidraw",
version: "2",
source: window.location.origin,
elements: elements,
appState: {},
files: {},
};

try {
const file = new File([JSON.stringify(obj)], `${name}.excalidraw`, {
type: "application/vnd.excalidraw",
});
let signedUrl = "";
let response: CreateFileResponse | null = null;
if (!id) {
response = await createUpload({
original_name: `${name}.excalidraw`,
name: name,
file_type: fileType,
file_category: "unspecified",
associating_id: associatingId,
mime_type: "text/plain",
});
signedUrl = response.signed_url;
}

const formData = new FormData();
formData.append("file", file);

const upload = await fetch(signedUrl, {
method: "PUT",
body: file,
});

if (!upload.ok) {
toast.error(t("error_uploading_file"));

return;
}
await markUploadComplete({ id: response?.id });
if (markUploadCompleteError) {
toast.error(t("file_error__mark_complete_failed"));

return;
} else {
toast.success(t("file_success__upload_complete"));
navigate(`drawings/${response!.id}`);
}
} catch {
toast.error(t("error_in_createUpload"));
}
};
Comment on lines +53 to +111
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix file upload implementation and improve error handling.

Several issues need attention:

  1. FormData is created but not used
  2. Generic error handling could be more specific
  3. Upload progress tracking is missing
-      const formData = new FormData();
-      formData.append("file", file);
-
       const upload = await fetch(signedUrl, {
         method: "PUT",
         body: file,
+        headers: {
+          'Content-Type': 'application/vnd.excalidraw',
+        },
       });

       if (!upload.ok) {
-        toast.error(t("error_uploading_file"));
+        toast.error(t("error_uploading_file_with_status", { status: upload.status }));
         return;
       }

Also consider adding upload progress tracking:

const upload = await fetch(signedUrl, {
  method: "PUT",
  body: file,
  headers: {
    'Content-Type': 'application/vnd.excalidraw',
  },
  onUploadProgress: (progressEvent) => {
    const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
    // Update progress state
  },
});


if (elements === null) {
return <Loading />;
}

return (
<div className="flex flex-col h-[calc(100vh-4rem)]">
<div className="flex flex-row justify-between items-center p-2">
<div className="flex flex-row items-center">
<div className="rounded-full bg-primary-100 px-5 py-4">
<CareIcon icon="l-pen" className="text-lg text-primary-500" />
</div>
<div className="m-4">
<Input
type="text"
value={name}
placeholder={t("enter_the_file_name")}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
{isDirty && (
<Button className="ml-auto" onClick={handleSave}>
{t("save")}
</Button>
)}
</div>

<div className="flex-grow h-[calc(100vh-10rem)] -m-2">
<Excalidraw
UIOptions={{
canvasActions: {
saveAsImage: true,
export: false,
loadScene: false,
},
}}
initialData={{
appState: { theme: "light" },
}}
onChange={debounce((elements) => {
setElements(elements);
}, 100)}
/>
</div>
</div>
);
}
Loading
Loading