From ed1e0ceb30c482adf4f0773750358231be957542 Mon Sep 17 00:00:00 2001 From: Neil <55785687+carkom@users.noreply.github.com> Date: Wed, 28 Feb 2024 21:50:23 +0000 Subject: [PATCH] Custom Reports AutoComplete (#2350) * updated saved work * merge fixes * Disable CREATE TABLE * notes * turn on db table * Fix TableGraph recall crash * table format changes * type fixes * fixing some card displays * merge fixes * revert table change * cardMenu width * Add Saved Reports Autocomplete * notes * fix invalid values crash * Title and auto-focus and esc * notes * fix filtering logic * reload saved filters * lint fix * visual graph changes * merge fixes * fix * review updates --- .../autocomplete/ReportAutocomplete.tsx | 34 ++++++ .../components/autocomplete/ReportList.tsx | 52 ++++++++ .../src/components/common/Menu.tsx | 4 +- .../src/components/reports/ReportOptions.ts | 2 + .../src/components/reports/ReportTopbar.jsx | 2 - .../src/components/reports/SaveReport.tsx | 31 ++++- .../components/reports/SaveReportChoose.tsx | 82 +++++++++++++ .../src/components/reports/SaveReportMenu.tsx | 114 ++++++++++-------- .../src/components/reports/SaveReportName.tsx | 8 +- .../reports/reports/CustomReport.jsx | 65 +++++----- .../src/components/util/GenericInput.jsx | 18 +++ .../loot-core/src/types/models/reports.d.ts | 8 +- upcoming-release-notes/2350.md | 6 + 13 files changed, 319 insertions(+), 107 deletions(-) create mode 100644 packages/desktop-client/src/components/autocomplete/ReportAutocomplete.tsx create mode 100644 packages/desktop-client/src/components/autocomplete/ReportList.tsx create mode 100644 packages/desktop-client/src/components/reports/SaveReportChoose.tsx create mode 100644 upcoming-release-notes/2350.md diff --git a/packages/desktop-client/src/components/autocomplete/ReportAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/ReportAutocomplete.tsx new file mode 100644 index 000000000..7a390bbd0 --- /dev/null +++ b/packages/desktop-client/src/components/autocomplete/ReportAutocomplete.tsx @@ -0,0 +1,34 @@ +import React, { type ComponentProps } from 'react'; + +import { useReports } from 'loot-core/client/data-hooks/reports'; +import { type CustomReportEntity } from 'loot-core/src/types/models/reports'; + +import { Autocomplete } from './Autocomplete'; +import { ReportList } from './ReportList'; + +export function ReportAutocomplete({ + embedded, + ...props +}: { + embedded?: boolean; +} & ComponentProps<typeof Autocomplete<CustomReportEntity>>) { + const reports = useReports() || []; + + return ( + <Autocomplete + strict={true} + highlightFirst={true} + embedded={embedded} + suggestions={reports} + renderItems={(items, getItemProps, highlightedIndex) => ( + <ReportList + items={items} + getItemProps={getItemProps} + highlightedIndex={highlightedIndex} + embedded={embedded} + /> + )} + {...props} + /> + ); +} diff --git a/packages/desktop-client/src/components/autocomplete/ReportList.tsx b/packages/desktop-client/src/components/autocomplete/ReportList.tsx new file mode 100644 index 000000000..81e3ff978 --- /dev/null +++ b/packages/desktop-client/src/components/autocomplete/ReportList.tsx @@ -0,0 +1,52 @@ +import React, { Fragment, type ComponentProps } from 'react'; + +import { theme } from '../../style/theme'; +import { View } from '../common/View'; + +import { ItemHeader } from './ItemHeader'; + +export function ReportList<T extends { id: string; name: string }>({ + items, + getItemProps, + highlightedIndex, + embedded, +}: { + items: T[]; + getItemProps: (arg: { item: T }) => ComponentProps<typeof View>; + highlightedIndex: number; + embedded?: boolean; +}) { + return ( + <View> + <View + style={{ + overflow: 'auto', + padding: '5px 0', + ...(!embedded && { maxHeight: 175 }), + }} + > + <Fragment>{ItemHeader({ title: 'Saved Reports' })}</Fragment> + {items.map((item, idx) => { + return [ + <div + {...(getItemProps ? getItemProps({ item }) : null)} + key={item.id} + style={{ + backgroundColor: + highlightedIndex === idx + ? theme.menuAutoCompleteBackgroundHover + : 'transparent', + padding: 4, + paddingLeft: 20, + borderRadius: embedded ? 4 : 0, + }} + data-highlighted={highlightedIndex === idx || undefined} + > + {item.name} + </div>, + ]; + })} + </View> + </View> + ); +} diff --git a/packages/desktop-client/src/components/common/Menu.tsx b/packages/desktop-client/src/components/common/Menu.tsx index 57dd789ab..4577a2756 100644 --- a/packages/desktop-client/src/components/common/Menu.tsx +++ b/packages/desktop-client/src/components/common/Menu.tsx @@ -38,11 +38,11 @@ type MenuItem = { tooltip?: string; }; -type MenuProps<T extends MenuItem = MenuItem> = { +export type MenuProps<T extends MenuItem = MenuItem> = { header?: ReactNode; footer?: ReactNode; items: Array<T | typeof Menu.line>; - onMenuSelect: (itemName: T['name']) => void; + onMenuSelect?: (itemName: T['name']) => void; style?: CSSProperties; }; diff --git a/packages/desktop-client/src/components/reports/ReportOptions.ts b/packages/desktop-client/src/components/reports/ReportOptions.ts index 1f868ef1f..523e024ac 100644 --- a/packages/desktop-client/src/components/reports/ReportOptions.ts +++ b/packages/desktop-client/src/components/reports/ReportOptions.ts @@ -11,6 +11,8 @@ const startDate = monthUtils.subMonths(monthUtils.currentMonth(), 5); const endDate = monthUtils.currentMonth(); export const defaultReport: CustomReportEntity = { + id: '', + name: '', startDate, endDate, isDateStatic: false, diff --git a/packages/desktop-client/src/components/reports/ReportTopbar.jsx b/packages/desktop-client/src/components/reports/ReportTopbar.jsx index 5ffeaee5c..81728a30b 100644 --- a/packages/desktop-client/src/components/reports/ReportTopbar.jsx +++ b/packages/desktop-client/src/components/reports/ReportTopbar.jsx @@ -30,7 +30,6 @@ export function ReportTopbar({ onApplyFilter, onChangeViews, onReportChange, - onResetReports, }) { return ( <View @@ -179,7 +178,6 @@ export function ReportTopbar({ 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 4200d781f..a71416e32 100644 --- a/packages/desktop-client/src/components/reports/SaveReport.tsx +++ b/packages/desktop-client/src/components/reports/SaveReport.tsx @@ -1,5 +1,6 @@ import React, { createRef, useState } from 'react'; +import { useReports } from 'loot-core/client/data-hooks/reports'; import { send, sendCatch } from 'loot-core/src/platform/client/fetch'; import { type CustomReportEntity } from 'loot-core/src/types/models'; @@ -8,6 +9,7 @@ import { Button } from '../common/Button'; import { Text } from '../common/Text'; import { View } from '../common/View'; +import { SaveReportChoose } from './SaveReportChoose'; import { SaveReportMenu } from './SaveReportMenu'; import { SaveReportName } from './SaveReportName'; @@ -22,7 +24,6 @@ type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = { savedReport?: T; type: string; }) => void; - onResetReports: () => void; }; export function SaveReport({ @@ -30,15 +31,23 @@ export function SaveReport({ report, savedStatus, onReportChange, - onResetReports, }: SaveReportProps) { + const listReports = useReports(); const [nameMenuOpen, setNameMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + const [chooseMenuOpen, setChooseMenuOpen] = useState(false); const [menuItem, setMenuItem] = useState(''); const [err, setErr] = useState(''); const [name, setName] = useState(report.name ?? ''); const inputRef = createRef<HTMLInputElement>(); + async function onApply(cond: string) { + const chooseSavedReport = listReports.find(r => cond === r.id); + onReportChange({ savedReport: chooseSavedReport, type: 'choose' }); + setChooseMenuOpen(false); + setName(chooseSavedReport === undefined ? '' : chooseSavedReport.name); + } + const onAddUpdate = async (menuChoice: string) => { if (menuChoice === 'save-report') { const newSavedReport = { @@ -78,7 +87,6 @@ export function SaveReport({ setNameMenuOpen(true); return; } - setNameMenuOpen(false); onReportChange({ savedReport: updatedReport, @@ -98,7 +106,7 @@ export function SaveReport({ setMenuOpen(false); setName(''); await send('report/delete', report.id); - onResetReports(); + onReportChange({ type: 'reset' }); break; case 'update-report': setErr(''); @@ -117,7 +125,12 @@ export function SaveReport({ case 'reset-report': setMenuOpen(false); setName(''); - onResetReports(); + onReportChange({ type: 'reset' }); + break; + case 'choose-report': + setErr(''); + setMenuOpen(false); + setChooseMenuOpen(true); break; default: } @@ -153,9 +166,9 @@ export function SaveReport({ {menuOpen && ( <SaveReportMenu onClose={() => setMenuOpen(false)} - report={report} onMenuSelect={onMenuSelect} savedStatus={savedStatus} + listReports={listReports && listReports.length} /> )} {nameMenuOpen && ( @@ -169,6 +182,12 @@ export function SaveReport({ err={err} /> )} + {chooseMenuOpen && ( + <SaveReportChoose + onApply={onApply} + onClose={() => setChooseMenuOpen(false)} + /> + )} </View> ); } diff --git a/packages/desktop-client/src/components/reports/SaveReportChoose.tsx b/packages/desktop-client/src/components/reports/SaveReportChoose.tsx new file mode 100644 index 000000000..2a6b3f123 --- /dev/null +++ b/packages/desktop-client/src/components/reports/SaveReportChoose.tsx @@ -0,0 +1,82 @@ +import React, { createRef, useEffect, useState } from 'react'; + +import { theme } from '../../style/theme'; +import { Button } from '../common/Button'; +import { Stack } from '../common/Stack'; +import { Text } from '../common/Text'; +import { View } from '../common/View'; +import { Tooltip } from '../tooltips'; +import { GenericInput } from '../util/GenericInput'; + +type SaveReportChooseProps = { + onApply: (cond: string) => void; + onClose: () => void; +}; + +export function SaveReportChoose({ onApply, onClose }: SaveReportChooseProps) { + const inputRef = createRef<HTMLInputElement>(); + const [err, setErr] = useState(''); + const [value, setValue] = useState(''); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }); + + return ( + <Tooltip + position="bottom-right" + style={{ padding: 15, color: theme.menuItemText }} + width={275} + onClose={onClose} + > + <form> + <View style={{ flexDirection: 'row', align: 'center' }}> + <Text style={{ userSelect: 'none', flex: 1 }}>Choose Report</Text> + <View style={{ flex: 1 }} /> + </View> + <GenericInput + inputRef={inputRef} + field="report" + subfield={null} + type="saved" + value={value} + multi={false} + style={{ marginTop: 10 }} + onChange={(v: string) => setValue(v)} + /> + + <Stack + direction="row" + justify="flex-end" + align="center" + style={{ marginTop: 15 }} + > + <View style={{ flex: 1 }} /> + <Button + type="primary" + onClick={e => { + e.preventDefault(); + if (!value) { + setErr('Invalid report entered'); + return; + } + + onApply(value); + }} + > + Apply + </Button> + </Stack> + </form> + {err !== '' ? ( + <Stack direction="row" align="center" style={{ padding: 10 }}> + <Text style={{ color: theme.errorText }}>{err}</Text> + </Stack> + ) : ( + <View /> + )} + </Tooltip> + ); +} diff --git a/packages/desktop-client/src/components/reports/SaveReportMenu.tsx b/packages/desktop-client/src/components/reports/SaveReportMenu.tsx index 01e311dd2..9b5ec358e 100644 --- a/packages/desktop-client/src/components/reports/SaveReportMenu.tsx +++ b/packages/desktop-client/src/components/reports/SaveReportMenu.tsx @@ -1,75 +1,83 @@ import React from 'react'; -import { type CustomReportEntity } from 'loot-core/src/types/models'; - -import { Menu } from '../common/Menu'; +import { Menu, type MenuProps } from '../common/Menu'; import { MenuTooltip } from '../common/MenuTooltip'; export function SaveReportMenu({ - report, onClose, onMenuSelect, savedStatus, + listReports, }: { - report: CustomReportEntity; onClose: () => void; onMenuSelect: (item: string) => void; savedStatus: string; + listReports: number; }) { + const savedMenu: MenuProps = + savedStatus === 'saved' + ? { + items: [ + { name: 'rename-report', text: 'Rename' }, + { name: 'delete-report', text: 'Delete' }, + Menu.line, + ], + } + : { + items: [], + }; + + const modifiedMenu: MenuProps = + savedStatus === 'modified' + ? { + items: [ + { name: 'rename-report', text: 'Rename' }, + { + name: 'update-report', + text: 'Update report', + }, + { + name: 'reload-report', + text: 'Revert changes', + }, + { name: 'delete-report', text: 'Delete' }, + Menu.line, + ], + } + : { + items: [], + }; + + const unsavedMenu: MenuProps = { + items: [ + { + name: 'save-report', + text: 'Save new report', + }, + { + name: 'reset-report', + text: 'Reset to default', + }, + Menu.line, + { + name: 'choose-report', + text: 'Choose Report', + disabled: listReports > 0 ? false : true, + }, + ], + }; + 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', - }, - ] - } + items={[ + ...savedMenu.items, + ...modifiedMenu.items, + ...unsavedMenu.items, + ]} /> </MenuTooltip> ); diff --git a/packages/desktop-client/src/components/reports/SaveReportName.tsx b/packages/desktop-client/src/components/reports/SaveReportName.tsx index c6ef39ed6..828a20a60 100644 --- a/packages/desktop-client/src/components/reports/SaveReportName.tsx +++ b/packages/desktop-client/src/components/reports/SaveReportName.tsx @@ -6,6 +6,7 @@ import { Input } from '../common/Input'; import { MenuTooltip } from '../common/MenuTooltip'; import { Stack } from '../common/Stack'; import { Text } from '../common/Text'; +import { View } from '../common/View'; import { FormField, FormLabel } from '../forms'; type SaveReportNameProps = { @@ -41,7 +42,7 @@ export function SaveReportName({ direction="row" justify="flex-end" align="center" - style={{ padding: 10 }} + style={{ padding: 15 }} > <FormField style={{ flex: 1 }}> <FormLabel @@ -54,11 +55,12 @@ export function SaveReportName({ id="name-field" inputRef={inputRef} onUpdate={setName} + style={{ marginTop: 10 }} /> </FormField> <Button type="primary" - style={{ marginTop: 18 }} + style={{ marginTop: 30 }} onClick={e => { e.preventDefault(); onAddUpdate(menuItem); @@ -74,7 +76,7 @@ export function SaveReportName({ <Text style={{ color: theme.errorText }}>{err}</Text> </Stack> ) : ( - <Text /> + <View /> )} </MenuTooltip> ); diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx index 93559528d..ed0bd28d1 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx @@ -248,30 +248,29 @@ export function CustomReport() { } }; - const onResetReports = () => { + const setReportData = input => { 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); - setInterval(defaultReport.interval); - setBalanceType(defaultReport.balanceType); - setShowEmpty(defaultReport.showEmpty); - setShowOffBudget(defaultReport.showOffBudget); - setShowHiddenCategories(defaultReport.showHiddenCategories); - setShowUncategorized(defaultReport.showUncategorized); - setSelectedCategories(selectAll); - setGraphType(defaultReport.graphType); + setStartDate(input.startDate); + setEndDate(input.endDate); + setIsDateStatic(input.isDateStatic); + setDateRange(input.dateRange); + setMode(input.mode); + setGroupBy(input.groupBy); + setInterval(input.interval); + setBalanceType(input.balanceType); + setShowEmpty(input.showEmpty); + setShowOffBudget(input.showOffBudget); + setShowHiddenCategories(input.showHiddenCategories); + setShowUncategorized(input.showUncategorized); + setSelectedCategories(input.selectedCategories ?? selectAll); + setGraphType(input.graphType); onApplyFilter(null); - onCondOpChange(defaultReport.conditionsOp); - setReport(defaultReport); - setSavedStatus('new'); + input.conditions.forEach(condition => onApplyFilter(condition)); + onCondOpChange(input.conditionsOp); }; const onChangeAppliedFilter = (filter, changedElement) => { @@ -295,24 +294,17 @@ export function CustomReport() { break; case 'reload': setSavedStatus('saved'); - - setStartDate(report.startDate); - setEndDate(report.endDate); - setIsDateStatic(report.isDateStatic); - setDateRange(report.dateRange); - setMode(report.mode); - setGroupBy(report.groupBy); - setInterval(report.interval); - setBalanceType(report.balanceType); - setShowEmpty(report.showEmpty); - setShowOffBudget(report.showOffBudget); - setShowHiddenCategories(report.showHiddenCategories); - setShowUncategorized(report.showUncategorized); - setSelectedCategories(report.selectedCategories); - setGraphType(report.graphType); - onApplyFilter(null); - report.conditions.forEach(condition => onApplyFilter(condition)); - onCondOpChange(report.conditionsOp); + setReportData(report); + break; + case 'reset': + setSavedStatus('new'); + setReport(defaultReport); + setReportData(defaultReport); + break; + case 'choose': + setSavedStatus('saved'); + setReport(savedReport); + setReportData(savedReport); break; default: } @@ -372,7 +364,6 @@ export function CustomReport() { onApplyFilter={onApplyFilter} onChangeViews={onChangeViews} onReportChange={onReportChange} - onResetReports={onResetReports} /> {filters && filters.length > 0 && ( <View diff --git a/packages/desktop-client/src/components/util/GenericInput.jsx b/packages/desktop-client/src/components/util/GenericInput.jsx index c2e1ec23a..3c46d3a43 100644 --- a/packages/desktop-client/src/components/util/GenericInput.jsx +++ b/packages/desktop-client/src/components/util/GenericInput.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; +import { useReports } from 'loot-core/client/data-hooks/reports'; import { getMonthYearFormat } from 'loot-core/src/shared/months'; import { useCategories } from '../../hooks/useCategories'; @@ -10,6 +11,7 @@ import { Autocomplete } from '../autocomplete/Autocomplete'; import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete'; import { FilterAutocomplete } from '../autocomplete/FilterAutocomplete'; import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete'; +import { ReportAutocomplete } from '../autocomplete/ReportAutocomplete'; import { Input } from '../common/Input'; import { View } from '../common/View'; import { Checkbox } from '../forms'; @@ -27,6 +29,7 @@ export function GenericInput({ onChange, }) { const { grouped: categoryGroups } = useCategories(); + const savedReports = useReports(); const saved = useSelector(state => state.queries.saved); const dateFormat = useDateFormat() || 'MM/dd/yyyy'; @@ -111,6 +114,21 @@ export function GenericInput({ /> ); break; + case 'report': + content = ( + <ReportAutocomplete + saved={savedReports} + value={value} + multi={multi} + openOnFocus={true} + onSelect={onChange} + inputProps={{ + inputRef, + ...(showPlaceholder ? { placeholder: 'nothing' } : null), + }} + /> + ); + break; default: } diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts index cb966d6e3..4d6464573 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; - name?: string; + id: string; + name: string; startDate: string; endDate: string; isDateStatic: boolean; @@ -73,8 +73,8 @@ export type Month = { }; export interface CustomReportData { - id?: string; - name?: string; + id: string; + name: string; start_date: string; end_date: string; date_static: number; diff --git a/upcoming-release-notes/2350.md b/upcoming-release-notes/2350.md new file mode 100644 index 000000000..413640133 --- /dev/null +++ b/upcoming-release-notes/2350.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [carkom] +--- + +Creating an autocomplete for custom reports so they can be recalled without switching back to the dashboard. -- GitLab