From 4c4f2fd42639d1c3fe05b238ef40329ea9ca78eb Mon Sep 17 00:00:00 2001
From: Neil <55785687+carkom@users.noreply.github.com>
Date: Mon, 5 Feb 2024 14:40:46 -0800
Subject: [PATCH] Custom Reports: add save reports menu (#2257)

* Add schema work

* notes

* merge fixes

* Add Reports Save Menu

* merge fixes

* updates

* notes

* updates

* updates

* save updates fix

* typecheck fixes

* merge fixes

* saveReport strict Typescript

* fix sidebar

* lint fix

* fixing functionality plus clean up

* clean up
---
 .../src/components/reports/ReportOptions.ts   |   3 -
 .../src/components/reports/ReportSidebar.jsx  |  15 +-
 .../src/components/reports/ReportTopbar.jsx   |  18 +-
 .../src/components/reports/SaveReport.tsx     | 176 ++++++++++++++----
 .../src/components/reports/SaveReportMenu.tsx |  76 ++++++++
 .../src/components/reports/SaveReportName.tsx |  74 ++++++++
 .../reports/reports/CustomReport.jsx          |  38 +++-
 .../src/client/data-hooks/reports.ts          |   8 +-
 .../loot-core/src/types/models/reports.d.ts   |   6 +-
 upcoming-release-notes/2257.md                |   6 +
 10 files changed, 361 insertions(+), 59 deletions(-)
 create mode 100644 packages/desktop-client/src/components/reports/SaveReportMenu.tsx
 create mode 100644 packages/desktop-client/src/components/reports/SaveReportName.tsx
 create mode 100644 upcoming-release-notes/2257.md

diff --git a/packages/desktop-client/src/components/reports/ReportOptions.ts b/packages/desktop-client/src/components/reports/ReportOptions.ts
index 8c2d361db..a79c2059a 100644
--- a/packages/desktop-client/src/components/reports/ReportOptions.ts
+++ b/packages/desktop-client/src/components/reports/ReportOptions.ts
@@ -11,8 +11,6 @@ const startDate = monthUtils.subMonths(monthUtils.currentMonth(), 5);
 const endDate = monthUtils.currentMonth();
 
 export const defaultReport: CustomReportEntity = {
-  id: undefined,
-  name: 'Default',
   startDate,
   endDate,
   isDateStatic: false,
@@ -24,7 +22,6 @@ export const defaultReport: CustomReportEntity = {
   showOffBudget: false,
   showHiddenCategories: false,
   showUncategorized: false,
-  selectedCategories: [],
   graphType: 'BarGraph',
   conditions: [],
   conditionsOp: 'and',
diff --git a/packages/desktop-client/src/components/reports/ReportSidebar.jsx b/packages/desktop-client/src/components/reports/ReportSidebar.jsx
index 0ccb5ef2b..46981a2c0 100644
--- a/packages/desktop-client/src/components/reports/ReportSidebar.jsx
+++ b/packages/desktop-client/src/components/reports/ReportSidebar.jsx
@@ -45,7 +45,7 @@ export function ReportSidebar({
 }) {
   const [menuOpen, setMenuOpen] = useState(false);
   const onSelectRange = cond => {
-    onReportChange(null, 'modify');
+    onReportChange({ type: 'modify' });
     setDateRange(cond);
     switch (cond) {
       case 'All time':
@@ -79,7 +79,7 @@ export function ReportSidebar({
   };
 
   const onChangeMode = cond => {
-    onReportChange(null, 'modify');
+    onReportChange({ type: 'modify' });
     setMode(cond);
     if (cond === 'time') {
       if (customReportItems.graphType === 'TableGraph') {
@@ -110,7 +110,7 @@ export function ReportSidebar({
   };
 
   const onChangeSplit = cond => {
-    onReportChange(null, 'modify');
+    onReportChange({ type: 'modify' });
     setGroupBy(cond);
     if (customReportItems.mode === 'total') {
       if (customReportItems.graphType !== 'TableGraph') {
@@ -128,7 +128,7 @@ export function ReportSidebar({
   };
 
   const onChangeBalanceType = cond => {
-    onReportChange(null, 'modify');
+    onReportChange({ type: 'modify' });
     setBalanceType(cond);
   };
 
@@ -275,6 +275,8 @@ export function ReportSidebar({
               >
                 <Menu
                   onMenuSelect={type => {
+                    onReportChange({ type: 'modify' });
+
                     if (type === 'show-hidden-categories') {
                       setShowHiddenCategories(
                         !customReportItems.showHiddenCategories,
@@ -464,7 +466,10 @@ export function ReportSidebar({
                 : false;
             })}
             selectedCategories={customReportItems.selectedCategories}
-            setSelectedCategories={setSelectedCategories}
+            setSelectedCategories={e => {
+              setSelectedCategories(e);
+              onReportChange({ type: 'modify' });
+            }}
             showHiddenCategories={customReportItems.showHiddenCategories}
           />
         </View>
diff --git a/packages/desktop-client/src/components/reports/ReportTopbar.jsx b/packages/desktop-client/src/components/reports/ReportTopbar.jsx
index d8944ae8c..d63744e9b 100644
--- a/packages/desktop-client/src/components/reports/ReportTopbar.jsx
+++ b/packages/desktop-client/src/components/reports/ReportTopbar.jsx
@@ -14,10 +14,11 @@ import { View } from '../common/View';
 import { FilterButton } from '../filters/FiltersMenu';
 
 import { GraphButton } from './GraphButton';
-import { SaveReportMenuButton } from './SaveReport';
+import { SaveReport } from './SaveReport';
 
 export function ReportTopbar({
   customReportItems,
+  report,
   savedStatus,
   setGraphType,
   setTypeDisabled,
@@ -29,6 +30,7 @@ export function ReportTopbar({
   onApplyFilter,
   onChangeViews,
   onReportChange,
+  onResetReports,
 }) {
   return (
     <View
@@ -43,6 +45,7 @@ export function ReportTopbar({
         selected={customReportItems.graphType === 'TableGraph'}
         title="Data Table"
         onSelect={() => {
+          onReportChange({ type: 'modify' });
           setGraphType('TableGraph');
           onChangeViews('viewLegend', false);
           setTypeDisabled([]);
@@ -60,6 +63,7 @@ export function ReportTopbar({
           customReportItems.graphType === 'StackedBarGraph'
         }
         onSelect={() => {
+          onReportChange({ type: 'modify' });
           if (customReportItems.mode === 'total') {
             setGraphType('BarGraph');
             if (['Net'].includes(customReportItems.balanceType)) {
@@ -84,6 +88,7 @@ export function ReportTopbar({
         title="Area Graph"
         selected={customReportItems.graphType === 'AreaGraph'}
         onSelect={() => {
+          onReportChange({ type: 'modify' });
           setGraphType('AreaGraph');
           setGroupBy('Month');
           onChangeViews('viewLegend', false);
@@ -98,6 +103,7 @@ export function ReportTopbar({
         title="Donut Graph"
         selected={customReportItems.graphType === 'DonutGraph'}
         onSelect={() => {
+          onReportChange({ type: 'modify' });
           setGraphType('DonutGraph');
           setTypeDisabled(['Net']);
           setBalanceType('Payment');
@@ -166,11 +172,17 @@ export function ReportTopbar({
         hover
         onApply={e => {
           onApplyFilter(e);
-          onReportChange(null, 'modify');
+          onReportChange({ type: 'modify' });
         }}
       />
       <View style={{ flex: 1 }} />
-      <SaveReportMenuButton savedStatus={savedStatus} />
+      <SaveReport
+        customReportItems={customReportItems}
+        report={report}
+        savedStatus={savedStatus}
+        onReportChange={onReportChange}
+        onResetReports={onResetReports}
+      />
     </View>
   );
 }
diff --git a/packages/desktop-client/src/components/reports/SaveReport.tsx b/packages/desktop-client/src/components/reports/SaveReport.tsx
index 00b59ada9..e7e26a3a5 100644
--- a/packages/desktop-client/src/components/reports/SaveReport.tsx
+++ b/packages/desktop-client/src/components/reports/SaveReport.tsx
@@ -1,45 +1,132 @@
-// @ts-strict-ignore
-import React, { useState } from 'react';
+import React, { createRef, useState } from 'react';
+
+import { v4 as uuidv4 } from 'uuid';
+
+//import { send, sendCatch } from 'loot-core/src/platform/client/fetch';
+import { type CustomReportEntity } from 'loot-core/src/types/models';
 
 import { SvgExpandArrow } from '../../icons/v0';
 import { Button } from '../common/Button';
-import { Menu } from '../common/Menu';
-import { MenuTooltip } from '../common/MenuTooltip';
 import { Text } from '../common/Text';
 import { View } from '../common/View';
 
-function SaveReportMenu({ setMenuOpen }) {
-  return (
-    <MenuTooltip width={150} onClose={() => setMenuOpen(false)}>
-      <Menu
-        onMenuSelect={item => {
-          switch (item) {
-            case 'save':
-            case 'clear':
-              setMenuOpen(false);
-              break;
-            default:
-          }
-        }}
-        items={[
-          {
-            name: 'save',
-            text: 'Save new report',
-            disabled: true,
-          },
-          {
-            name: 'clear',
-            text: 'Clear all',
-            disabled: true,
-          },
-        ]}
-      />
-    </MenuTooltip>
-  );
-}
+import { SaveReportMenu } from './SaveReportMenu';
+import { SaveReportName } from './SaveReportName';
 
-export function SaveReportMenuButton({ savedStatus }: { savedStatus: string }) {
+type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
+  customReportItems: T;
+  report: CustomReportEntity;
+  savedStatus: string;
+  onReportChange: ({
+    savedReport,
+    type,
+  }: {
+    savedReport?: T;
+    type: string;
+  }) => void;
+  onResetReports: () => void;
+};
+
+export function SaveReport({
+  customReportItems,
+  report,
+  savedStatus,
+  onReportChange,
+  onResetReports,
+}: SaveReportProps) {
+  const [nameMenuOpen, setNameMenuOpen] = useState(false);
   const [menuOpen, setMenuOpen] = useState(false);
+  const [menuItem, setMenuItem] = useState('');
+  const [err, setErr] = useState('');
+  const [res, setRes] = useState('');
+  const [name, setName] = useState(report.name);
+  const inputRef = createRef<HTMLInputElement>();
+
+  const onAddUpdate = async (menuChoice: string) => {
+    let savedReport: CustomReportEntity;
+    //save existing states
+    savedReport = {
+      ...report,
+      ...customReportItems,
+    };
+
+    if (menuChoice === 'save-report') {
+      setRes('');
+      //create new flow
+      /*
+      res = await sendCatch('report/create', {
+        ...savedReport,
+      });
+      */
+      savedReport = {
+        ...savedReport,
+        id: uuidv4(),
+        name,
+      };
+    }
+
+    if (menuChoice === 'rename-report') {
+      //rename
+      savedReport = {
+        ...savedReport,
+        name,
+      };
+    }
+
+    if (menuChoice === 'update-report') {
+      //send update and rename to DB
+      /*
+      res = await sendCatch('report/update', {
+        ...savedReport,
+      });
+      */
+    }
+    if (res !== '') {
+      setErr(res);
+      setNameMenuOpen(true);
+    } else {
+      setNameMenuOpen(false);
+      onReportChange({
+        savedReport,
+        type: menuChoice === 'rename-report' ? 'rename' : 'add-update',
+      });
+    }
+  };
+
+  const onMenuSelect = async (item: string) => {
+    setMenuItem(item);
+    switch (item) {
+      case 'rename-report':
+        setErr('');
+        setMenuOpen(false);
+        setNameMenuOpen(true);
+        break;
+      case 'delete-report':
+        setMenuOpen(false);
+        //await send('report/delete', id);
+        onResetReports();
+        break;
+      case 'update-report':
+        setErr('');
+        setMenuOpen(false);
+        onAddUpdate(item);
+        break;
+      case 'save-report':
+        setErr('');
+        setMenuOpen(false);
+        setNameMenuOpen(true);
+        break;
+      case 'reload-report':
+        setMenuOpen(false);
+        onReportChange({ type: 'reload' });
+        break;
+      case 'reset-report':
+        setMenuOpen(false);
+        onResetReports();
+        break;
+      default:
+    }
+  };
 
   return (
     <View
@@ -63,12 +150,29 @@ export function SaveReportMenuButton({ savedStatus }: { savedStatus: string }) {
             flexShrink: 0,
           }}
         >
-          Unsaved Report&nbsp;
+          {!report.id ? 'Unsaved report' : report.name}&nbsp;
         </Text>
         {savedStatus === 'modified' && <Text>(modified)&nbsp;</Text>}
         <SvgExpandArrow width={8} height={8} style={{ marginRight: 5 }} />
       </Button>
-      {menuOpen && <SaveReportMenu setMenuOpen={setMenuOpen} />}
+      {menuOpen && (
+        <SaveReportMenu
+          onClose={() => setMenuOpen(false)}
+          report={report}
+          onMenuSelect={onMenuSelect}
+          savedStatus={savedStatus}
+        />
+      )}
+      {nameMenuOpen && (
+        <SaveReportName
+          onClose={() => setNameMenuOpen(false)}
+          menuItem={menuItem}
+          setName={setName}
+          inputRef={inputRef}
+          onAddUpdate={onAddUpdate}
+          err={err}
+        />
+      )}
     </View>
   );
 }
diff --git a/packages/desktop-client/src/components/reports/SaveReportMenu.tsx b/packages/desktop-client/src/components/reports/SaveReportMenu.tsx
new file mode 100644
index 000000000..01e311dd2
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/SaveReportMenu.tsx
@@ -0,0 +1,76 @@
+import React from 'react';
+
+import { type CustomReportEntity } from 'loot-core/src/types/models';
+
+import { Menu } from '../common/Menu';
+import { MenuTooltip } from '../common/MenuTooltip';
+
+export function SaveReportMenu({
+  report,
+  onClose,
+  onMenuSelect,
+  savedStatus,
+}: {
+  report: CustomReportEntity;
+  onClose: () => void;
+  onMenuSelect: (item: string) => void;
+  savedStatus: string;
+}) {
+  return (
+    <MenuTooltip width={150} onClose={onClose}>
+      <Menu
+        onMenuSelect={item => {
+          onMenuSelect(item);
+        }}
+        items={
+          report.id === undefined
+            ? [
+                {
+                  name: 'save-report',
+                  text: 'Save new report',
+                },
+                {
+                  name: 'reset-report',
+                  text: 'Reset to default',
+                },
+              ]
+            : savedStatus === 'saved'
+              ? [
+                  { name: 'rename-report', text: 'Rename' },
+                  { name: 'delete-report', text: 'Delete' },
+                  Menu.line,
+                  {
+                    name: 'save-report',
+                    text: 'Save new report',
+                  },
+                  {
+                    name: 'reset-report',
+                    text: 'Reset to default',
+                  },
+                ]
+              : [
+                  { name: 'rename-report', text: 'Rename' },
+                  {
+                    name: 'update-report',
+                    text: 'Update report',
+                  },
+                  {
+                    name: 'reload-report',
+                    text: 'Revert changes',
+                  },
+                  { name: 'delete-report', text: 'Delete' },
+                  Menu.line,
+                  {
+                    name: 'save-report',
+                    text: 'Save new report',
+                  },
+                  {
+                    name: 'reset-report',
+                    text: 'Reset to default',
+                  },
+                ]
+        }
+      />
+    </MenuTooltip>
+  );
+}
diff --git a/packages/desktop-client/src/components/reports/SaveReportName.tsx b/packages/desktop-client/src/components/reports/SaveReportName.tsx
new file mode 100644
index 000000000..34c1d8176
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/SaveReportName.tsx
@@ -0,0 +1,74 @@
+import React, { type RefObject, useEffect } from 'react';
+
+import { theme } from '../../style';
+import { Button } from '../common/Button';
+import { Input } from '../common/Input';
+import { MenuTooltip } from '../common/MenuTooltip';
+import { Stack } from '../common/Stack';
+import { Text } from '../common/Text';
+import { FormField, FormLabel } from '../forms';
+
+type SaveReportNameProps = {
+  onClose: () => void;
+  menuItem: string;
+  setName: (name: string) => void;
+  inputRef: RefObject<HTMLInputElement>;
+  onAddUpdate: (menuItem: string) => void;
+  err: string;
+};
+
+export function SaveReportName({
+  onClose,
+  menuItem,
+  setName,
+  inputRef,
+  onAddUpdate,
+  err,
+}: SaveReportNameProps) {
+  useEffect(() => {
+    if (inputRef.current) {
+      inputRef.current.focus();
+    }
+  }, []);
+
+  return (
+    <MenuTooltip width={325} onClose={onClose}>
+      {menuItem !== 'update-report' && (
+        <form>
+          <Stack
+            direction="row"
+            justify="flex-end"
+            align="center"
+            style={{ padding: 10 }}
+          >
+            <FormField style={{ flex: 1 }}>
+              <FormLabel
+                title="Report Name"
+                htmlFor="name-field"
+                style={{ userSelect: 'none' }}
+              />
+              <Input inputRef={inputRef} onUpdate={setName} />
+            </FormField>
+            <Button
+              type="primary"
+              style={{ marginTop: 18 }}
+              onClick={e => {
+                e.preventDefault();
+                onAddUpdate(menuItem);
+              }}
+            >
+              {menuItem === 'save-report' ? 'Add' : 'Update'}
+            </Button>
+          </Stack>
+        </form>
+      )}
+      {err !== '' ? (
+        <Stack direction="row" align="center" style={{ padding: 10 }}>
+          <Text style={{ color: theme.errorText }}>{err}</Text>
+        </Stack>
+      ) : (
+        <Text />
+      )}
+    </MenuTooltip>
+  );
+}
diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx
index 7e679f233..97fd06aaa 100644
--- a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx
+++ b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx
@@ -91,7 +91,7 @@ export function CustomReport() {
   const months = monthUtils.rangeInclusive(startDate, endDate);
 
   useEffect(() => {
-    if (selectedCategories.length === 0 && categories.list.length !== 0) {
+    if (selectedCategories === undefined && categories.list.length !== 0) {
       setSelectedCategories(categories.list);
     }
   }, [categories, selectedCategories]);
@@ -203,8 +203,6 @@ export function CustomReport() {
 
   const data = { ...graphData, groupedData };
   const customReportItems = {
-    id: undefined,
-    name: undefined,
     startDate,
     endDate,
     isDateStatic,
@@ -232,7 +230,7 @@ export function CustomReport() {
   const onChangeDates = (startDate, endDate) => {
     setStartDate(startDate);
     setEndDate(endDate);
-    setSavedStatus('modified');
+    onReportChange({ type: 'modify' });
   };
 
   const onChangeViews = (viewType, status) => {
@@ -247,12 +245,37 @@ export function CustomReport() {
     }
   };
 
+  const onResetReports = () => {
+    const selectAll = [];
+    categories.grouped.map(categoryGroup =>
+      categoryGroup.categories.map(category => selectAll.push(category)),
+    );
+
+    setStartDate(defaultReport.startDate);
+    setEndDate(defaultReport.endDate);
+    setIsDateStatic(defaultReport.isDateStatic);
+    setDateRange(defaultReport.dateRange);
+    setMode(defaultReport.mode);
+    setGroupBy(defaultReport.groupBy);
+    setBalanceType(defaultReport.balanceType);
+    setShowEmpty(defaultReport.showEmpty);
+    setShowOffBudget(defaultReport.showOffBudget);
+    setShowHiddenCategories(defaultReport.showHiddenCategories);
+    setShowUncategorized(defaultReport.showUncategorized);
+    setSelectedCategories(selectAll);
+    setGraphType(defaultReport.graphType);
+    onApplyFilter(null);
+    onCondOpChange(defaultReport.conditionsOp);
+    setReport(defaultReport);
+    setSavedStatus('new');
+  };
+
   const onChangeAppliedFilter = (filter, changedElement) => {
-    onReportChange(null, 'modify');
+    onReportChange({ type: 'modify' });
     return changedElement(filter);
   };
 
-  const onReportChange = (savedReport, type) => {
+  const onReportChange = ({ savedReport, type }) => {
     switch (type) {
       case 'add-update':
         setSavedStatus('saved');
@@ -278,6 +301,7 @@ export function CustomReport() {
         setBalanceType(report.balanceType);
         setShowEmpty(report.showEmpty);
         setShowOffBudget(report.showOffBudget);
+        setShowHiddenCategories(report.showHiddenCategories);
         setShowUncategorized(report.showUncategorized);
         setSelectedCategories(report.selectedCategories);
         setGraphType(report.graphType);
@@ -330,6 +354,7 @@ export function CustomReport() {
         >
           <ReportTopbar
             customReportItems={customReportItems}
+            report={report}
             savedStatus={savedStatus}
             setGraphType={setGraphType}
             setTypeDisabled={setTypeDisabled}
@@ -341,6 +366,7 @@ export function CustomReport() {
             onApplyFilter={onApplyFilter}
             onChangeViews={onChangeViews}
             onReportChange={onReportChange}
+            onResetReports={onResetReports}
           />
           {filters && filters.length > 0 && (
             <View
diff --git a/packages/loot-core/src/client/data-hooks/reports.ts b/packages/loot-core/src/client/data-hooks/reports.ts
index e347c48fc..07142d2cb 100644
--- a/packages/loot-core/src/client/data-hooks/reports.ts
+++ b/packages/loot-core/src/client/data-hooks/reports.ts
@@ -31,9 +31,11 @@ export function useReports(): CustomReportEntity[] {
   /** Sort reports by alphabetical order */
   function sort(reports: CustomReportEntity[]) {
     return reports.sort((a, b) =>
-      a.name
-        .trim()
-        .localeCompare(b.name.trim(), undefined, { ignorePunctuation: true }),
+      a.name && b.name
+        ? a.name.trim().localeCompare(b.name.trim(), undefined, {
+            ignorePunctuation: true,
+          })
+        : 0,
     );
   }
 
diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts
index a9b8269a4..ef888a2a8 100644
--- a/packages/loot-core/src/types/models/reports.d.ts
+++ b/packages/loot-core/src/types/models/reports.d.ts
@@ -2,8 +2,8 @@ import { CategoryEntity } from './category';
 import { type RuleConditionEntity } from './rule';
 
 export interface CustomReportEntity {
-  id: string | undefined;
-  name: string;
+  id?: string;
+  name?: string;
   startDate: string;
   endDate: string;
   isDateStatic: boolean;
@@ -15,7 +15,7 @@ export interface CustomReportEntity {
   showOffBudget: boolean;
   showHiddenCategories: boolean;
   showUncategorized: boolean;
-  selectedCategories: CategoryEntity[];
+  selectedCategories?: CategoryEntity[];
   graphType: string;
   conditions?: RuleConditionEntity[];
   conditionsOp: string;
diff --git a/upcoming-release-notes/2257.md b/upcoming-release-notes/2257.md
new file mode 100644
index 000000000..9413515c7
--- /dev/null
+++ b/upcoming-release-notes/2257.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [carkom]
+---
+
+Expanding the menu for saving reports and adding hooks and logic.
-- 
GitLab