From 26f1d444f756d225653519bc7432060265e15574 Mon Sep 17 00:00:00 2001
From: Jed Fox <git@jedfox.com>
Date: Fri, 10 Feb 2023 14:21:10 -0500
Subject: [PATCH] Allow editing filters (#646)

* Make the filter text clickable

* Fix display of notes field in rules/filters

* Allow editing filters

* debugger
---
 .../src/components/ManageRules.js             |   2 +
 .../src/components/accounts/Account.js        |  14 +-
 .../src/components/accounts/Filters.js        | 170 +++++++++++++-----
 3 files changed, 142 insertions(+), 44 deletions(-)

diff --git a/packages/desktop-client/src/components/ManageRules.js b/packages/desktop-client/src/components/ManageRules.js
index 62ea2a204..836b7da87 100644
--- a/packages/desktop-client/src/components/ManageRules.js
+++ b/packages/desktop-client/src/components/ManageRules.js
@@ -109,6 +109,8 @@ export function Value({
           : null;
       } else if (field === 'year') {
         return value ? formatDate(parseISO(value), 'yyyy') : null;
+      } else if (field === 'notes') {
+        return value;
       } else {
         if (data && data.length) {
           let item = data.find(item => item.id === value);
diff --git a/packages/desktop-client/src/components/accounts/Account.js b/packages/desktop-client/src/components/accounts/Account.js
index f8fb0762d..e09d40917 100644
--- a/packages/desktop-client/src/components/accounts/Account.js
+++ b/packages/desktop-client/src/components/accounts/Account.js
@@ -636,6 +636,7 @@ const AccountHeader = React.memo(
     onBatchEdit,
     onBatchUnlink,
     onApplyFilter,
+    onUpdateFilter,
     onDeleteFilter,
     onScheduleAction
   }) => {
@@ -915,7 +916,11 @@ const AccountHeader = React.memo(
           </Stack>
 
           {filters && filters.length > 0 && (
-            <AppliedFilters filters={filters} onDelete={onDeleteFilter} />
+            <AppliedFilters
+              filters={filters}
+              onUpdate={onUpdateFilter}
+              onDelete={onDeleteFilter}
+            />
           )}
         </View>
         {reconcileAmount != null && (
@@ -1608,6 +1613,12 @@ class AccountInternal extends React.PureComponent {
     await this.refetchTransactions();
   };
 
+  onUpdateFilter = (oldFilter, updatedFilter) => {
+    this.applyFilters(
+      this.state.filters.map(f => (f === oldFilter ? updatedFilter : f))
+    );
+  };
+
   onDeleteFilter = filter => {
     this.applyFilters(this.state.filters.filter(f => f !== filter));
   };
@@ -1755,6 +1766,7 @@ class AccountInternal extends React.PureComponent {
                   onBatchDuplicate={this.onBatchDuplicate}
                   onBatchEdit={this.onBatchEdit}
                   onBatchUnlink={this.onBatchUnlink}
+                  onUpdateFilter={this.onUpdateFilter}
                   onDeleteFilter={this.onDeleteFilter}
                   onApplyFilter={this.onApplyFilter}
                   onScheduleAction={this.onScheduleAction}
diff --git a/packages/desktop-client/src/components/accounts/Filters.js b/packages/desktop-client/src/components/accounts/Filters.js
index bc5f91832..81cd69d4b 100644
--- a/packages/desktop-client/src/components/accounts/Filters.js
+++ b/packages/desktop-client/src/components/accounts/Filters.js
@@ -47,6 +47,23 @@ let filterFields = [
   'cleared'
 ].map(field => [field, mapField(field)]);
 
+function subfieldFromFilter({ field, options, value }) {
+  if (field === 'date') {
+    if (value.length === 7) {
+      return 'month';
+    } else if (value.length === 4) {
+      return 'year';
+    }
+  } else if (field === 'amount') {
+    if (options && options.inflow) {
+      return 'amount-inflow';
+    } else if (options && options.outflow) {
+      return 'amount-outflow';
+    }
+  }
+  return field;
+}
+
 function subfieldToOptions(field, subfield) {
   switch (field) {
     case 'amount':
@@ -111,8 +128,38 @@ function OpButton({ op, selected, style, onClick }) {
   );
 }
 
-function ConfigureField({ field, op, value, dispatch, onApply }) {
-  let [subfield, setSubfield] = useState(field);
+function updateFilterReducer(state, action) {
+  switch (action.type) {
+    case 'set-op': {
+      let type = FIELD_TYPES.get(state.field);
+      let value = state.value;
+      if (type === 'id' && action.op === 'contains') {
+        // Clear out the value if switching between contains for
+        // the id type
+        value = null;
+      }
+      return { ...state, op: action.op, value };
+    }
+    case 'set-value': {
+      let { value } = makeValue(action.value, {
+        type: FIELD_TYPES.get(state.field)
+      });
+      return { ...state, value: value };
+    }
+    default:
+      throw new Error(`Unhandled action type: ${action.type}`);
+  }
+}
+
+function ConfigureField({
+  field,
+  initialSubfield = field,
+  op,
+  value,
+  dispatch,
+  onApply
+}) {
+  let [subfield, setSubfield] = useState(initialSubfield);
   let inputRef = useRef();
   let prevOp = useRef(null);
 
@@ -272,25 +319,10 @@ export function FilterButton({ onApply }) {
             value: type === 'boolean' ? true : null
           };
         }
-        case 'set-op': {
-          let type = FIELD_TYPES.get(state.field);
-          let value = state.value;
-          if (type === 'id' && action.op === 'contains') {
-            // Clear out the value if switching between contains for
-            // the id type
-            value = null;
-          }
-          return { ...state, op: action.op, value };
-        }
-        case 'set-value':
-          let { value } = makeValue(action.value, {
-            type: FIELD_TYPES.get(state.field)
-          });
-          return { ...state, value: value };
         case 'close':
           return { fieldsOpen: false, condOpen: false, value: null };
         default:
-          throw new Error('Unknown action: ' + action.type);
+          return updateFilterReducer(state, action);
       }
     },
     { fieldsOpen: false, condOpen: false, field: null, value: null }
@@ -380,6 +412,36 @@ export function FilterButton({ onApply }) {
   );
 }
 
+function FilterEditor({ field, op, value, options, onSave, onClose }) {
+  let [state, dispatch] = useReducer(
+    (state, action) => {
+      switch (action.type) {
+        case 'close':
+          onClose();
+          return state;
+        default:
+          return updateFilterReducer(state, action);
+      }
+    },
+    { field, op, value, options }
+  );
+
+  return (
+    <ConfigureField
+      field={state.field}
+      initialSubfield={subfieldFromFilter({ field, options, value })}
+      op={state.op}
+      value={state.value}
+      options={state.options}
+      dispatch={dispatch}
+      onApply={cond => {
+        onSave(cond);
+        onClose();
+      }}
+    />
+  );
+}
+
 function FilterExpression({
   field: originalField,
   customName,
@@ -388,18 +450,12 @@ function FilterExpression({
   options,
   stage,
   style,
+  onChange,
   onDelete
 }) {
-  let type = FIELD_TYPES.get(originalField);
+  let [editing, setEditing] = useState(false);
 
-  let field = originalField;
-  if (type === 'date') {
-    if (value.length === 7) {
-      field = 'month';
-    } else if (value.length === 4) {
-      field = 'year';
-    }
-  }
+  let field = subfieldFromFilter({ field: originalField, value });
 
   return (
     <View
@@ -409,33 +465,60 @@ function FilterExpression({
           borderRadius: 4,
           flexDirection: 'row',
           alignItems: 'center',
-          padding: 5,
-          paddingLeft: 10,
           marginBottom: 10,
           marginRight: 10
         },
         style
       ]}
     >
-      <div>
-        {customName ? (
-          <Text style={{ color: colors.p4 }}>{customName}</Text>
-        ) : (
-          <>
-            <Text style={{ color: colors.p4 }}>{mapField(field, options)}</Text>{' '}
-            <Text style={{ color: colors.n3 }}>{friendlyOp(op)}</Text>{' '}
-            <Value value={value} field={field} inline={true} />
-          </>
-        )}
-      </div>
-      <Button bare style={{ marginLeft: 3 }} onClick={onDelete}>
-        <DeleteIcon style={{ width: 8, height: 8, color: colors.n4 }} />
+      <Button
+        bare
+        disabled={customName != null}
+        onClick={() => setEditing(true)}
+        style={{ marginRight: -7 }}
+      >
+        <div style={{ paddingBlock: 1, paddingLeft: 5, paddingRight: 2 }}>
+          {customName ? (
+            <Text style={{ color: colors.p4 }}>{customName}</Text>
+          ) : (
+            <>
+              <Text style={{ color: colors.p4 }}>
+                {mapField(field, options)}
+              </Text>{' '}
+              <Text style={{ color: colors.n3 }}>{friendlyOp(op)}</Text>{' '}
+              <Value value={value} field={field} inline={true} />
+            </>
+          )}
+        </div>
       </Button>
+      <Button bare onClick={onDelete}>
+        <DeleteIcon
+          style={{
+            width: 8,
+            height: 8,
+            color: colors.n4,
+            margin: 5,
+            marginLeft: 3
+          }}
+        />
+      </Button>
+      {editing && (
+        <FilterEditor
+          field={originalField}
+          customName={customName}
+          op={op}
+          value={value}
+          options={options}
+          stage={stage}
+          onSave={onChange}
+          onClose={() => setEditing(false)}
+        />
+      )}
     </View>
   );
 }
 
-export function AppliedFilters({ filters, editingFilter, onDelete }) {
+export function AppliedFilters({ filters, editingFilter, onUpdate, onDelete }) {
   return (
     <View
       style={{
@@ -455,6 +538,7 @@ export function AppliedFilters({ filters, editingFilter, onDelete }) {
           value={filter.value}
           options={filter.options}
           editing={editingFilter === filter}
+          onChange={newFilter => onUpdate(filter, newFilter)}
           onDelete={() => onDelete(filter)}
         />
       ))}
-- 
GitLab