From e6459f306299aacc57538535636565f96eb6dd18 Mon Sep 17 00:00:00 2001
From: Jed Fox <git@jedfox.com>
Date: Fri, 3 Feb 2023 14:36:17 -0500
Subject: [PATCH] Add support for filtering the schedules table (#590)

* Add support for searching the schedules table at /schedules

* Move filtering logic into SchedulesTable

* Extract out a <Search> component

* Improve margins in Link Schedule dialog

* Add support for filtering in Link Schedule

* Add support for filtering by date

* rename param for clarity

* Remove unused imports

* Fix schedules with empty values always showing up in search results

* Fix matching behavior
---
 .../src/components/schedules/LinkSchedule.js  | 24 ++++++--
 .../components/schedules/SchedulesTable.js    | 59 ++++++++++++++++---
 .../src/components/schedules/index.js         | 15 ++++-
 packages/loot-design/src/components/common.js | 29 +++++++++
 4 files changed, 111 insertions(+), 16 deletions(-)

diff --git a/packages/desktop-client/src/components/schedules/LinkSchedule.js b/packages/desktop-client/src/components/schedules/LinkSchedule.js
index ddced09a6..d7289298a 100644
--- a/packages/desktop-client/src/components/schedules/LinkSchedule.js
+++ b/packages/desktop-client/src/components/schedules/LinkSchedule.js
@@ -1,9 +1,9 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useState } from 'react';
 import { useLocation, useHistory } from 'react-router-dom';
 
 import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
 import { send } from 'loot-core/src/platform/client/fetch';
-import { Text } from 'loot-design/src/components/common';
+import { Search, Text, View } from 'loot-design/src/components/common';
 
 import { Page } from '../Page';
 
@@ -16,6 +16,8 @@ export default function ScheduleLink() {
     useCallback(query => query.filter({ completed: false }), [])
   );
 
+  let [filter, setFilter] = useState('');
+
   if (scheduleData == null) {
     return null;
   }
@@ -35,15 +37,27 @@ export default function ScheduleLink() {
 
   return (
     <Page title="Link Schedule" modalSize="medium">
-      <Text style={{ marginBottom: 20 }}>
-        Choose a schedule to link these transactions to:
-      </Text>
+      <View
+        style={{ flexDirection: 'row', marginBottom: 20, alignItems: 'center' }}
+      >
+        <Text>Choose a schedule to link these transactions to:</Text>
+        <View style={{ flex: 1 }} />
+        <Search
+          isInModal
+          width={300}
+          placeholder="Filter schedules…"
+          value={filter}
+          onChange={setFilter}
+        />
+      </View>
 
       <SchedulesTable
         schedules={schedules}
+        filter={filter}
         statuses={statuses}
         minimal={true}
         onSelect={onSelect}
+        tableStyle={{ marginInline: -20 }}
       />
     </Page>
   );
diff --git a/packages/desktop-client/src/components/schedules/SchedulesTable.js b/packages/desktop-client/src/components/schedules/SchedulesTable.js
index 2986ad49b..878c32f71 100644
--- a/packages/desktop-client/src/components/schedules/SchedulesTable.js
+++ b/packages/desktop-client/src/components/schedules/SchedulesTable.js
@@ -1,6 +1,8 @@
 import React, { useState, useMemo } from 'react';
 import { useSelector } from 'react-redux';
 
+import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
+import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { getScheduledAmount } from 'loot-core/src/shared/schedules';
 import { integerToCurrency } from 'loot-core/src/shared/util';
@@ -126,11 +128,13 @@ export function ScheduleAmountCell({ amount, op }) {
 export function SchedulesTable({
   schedules,
   statuses,
+  filter,
   minimal,
   allowCompleted,
   style,
   onSelect,
-  onAction
+  onAction,
+  tableStyle
 }) {
   let dateFormat = useSelector(state => {
     return state.prefs.local.dateFormat || 'MM/dd/yyyy';
@@ -138,19 +142,56 @@ export function SchedulesTable({
 
   let [showCompleted, setShowCompleted] = useState(false);
 
+  let payees = useCachedPayees();
+  let accounts = useCachedAccounts();
+
+  let filteredSchedules = useMemo(() => {
+    if (!filter) {
+      return schedules;
+    }
+    const filterIncludes = str =>
+      str
+        ? str.toLowerCase().includes(filter.toLowerCase()) ||
+          filter.toLowerCase().includes(str.toLowerCase())
+        : false;
+
+    return schedules.filter(schedule => {
+      let payee = payees.find(p => schedule._payee === p.id);
+      let account = accounts.find(a => schedule._account === a.id);
+      let amount = getScheduledAmount(schedule._amount);
+      let amountStr =
+        (schedule._amountOp === 'isapprox' || schedule._amountOp === 'isbetween'
+          ? '~'
+          : '') +
+        (amount > 0 ? '+' : '') +
+        integerToCurrency(Math.abs(amount || 0));
+      let dateStr = schedule.next_date
+        ? monthUtils.format(schedule.next_date, dateFormat)
+        : null;
+
+      return (
+        filterIncludes(payee && payee.name) ||
+        filterIncludes(account && account.name) ||
+        filterIncludes(amountStr) ||
+        filterIncludes(statuses.get(schedule.id)) ||
+        filterIncludes(dateStr)
+      );
+    });
+  }, [schedules, filter, statuses]);
+
   let items = useMemo(() => {
     if (!allowCompleted) {
-      return schedules.filter(s => !s.completed);
+      return filteredSchedules.filter(s => !s.completed);
     }
     if (showCompleted) {
-      return schedules;
+      return filteredSchedules;
     }
-    let arr = schedules.filter(s => !s.completed);
-    if (schedules.find(s => s.completed)) {
+    let arr = filteredSchedules.filter(s => !s.completed);
+    if (filteredSchedules.find(s => s.completed)) {
       arr.push({ type: 'show-completed' });
     }
     return arr;
-  }, [schedules, showCompleted, allowCompleted]);
+  }, [filteredSchedules, showCompleted, allowCompleted]);
 
   function renderSchedule({ item }) {
     return (
@@ -231,7 +272,7 @@ export function SchedulesTable({
   }
 
   return (
-    <>
+    <View style={[{ flex: 1 }, tableStyle]}>
       <TableHeader height={ROW_HEIGHT} inset={15} version="v2">
         <Field width="flex">Payee</Field>
         <Field width="flex">Account</Field>
@@ -254,9 +295,9 @@ export function SchedulesTable({
         style={[{ flex: 1, backgroundColor: 'transparent' }, style]}
         items={items}
         renderItem={renderItem}
-        renderEmpty="No schedules"
+        renderEmpty={filter ? 'No matching schedules' : 'No schedules'}
         allowPopupsEscape={items.length < 6}
       />
-    </>
+    </View>
   );
 }
diff --git a/packages/desktop-client/src/components/schedules/index.js b/packages/desktop-client/src/components/schedules/index.js
index 08822c76e..a945113c5 100644
--- a/packages/desktop-client/src/components/schedules/index.js
+++ b/packages/desktop-client/src/components/schedules/index.js
@@ -1,9 +1,9 @@
-import React from 'react';
+import React, { useState } from 'react';
 import { useHistory } from 'react-router-dom';
 
 import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
 import { send } from 'loot-core/src/platform/client/fetch';
-import { View, Button } from 'loot-design/src/components/common';
+import { View, Button, Search } from 'loot-design/src/components/common';
 
 import { Page } from '../Page';
 
@@ -12,6 +12,8 @@ import { SchedulesTable, ROW_HEIGHT } from './SchedulesTable';
 export default function Schedules() {
   let history = useHistory();
 
+  let [filter, setFilter] = useState('');
+
   let scheduleData = useSchedules();
 
   if (scheduleData == null) {
@@ -58,6 +60,14 @@ export default function Schedules() {
 
   return (
     <Page title="Schedules">
+      <View style={{ alignItems: 'flex-end' }}>
+        <Search
+          placeholder="Filter schedules…"
+          value={filter}
+          onChange={setFilter}
+        />
+      </View>
+
       <View
         style={{
           marginTop: 20,
@@ -67,6 +77,7 @@ export default function Schedules() {
       >
         <SchedulesTable
           schedules={schedules}
+          filter={filter}
           statuses={statuses}
           allowCompleted={true}
           onSelect={onEdit}
diff --git a/packages/loot-design/src/components/common.js b/packages/loot-design/src/components/common.js
index c22fbc811..1cf92084e 100644
--- a/packages/loot-design/src/components/common.js
+++ b/packages/loot-design/src/components/common.js
@@ -422,6 +422,35 @@ export function InputWithContent({
   );
 }
 
+export function Search({
+  inputRef,
+  value,
+  onChange,
+  placeholder,
+  isInModal,
+  width = 350
+}) {
+  return (
+    <Input
+      inputRef={inputRef}
+      placeholder={placeholder}
+      value={value}
+      onChange={e => onChange(e.target.value)}
+      style={{
+        width,
+        borderColor: isInModal ? null : 'transparent',
+        backgroundColor: isInModal ? null : colors.n11,
+        ':focus': isInModal
+          ? null
+          : {
+              backgroundColor: 'white',
+              '::placeholder': { color: colors.n8 }
+            }
+      }}
+    />
+  );
+}
+
 export function KeyboardButton({ highlighted, children, ...props }) {
   return (
     <Button
-- 
GitLab