Repository: pkpdapp-team/pkpdapp
Pull request #675: refactor(frontend): trial design dose rows form
Author: login: eatyourgreens
Created: 2025-04-03T16:04:30Z | Merged: 2025-04-04T09:32:18Z

Refactor Trial Design dose rows into a `DoseRow` component, which handles the values for a single dose in the `protocol.doses` array. Give each dose row its own form controller to handle input value changes.

- fixes #674.


https://github.com/user-attachments/assets/3ddbae03-0364-42a8-813f-f59361340ac6





Linked issue #674: Trial Design: saving a value loses current keyboard focus
Updating a value then tabbing to the next field loses keyboard focus when the API request completes.

https://github.com/user-attachments/assets/c8c2cfce-b0b8-402a-9e64-9aee7f260be8

Diff:
diff --git a/frontend-v2/src/features/trial/DoseRow.tsx b/frontend-v2/src/features/trial/DoseRow.tsx
new file mode 100644
index 000000000..f1c3d5d9f
--- /dev/null
+++ b/frontend-v2/src/features/trial/DoseRow.tsx
@@ -0,0 +1,197 @@
+import { Delete } from "@mui/icons-material";
+import { IconButton, TableCell, TableRow, Typography } from "@mui/material";
+import { FC, useEffect, useMemo } from "react";
+import FloatField from "../../components/FloatField";
+import IntegerField from "../../components/IntegerField";
+import UnitField from "../../components/UnitField";
+import {
+  DoseRead,
+  Protocol,
+  UnitRead,
+  useDoseRetrieveQuery,
+  useDoseUpdateMutation,
+} from "../../app/backendApi";
+import { Control, useForm } from "react-hook-form";
+import useDirty from "../../hooks/useDirty";
+import useInterval from "../../hooks/useInterval";
+
+type Props = {
+  baseUnit?: UnitRead;
+  control: Control<Protocol, any, Protocol>;
+  disabled: boolean;
+  doseId: number;
+  index: number;
+  isPreclinical: boolean;
+  minStartTime: number;
+  onChange: () => void;
+  removeDose: (index: number) => void;
+  selectedAmountLabel: string;
+  timeUnit?: UnitRead;
+};
+
+const DoseRow: FC<Props> = ({
+  baseUnit,
+  control,
+  disabled,
+  doseId,
+  index,
+  isPreclinical,
+  minStartTime,
+  onChange,
+  removeDose,
+  selectedAmountLabel,
+  timeUnit,
+}) => {
+  const { data: dose, refetch: refetchDose } = useDoseRetrieveQuery(
+    { id: doseId },
+    { skip: !doseId },
+  );
+  const {
+    control: doseControl,
+    handleSubmit,
+    formState: { isDirty },
+    reset,
+  } = useForm<DoseRead>({
+    defaultValues: dose,
+  });
+  useDirty(isDirty);
+
+  useEffect(() => {
+    reset(dose);
+  }, [dose, reset]);
+
+  const [updateDose] = useDoseUpdateMutation();
+
+  const handleSave = useMemo(
+    () =>
+      handleSubmit(async (data) => {
+        if (JSON.stringify(data) !== JSON.stringify(dose)) {
+          await updateDose({ id: doseId, dose: data });
+          refetchDose();
+          onChange();
+        }
+      }),
+    [dose, handleSubmit, doseId, refetchDose, updateDose],
+  );
+
+  // save dose every second if dirty
+  useInterval({
+    callback: handleSave,
+    delay: 1000,
+    isDirty,
+  });
+
+  const defaultProps = {
+    disabled,
+  };
+
+  const handleDeleteRow = () => {
+    removeDose(index);
+  };
+  return (
+    <TableRow>
+      <TableCell>
+        <FloatField
+          sx={{ minWidth: "6rem" }}
+          size="small"
+          label={"Dose"}
+          name={`amount`}
+          control={doseControl}
+          rules={{
+            required: true,
+            min: { value: 0, message: "Must be greater or equal to 0" },
+          }}
+          textFieldProps={defaultProps}
+        />
+      </TableCell>
+      <TableCell>
+        {baseUnit && index === 0 ? (
+          <UnitField
+            size="small"
+            label={"Unit"}
+            name={`amount_unit`}
+            control={control}
+            baseUnit={baseUnit}
+            isPreclinicalPerKg={isPreclinical}
+            selectProps={defaultProps}
+          />
+        ) : (
+          <Typography>{selectedAmountLabel}</Typography>
+        )}
+      </TableCell>
+      <TableCell>
+        <IntegerField
+          size="small"
+          label={"Number of Doses"}
+          name={`repeats`}
+          control={doseControl}
+          rules={{
+            required: true,
+            min: { value: 1, message: "One or more required" },
+          }}
+          textFieldProps={defaultProps}
+        />
+      </TableCell>
+      <TableCell>
+        <FloatField
+          size="small"
+          label="Start Time"
+          name={`start_time`}
+          control={doseControl}
+          rules={{
+            required: true,
+            min: {
+              value: minStartTime,
+              message: "value > prev. dose or 0",
+            },
+          }}
+          textFieldProps={defaultProps}
+        />
+      </TableCell>
+      <TableCell>
+        <FloatField
+          size="small"
+          label={"Dosing Duration"}
+          name={`duration`}
+          control={doseControl}
+          rules={{
+            required: true,
+            min: {
+              value: Number.EPSILON,
+              message: "Must be greater than 0",
+            },
+          }}
+          textFieldProps={defaultProps}
+        />
+      </TableCell>
+      <TableCell>
+        <FloatField
+          size="small"
+          label={"Dosing Interval"}
+          name={`repeat_interval`}
+          control={doseControl}
+          rules={{
+            required: true,
+            min: {
+              value: Number.EPSILON,
+              message: "Must be greater than 0",
+            },
+          }}
+          textFieldProps={defaultProps}
+        />
+      </TableCell>
+      <TableCell>
+        <Typography>{timeUnit?.symbol}</Typography>
+      </TableCell>
+      <TableCell align="center">
+        {index !== 0 && (
+          <IconButton onClick={handleDeleteRow} disabled={disabled}>
+            <Delete />
+          </IconButton>
+        )}
+      </TableCell>
+    </TableRow>
+  );
+};
+
+export default DoseRow;
diff --git a/frontend-v2/src/features/trial/Doses.tsx b/frontend-v2/src/features/trial/Doses.tsx
index 42b05d741..c02f93ecc 100644
--- a/frontend-v2/src/features/trial/Doses.tsx
+++ b/frontend-v2/src/features/trial/Doses.tsx
@@ -2,10 +2,8 @@ import { FC, useEffect, useMemo } from "react";
 import {
   TableCell,
   TableRow,
-  IconButton,
   Button,
   Stack,
-  Typography,
   Box,
   Tooltip,
 } from "@mui/material";
@@ -14,15 +12,10 @@ import {
   Protocol,
   ProtocolRead,
   UnitRead,
-  useCompoundRetrieveQuery,
   useProtocolUpdateMutation,
   useVariableRetrieveQuery,
 } from "../../app/backendApi";
-import Delete from "@mui/icons-material/Delete";
 import { useFieldArray, useForm } from "react-hook-form";
-import UnitField from "../../components/UnitField";
-import FloatField from "../../components/FloatField";
-import IntegerField from "../../components/IntegerField";
 import useDirty from "../../hooks/useDirty";
 import useInterval from "../../hooks/useInterval";
 import { useSelector } from "react-redux";
@@ -30,6 +23,7 @@ import { RootState } from "../../app/store";
 import { selectIsProjectShared } from "../login/loginSlice";
 import { TableHeader } from "../../components/TableHeader";
 import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
+import DoseRow from "./DoseRow";
 
 interface Props {
   onChange: () => void;
@@ -39,10 +33,6 @@ interface Props {
 }
 
 const Doses: FC<Props> = ({ onChange, project, protocol, units }) => {
-  const { data: compound } = useCompoundRetrieveQuery(
-    { id: project?.compound || 0 },
-    { skip: !project || !project.compound },
-  );
   const { data: variable, isLoading: isVariableLoading } =
     useVariableRetrieveQuery(
       { id: protocol.variables[0] || 0 },
@@ -96,9 +86,6 @@ const Doses: FC<Props> = ({ onChange, project, protocol, units }) => {
   });
 
   const isPreclinical = project.species !== "H";
-  const defaultProps = {
-    disabled: isSharedWithMe,
-  };
   const defaultSymbol = isPreclinical ? "mg/kg" : "mg";
   const defaultUnit = units.find((u) => u.symbol === defaultSymbol);
   const baseUnit = units.find((u) => u.id === protocol.amount_unit);
@@ -120,9 +107,11 @@ const Doses: FC<Props> = ({ onChange, project, protocol, units }) => {
   }
 
   const handleAddRow = () => {
-    const isSmallMolecule = compound?.compound_type === "SM";
     const lastDose = doses[doses.length - 1];
-    const increment = (lastDose.repeats && lastDose.repeats > 1) ? ((lastDose.repeat_interval || 1) * lastDose.repeats) : (lastDose.duration || 1);
+    const increment =
+      lastDose.repeats && lastDose.repeats > 1
+        ? (lastDose.repeat_interval || 1) * lastDose.repeats
+        : lastDose.duration || 1;
     const lastDoseEndTime = lastDose.start_time + increment;
     appendDose({
       amount: lastDose.amount,
@@ -133,10 +122,6 @@ const Doses: FC<Props> = ({ onChange, project, protocol, units }) => {
     });
   };
 
-  const handleDeleteRow = (index: number) => {
-    removeDose(index);
-  };
-
   const selectedAmountId = getValues("amount_unit");
   const selectedAmountLabel =
     baseUnit?.compatible_units?.find(({ id }) => +id === selectedAmountId)
@@ -200,114 +185,23 @@ const Doses: FC<Props> = ({ onChange, project, protocol, units }) => {
           </Box>
         </TableCell>
       </TableRow>
-      {doses.map((dose, index) => (
-        <TableRow key={dose.id}>
-          <TableCell>
-            <FloatField
-              sx={{ minWidth: "6rem" }}
-              size="small"
-              label={"Dose"}
-              name={`doses.${index}.amount`}
-              control={control}
-              rules={{
-                required: true,
-                min: { value: 0, message: "Must be greater or equal to 0" },
-              }}
-              textFieldProps={defaultProps}
-            />
-          </TableCell>
-          <TableCell>
-            {protocol.amount_unit && index === 0 ? (
-              <UnitField
-                size="small"
-                label={"Unit"}
-                name={`amount_unit`}
-                control={control}
-                baseUnit={baseUnit}
-                isPreclinicalPerKg={isPreclinical}
-                selectProps={defaultProps}
-              />
-            ) : (
-              <Typography>{selectedAmountLabel}</Typography>
-            )}
-          </TableCell>
-          <TableCell>
-            <IntegerField
-              size="small"
-              label={"Number of Doses"}
-              name={`doses.${index}.repeats`}
-              control={control}
-              rules={{
-                required: true,
-                min: { value: 1, message: "One or more required" },
-              }}
-              textFieldProps={defaultProps}
-            />
-          </TableCell>
-          <TableCell>
-            <FloatField
-              size="small"
-              label="Start Time"
-              name={`doses.${index}.start_time`}
-              control={control}
-              rules={{
-                required: true,
-                min: {
-                  value: doses[index - 1]?.start_time + 1e4 * Number.EPSILON || 0,
-                  message: "value > prev. dose or 0",
-                }
-              }}
-              textFieldProps={defaultProps}
-            />
-          </TableCell>
-          <TableCell>
-            <FloatField
-              size="small"
-              label={"Dosing Duration"}
-              name={`doses.${index}.duration`}
-              control={control}
-              rules={{
-                required: true,
-                min: {
-                  value: Number.EPSILON,
-                  message: "Must be greater than 0",
-                },
-              }}
-              textFieldProps={defaultProps}
-            />
-          </TableCell>
-          <TableCell>
-            <FloatField
-              size="small"
-              label={"Dosing Interval"}
-              name={`doses.${index}.repeat_interval`}
-              control={control}
-              rules={{
-                required: true,
-                min: {
-                  value: Number.EPSILON,
-                  message: "Must be greater than 0",
-                },
-              }}
-              textFieldProps={defaultProps}
-            />
-          </TableCell>
-          <TableCell>
-            <Typography>
-              {units.find((u) => u.id === protocol.time_unit)?.symbol}
-            </Typography>
-          </TableCell>
-          <TableCell align="center">
-            {index !== 0 && (
-              <IconButton
-                onClick={() => handleDeleteRow(index)}
-                disabled={isSharedWithMe}
-              >
-                <Delete />
-              </IconButton>
-            )}
-          </TableCell>
-        </TableRow>
+      {protocol.doses.map((dose, index) => (
+        <DoseRow
+          key={dose.id}
+          index={index}
+          baseUnit={baseUnit}
+          disabled={isSharedWithMe}
+          doseId={dose.id}
+          control={control}
+          isPreclinical={isPreclinical}
+          minStartTime={
+            doses[index - 1]?.start_time + 1e4 * Number.EPSILON || 0
+          }
+          onChange={onChange}
+          removeDose={removeDose}
+          selectedAmountLabel={selectedAmountLabel}
+          timeUnit={units.find((u) => u.id === protocol.time_unit)}
+        />
       ))}
     </>
   );
diff --git a/frontend-v2/src/features/trial/Protocols.tsx b/frontend-v2/src/features/trial/Protocols.tsx
index 5d350adbe..6bd607088 100644
--- a/frontend-v2/src/features/trial/Protocols.tsx
+++ b/frontend-v2/src/features/trial/Protocols.tsx
@@ -80,11 +80,14 @@ const Protocols: FC = () => {
   const isSharedWithMe = useSelector((state: RootState) =>
     selectIsProjectShared(state, project),
   );
-  const { data: projectProtocols, isLoading: isProtocolsLoading } =
-    useProtocolListQuery(
-      { projectId: selectedProjectOrZero },
-      { skip: !selectedProject },
-    );
+  const {
+    data: projectProtocols,
+    isLoading: isProtocolsLoading,
+    refetch: refetchProtocols,
+  } = useProtocolListQuery(
+    { projectId: selectedProjectOrZero },
+    { skip: !selectedProject },
+  );
   const { data: models, isLoading: isModelsLoading } =
     useCombinedModelListQuery(
       { projectId: selectedProjectOrZero },
@@ -165,9 +168,10 @@ const Protocols: FC = () => {
   const removeGroup = (groupID: number) => async () => {
     const subjectGroup = groups?.find((g) => g.id === groupID);
     const subjectCount = subjectGroup?.subjects.length || 0;
-    const confirmationMessage = subjectCount === 0
-      ? `Are you sure you want to delete ${subjectGroup?.name}?`
-      : `Are you sure you want to delete group ${subjectGroup?.name} and all its subjects?`;
+    const confirmationMessage =
+      subjectCount === 0
+        ? `Are you sure you want to delete ${subjectGroup?.name}?`
+        : `Are you sure you want to delete group ${subjectGroup?.name} and all its subjects?`;
     if (window?.confirm(confirmationMessage)) {
       setTab(tab - 1);
       await destroySubjectGroup({ id: groupID });
@@ -178,6 +182,7 @@ const Protocols: FC = () => {
 
   const onProtocolChange = () => {
     refetchGroups();
+    refetchProtocols();
   };
 
   function a11yProps(index: number) {
diff --git a/pkpdapp/pkpdapp/api/views/dose.py b/pkpdapp/pkpdapp/api/views/dose.py
index 8d7a774e6..0f1f34fd3 100644
--- a/pkpdapp/pkpdapp/api/views/dose.py
+++ b/pkpdapp/pkpdapp/api/views/dose.py
@@ -6,7 +6,6 @@
 from rest_framework import viewsets
 from rest_framework.permissions import IsAuthenticated
 from pkpdapp.api.views import (
-    NotADatasetDose,
     CheckAccessToProject,
 )
 from pkpdapp.api.serializers import DoseSerializer
@@ -17,5 +16,5 @@ class DoseView(viewsets.ModelViewSet):
     queryset = Dose.objects.all()
     serializer_class = DoseSerializer
     permission_classes = [
-        IsAuthenticated & NotADatasetDose & CheckAccessToProject
+        IsAuthenticated & CheckAccessToProject
     ]


Reviews:
user: login: eatyourgreens
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T16:59:54Z
user: login: eatyourgreens
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T17:14:19Z
user: login: eatyourgreens
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T17:15:27Z

Review comments:
diff_hunk: @@ -120,9 +108,11 @@ const Doses: FC<Props> = ({ onChange, project, protocol, units }) => {
   }
 
   const handleAddRow = () => {
-    const isSmallMolecule = compound?.compound_type === "SM";
path: frontend-v2/src/features/trial/Doses.tsx
user: login: eatyourgreens
body: I think the linter deleted this. I don't remember deleting it.
created_at: 2025-04-03T16:59:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,194 @@
+import { Delete } from "@mui/icons-material";
+import { IconButton, TableCell, TableRow, Typography } from "@mui/material";
+import { FC, useEffect, useMemo } from "react";
+import FloatField from "../../components/FloatField";
+import IntegerField from "../../components/IntegerField";
+import UnitField from "../../components/UnitField";
+import {
+  DoseRead,
+  Protocol,
+  UnitRead,
+  useDoseRetrieveQuery,
+  useDoseUpdateMutation,
+} from "../../app/backendApi";
+import { Control, useForm } from "react-hook-form";
+import useDirty from "../../hooks/useDirty";
+import useInterval from "../../hooks/useInterval";
+
+type Props = {
+  baseUnit?: UnitRead;
+  control: Control<Protocol, any, Protocol>;
path: frontend-v2/src/features/trial/DoseRow.tsx
user: login: eatyourgreens
body: The amount unit field uses the protocol control, otherwise this form could be completely self-contained, with its own dose controller for each dose row.
created_at: 2025-04-03T17:14:19Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,194 @@
+import { Delete } from "@mui/icons-material";
+import { IconButton, TableCell, TableRow, Typography } from "@mui/material";
+import { FC, useEffect, useMemo } from "react";
+import FloatField from "../../components/FloatField";
+import IntegerField from "../../components/IntegerField";
+import UnitField from "../../components/UnitField";
+import {
+  DoseRead,
+  Protocol,
+  UnitRead,
+  useDoseRetrieveQuery,
+  useDoseUpdateMutation,
+} from "../../app/backendApi";
+import { Control, useForm } from "react-hook-form";
+import useDirty from "../../hooks/useDirty";
+import useInterval from "../../hooks/useInterval";
+
+type Props = {
+  baseUnit?: UnitRead;
+  control: Control<Protocol, any, Protocol>;
+  disabled: boolean;
+  doseId: number;
+  index: number;
+  isPreclinical: boolean;
+  minStartTime: number;
+  removeDose: (index: number) => void;
+  selectedAmountLabel: string;
+  timeUnit?: UnitRead;
+};
+
+const DoseRow: FC<Props> = ({
+  baseUnit,
+  control,
+  disabled,
+  doseId,
+  index,
+  isPreclinical,
+  minStartTime,
+  removeDose,
+  selectedAmountLabel,
+  timeUnit,
+}) => {
+  const { data: dose, refetch: refetchDose } = useDoseRetrieveQuery(
+    { id: doseId },
+    { skip: !doseId },
+  );
+  const {
+    control: doseControl,
+    handleSubmit,
+    formState: { isDirty },
+    reset,
+  } = useForm<DoseRead>({
+    defaultValues: dose,
+  });
+  useDirty(isDirty);
+
+  useEffect(() => {
+    reset(dose);
+  }, [dose, reset]);
+
+  const [updateDose] = useDoseUpdateMutation();
+
+  const handleSave = useMemo(
+    () =>
+      handleSubmit(async (data) => {
+        if (JSON.stringify(data) !== JSON.stringify(dose)) {
+          await updateDose({ id: doseId, dose: data });
+          refetchDose();
path: frontend-v2/src/features/trial/DoseRow.tsx
user: login: eatyourgreens
body: A callback here, to refetch the protocol in the parent component, might fix the 'Add new row' button.
created_at: 2025-04-03T17:15:27Z
author_association: COLLABORATOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-03T16:09:04Z
body: ## [Codecov](https://app.codecov.io/gh/pkpdapp-team/pkpdapp/pull/675?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pkpdapp-team) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 76.78%. Comparing base [(`4b52b95`)](https://app.codecov.io/gh/pkpdapp-team/pkpdapp/commit/4b52b950acf86f378f88b2c95f1ebde02477bca2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pkpdapp-team) to head [(`646ea61`)](https://app.codecov.io/gh/pkpdapp-team/pkpdapp/commit/646ea61de29372901c97631cd81c8d14d1197655?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pkpdapp-team).
> Report is 1 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master     #675   +/-   ##
=======================================
  Coverage   76.78%   76.78%           
=======================================
  Files         118      118           
  Lines        5758     5758           
=======================================
  Hits         4421     4421           
  Misses       1337     1337           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pkpdapp-team/pkpdapp/pull/675?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pkpdapp-team).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pkpdapp-team).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: eatyourgreens
created_at: 2025-04-03T17:12:41Z
body: I think this breaks the 'Add new row' button, because doses are now saved independently of `protocol.doses`.
author_association: COLLABORATOR
user: login: sonarqubecloud[bot]
created_at: 2025-04-03T17:32:11Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=pkpdapp-team_pkpdapp&pullRequest=675) **Quality Gate passed for 'pkpdapp'**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=pkpdapp-team_pkpdapp&pullRequest=675&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=pkpdapp-team_pkpdapp&pullRequest=675&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=pkpdapp-team_pkpdapp&pullRequest=675&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=pkpdapp-team_pkpdapp&pullRequest=675&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=pkpdapp-team_pkpdapp&pullRequest=675&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=pkpdapp-team_pkpdapp&pullRequest=675)


author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-03T17:32:56Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=pkpdapp-team_pkpdapp_frontend&pullRequest=675) **Quality Gate failed for 'pkpdapp-team_pkpdapp_frontend'**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=pkpdapp-team_pkpdapp_frontend&pullRequest=675&metric=new_coverage&view=list) (required ≥ 80%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=pkpdapp-team_pkpdapp_frontend&pullRequest=675)


author_association: NONE
user: login: eatyourgreens
created_at: 2025-04-04T09:31:01Z
body: >I think this breaks the 'Add new row' button, because doses are now saved independently of protocol.doses.

Fixed by triggering a refetch whenever we save a dose.
author_association: COLLABORATOR