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
This commit is contained in:
Neil
2024-02-28 21:50:23 +00:00
committed by GitHub
parent 55817b0e70
commit ed1e0ceb30
13 changed files with 319 additions and 107 deletions

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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:
}

View File

@@ -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;

View File

@@ -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.