Convert FiltersMenu to Typescript (part 1) (#2231)

* migration work

* notes

* typecheck

* typecheck fixes

* fixes

* merge fixes

* typecheck updates

* review fixes
This commit is contained in:
Neil
2024-02-02 12:10:24 -08:00
committed by GitHub
parent 39e7f2598b
commit 5914469b11
14 changed files with 351 additions and 264 deletions

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { type RuleConditionEntity } from 'loot-core/types/models';
import { View } from '../common/View';
import { FilterExpression } from './FilterExpression';
import { CondOpMenu } from './SavedFilters';
type AppliedFiltersProps = {
filters: RuleConditionEntity[];
onUpdate: (
filter: RuleConditionEntity,
newFilter: RuleConditionEntity,
) => RuleConditionEntity;
onDelete: (filter: RuleConditionEntity) => void;
conditionsOp: string;
onCondOpChange: () => void;
};
export function AppliedFilters({
filters,
onUpdate,
onDelete,
conditionsOp,
onCondOpChange,
}: AppliedFiltersProps) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
flexWrap: 'wrap',
}}
>
<CondOpMenu
conditionsOp={conditionsOp}
onCondOpChange={onCondOpChange}
filters={filters}
/>
{filters.map((filter: RuleConditionEntity, i: number) => (
<FilterExpression
key={i}
customName={filter.customName}
field={filter.field}
op={filter.op}
value={filter.value}
options={filter.options}
onChange={newFilter => onUpdate(filter, newFilter)}
onDelete={() => onDelete(filter)}
/>
))}
</View>
);
}

View File

@@ -1,14 +1,9 @@
// @ts-strict-ignore
import React from 'react';
import { SvgFilter } from '../../icons/v1';
import { Button } from '../common/Button';
type CompactFiltersButtonProps = {
onClick: (newValue) => void;
};
export function CompactFiltersButton({ onClick }: CompactFiltersButtonProps) {
export function CompactFiltersButton({ onClick }: { onClick: () => void }) {
return (
<Button type="bare" onClick={onClick}>
<SvgFilter width={15} height={15} />

View File

@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { mapField, friendlyOp } from 'loot-core/src/shared/rules';
import { integerToCurrency } from 'loot-core/src/shared/util';
import {
type RuleConditionOp,
type RuleConditionEntity,
} from 'loot-core/src/types/models';
import { SvgDelete } from '../../icons/v0';
import { type CSSProperties, theme } from '../../style';
import { Button } from '../common/Button';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { Value } from '../rules/Value';
import { FilterEditor } from './FiltersMenu';
import { subfieldFromFilter } from './subfieldFromFilter';
type FilterExpressionProps = {
field: string | undefined;
customName: string | undefined;
op: RuleConditionOp | undefined;
value: string | string[] | number | boolean | undefined;
options: RuleConditionEntity['options'];
style?: CSSProperties;
onChange: (cond: RuleConditionEntity) => RuleConditionEntity;
onDelete: () => void;
};
export function FilterExpression({
field: originalField,
customName,
op,
value,
options,
style,
onChange,
onDelete,
}: FilterExpressionProps) {
const [editing, setEditing] = useState(false);
const field = subfieldFromFilter({ field: originalField, value });
return (
<View
style={{
backgroundColor: theme.pillBackground,
borderRadius: 4,
flexDirection: 'row',
alignItems: 'center',
marginRight: 10,
marginTop: 10,
...style,
}}
>
<Button
type="bare"
disabled={customName != null}
onClick={() => setEditing(true)}
style={{ marginRight: -7 }}
>
<div style={{ paddingBlock: 1, paddingLeft: 5, paddingRight: 2 }}>
{customName ? (
<Text style={{ color: theme.pageTextPositive }}>{customName}</Text>
) : (
<>
<Text style={{ color: theme.pageTextPositive }}>
{mapField(field, options)}
</Text>{' '}
<Text>{friendlyOp(op, null)}</Text>{' '}
<Value
value={value}
field={field}
inline={true}
valueIsRaw={op === 'contains' || op === 'doesNotContain'}
/>
</>
)}
</div>
</Button>
<Button type="bare" onClick={onDelete} aria-label="Delete filter">
<SvgDelete
style={{
width: 8,
height: 8,
margin: 5,
marginLeft: 3,
}}
/>
</Button>
{editing && (
<FilterEditor
field={originalField}
op={op}
value={
field === 'amount' && typeof value === 'number'
? integerToCurrency(value)
: value
}
options={options}
onSave={onChange}
onClose={() => setEditing(false)}
/>
)}
</View>
);
}

View File

@@ -1,14 +1,9 @@
// @ts-strict-ignore
import React from 'react';
import { SvgSettingsSliderAlternate } from '../../icons/v2';
import { Button } from '../common/Button';
type FiltersButtonProps = {
onClick: (newValue) => void;
};
export function FiltersButton({ onClick }: FiltersButtonProps) {
export function FiltersButton({ onClick }: { onClick: () => void }) {
return (
<Button type="bare" onClick={onClick} title="Filters">
<SvgSettingsSliderAlternate

View File

@@ -13,17 +13,14 @@ import { send } from 'loot-core/src/platform/client/fetch';
import { getMonthYearFormat } from 'loot-core/src/shared/months';
import {
mapField,
friendlyOp,
deserializeField,
getFieldError,
unparse,
makeValue,
FIELD_TYPES,
TYPE_INFO,
} from 'loot-core/src/shared/rules';
import { titleFirst, integerToCurrency } from 'loot-core/src/shared/util';
import { titleFirst } from 'loot-core/src/shared/util';
import { SvgDelete } from '../../icons/v0';
import { theme } from '../../style';
import { Button } from '../common/Button';
import { HoverTarget } from '../common/HoverTarget';
@@ -32,13 +29,15 @@ import { Select } from '../common/Select';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { Value } from '../rules/Value';
import { Tooltip } from '../tooltips';
import { GenericInput } from '../util/GenericInput';
import { CompactFiltersButton } from './CompactFiltersButton';
import { FiltersButton } from './FiltersButton';
import { CondOpMenu } from './SavedFilters';
import { OpButton } from './OpButton';
import { subfieldFromFilter } from './subfieldFromFilter';
import { subfieldToOptions } from './subfieldToOptions';
import { updateFilterReducer } from './updateFilterReducer';
const filterFields = [
'date',
@@ -52,100 +51,6 @@ const filterFields = [
'saved',
].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':
switch (subfield) {
case 'amount-inflow':
return { inflow: true };
case 'amount-outflow':
return { outflow: true };
default:
return null;
}
case 'date':
switch (subfield) {
case 'month':
return { month: true };
case 'year':
return { year: true };
default:
return null;
}
default:
return null;
}
}
function OpButton({ op, selected, style, onClick }) {
return (
<Button
type="bare"
style={{
backgroundColor: theme.pillBackground,
marginBottom: 5,
...style,
...(selected && {
color: theme.buttonNormalSelectedText,
'&,:hover,:active': {
backgroundColor: theme.buttonNormalSelectedBackground,
color: theme.buttonNormalSelectedText,
},
}),
}}
onClick={onClick}
>
{friendlyOp(op)}
</Button>
);
}
function updateFilterReducer(state, action) {
switch (action.type) {
case 'set-op': {
const type = FIELD_TYPES.get(state.field);
let value = state.value;
if (
(type === 'id' || type === 'string') &&
(action.op === 'contains' ||
action.op === 'is' ||
action.op === 'doesNotContain' ||
action.op === 'isNot')
) {
// Clear out the value if switching between contains or
// is/oneof for the id or string type
value = null;
}
return { ...state, op: action.op, value };
}
case 'set-value': {
const { value } = makeValue(action.value, {
type: FIELD_TYPES.get(state.field),
});
return { ...state, value };
}
default:
throw new Error(`Unhandled action type: ${action.type}`);
}
}
function ConfigureField({
field,
initialSubfield = field,
@@ -478,7 +383,7 @@ export function FilterButton({ onApply, compact, hover }) {
);
}
function FilterEditor({ field, op, value, options, onSave, onClose }) {
export function FilterEditor({ field, op, value, options, onSave, onClose }) {
const [state, dispatch] = useReducer(
(state, action) => {
switch (action.type) {
@@ -508,119 +413,3 @@ function FilterEditor({ field, op, value, options, onSave, onClose }) {
/>
);
}
function FilterExpression({
field: originalField,
customName,
op,
value,
options,
stage,
style,
onChange,
onDelete,
}) {
const [editing, setEditing] = useState(false);
const field = subfieldFromFilter({ field: originalField, value });
return (
<View
style={{
backgroundColor: theme.pillBackground,
borderRadius: 4,
flexDirection: 'row',
alignItems: 'center',
marginRight: 10,
marginTop: 10,
...style,
}}
>
<Button
type="bare"
disabled={customName != null}
onClick={() => setEditing(true)}
style={{ marginRight: -7 }}
>
<div style={{ paddingBlock: 1, paddingLeft: 5, paddingRight: 2 }}>
{customName ? (
<Text style={{ color: theme.pageTextPositive }}>{customName}</Text>
) : (
<>
<Text style={{ color: theme.pageTextPositive }}>
{mapField(field, options)}
</Text>{' '}
<Text>{friendlyOp(op, null)}</Text>{' '}
<Value
value={value}
field={field}
inline={true}
valueIsRaw={op === 'contains' || op === 'doesNotContain'}
/>
</>
)}
</div>
</Button>
<Button type="bare" onClick={onDelete} aria-label="Delete filter">
<SvgDelete
style={{
width: 8,
height: 8,
margin: 5,
marginLeft: 3,
}}
/>
</Button>
{editing && (
<FilterEditor
field={originalField}
customName={customName}
op={op}
value={field === 'amount' ? integerToCurrency(value) : value}
options={options}
stage={stage}
onSave={onChange}
onClose={() => setEditing(false)}
/>
)}
</View>
);
}
export function AppliedFilters({
filters,
editingFilter,
onUpdate,
onDelete,
conditionsOp,
onCondOpChange,
}) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
flexWrap: 'wrap',
}}
>
<CondOpMenu
conditionsOp={conditionsOp}
onCondOpChange={onCondOpChange}
filters={filters}
/>
{filters.map((filter, i) => (
<FilterExpression
key={i}
customName={filter.customName}
field={filter.field}
op={filter.op}
value={filter.value}
options={filter.options}
editing={editingFilter === filter}
onChange={newFilter => onUpdate(filter, newFilter)}
onDelete={() => onDelete(filter)}
/>
))}
</View>
);
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { friendlyOp } from 'loot-core/src/shared/rules';
import { type CSSProperties, theme } from '../../style';
import { Button } from '../common/Button';
type OpButtonProps = {
op: string;
selected: boolean;
onClick: () => void;
style?: CSSProperties;
};
export function OpButton({ op, selected, style, onClick }: OpButtonProps) {
return (
<Button
type="bare"
style={{
backgroundColor: theme.pillBackground,
marginBottom: 5,
...style,
...(selected && {
color: theme.buttonNormalSelectedText,
'&,:hover,:active': {
backgroundColor: theme.buttonNormalSelectedBackground,
color: theme.buttonNormalSelectedText,
},
}),
}}
onClick={onClick}
>
{friendlyOp(op)}
</Button>
);
}

View File

@@ -14,7 +14,7 @@ import { FormField, FormLabel } from '../forms';
import { FieldSelect } from '../modals/EditRule';
import { GenericInput } from '../util/GenericInput';
import { AppliedFilters } from './FiltersMenu';
import { AppliedFilters } from './AppliedFilters';
function FilterMenu({ onClose, filterId, onFilterMenuSelect }) {
return (
@@ -285,21 +285,21 @@ function SavedFilterMenuButton({
}
export function CondOpMenu({ conditionsOp, onCondOpChange, filters }) {
return (
filters.length > 1 && (
<Text style={{ color: theme.pageText, marginTop: 11, marginRight: 5 }}>
<FieldSelect
style={{ display: 'inline-flex' }}
fields={[
['and', 'all'],
['or', 'any'],
]}
value={conditionsOp}
onChange={(name, value) => onCondOpChange(value, filters)}
/>
of:
</Text>
)
return filters.length > 1 ? (
<Text style={{ color: theme.pageText, marginTop: 11, marginRight: 5 }}>
<FieldSelect
style={{ display: 'inline-flex' }}
fields={[
['and', 'all'],
['or', 'any'],
]}
value={conditionsOp}
onChange={(name, value) => onCondOpChange(value, filters)}
/>
of:
</Text>
) : (
<View />
);
}

View File

@@ -0,0 +1,27 @@
import { type RuleConditionEntity } from 'loot-core/src/types/models';
export function subfieldFromFilter({
field,
options,
value,
}: RuleConditionEntity) {
if (field === 'date') {
if (typeof value === 'string') {
if (value.length === 7) {
return 'month';
} else if (value.length === 4) {
return 'year';
}
}
}
if (field === 'amount') {
if (options && options.inflow) {
return 'amount-inflow';
} else if (options && options.outflow) {
return 'amount-outflow';
}
}
return field;
}

View File

@@ -0,0 +1,34 @@
import { type RuleConditionEntity } from 'loot-core/src/types/models';
export function subfieldToOptions(field: string, subfield: string) {
let setOptions: RuleConditionEntity['options'];
switch (field) {
case 'amount':
switch (subfield) {
case 'amount-inflow':
setOptions = { inflow: true };
break;
case 'amount-outflow':
setOptions = { outflow: true };
break;
default:
break;
}
break;
case 'date':
switch (subfield) {
case 'month':
setOptions = { month: true };
break;
case 'year':
setOptions = { year: true };
break;
default:
break;
}
break;
default:
break;
}
return setOptions;
}

View File

@@ -0,0 +1,34 @@
import { makeValue, FIELD_TYPES } from 'loot-core/src/shared/rules';
import { type RuleConditionEntity } from 'loot-core/src/types/models';
export function updateFilterReducer(
state: { field: string; value: string | string[] | number | boolean | null },
action: RuleConditionEntity,
) {
switch (action.type) {
case 'set-op': {
const type = FIELD_TYPES.get(state.field);
let value = state.value;
if (
(type === 'id' || type === 'string') &&
(action.op === 'contains' ||
action.op === 'is' ||
action.op === 'doesNotContain' ||
action.op === 'isNot')
) {
// Clear out the value if switching between contains or
// is/oneof for the id or string type
value = null;
}
return { ...state, op: action.op, value };
}
case 'set-value': {
const { value } = makeValue(action.value, {
type: FIELD_TYPES.get(state.field),
});
return { ...state, value };
}
default:
throw new Error(`Unhandled action type: ${action.type}`);
}
}

View File

@@ -8,7 +8,8 @@ import { Button } from '../common/Button';
import { ButtonLink } from '../common/ButtonLink';
import { Select } from '../common/Select';
import { View } from '../common/View';
import { FilterButton, AppliedFilters } from '../filters/FiltersMenu';
import { AppliedFilters } from '../filters/AppliedFilters';
import { FilterButton } from '../filters/FiltersMenu';
export function validateStart(allMonths, start, end) {
const earliest = allMonths[allMonths.length - 1].name;

View File

@@ -18,7 +18,7 @@ import { AlignedText } from '../../common/AlignedText';
import { Block } from '../../common/Block';
import { Text } from '../../common/Text';
import { View } from '../../common/View';
import { AppliedFilters } from '../../filters/FiltersMenu';
import { AppliedFilters } from '../../filters/AppliedFilters';
import { PrivacyFilter } from '../../PrivacyFilter';
import { ChooseGraph } from '../ChooseGraph';
import { Header } from '../Header';

View File

@@ -9,24 +9,31 @@ export interface RuleEntity {
tombstone?: boolean;
}
export type RuleConditionOp =
| 'is'
| 'isNot'
| 'oneOf'
| 'notOneOf'
| 'isapprox'
| 'isbetween'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'doesNotContain';
export interface RuleConditionEntity {
field: unknown;
op:
| 'is'
| 'isNot'
| 'oneOf'
| 'notOneOf'
| 'isapprox'
| 'isbetween'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'doesNotContain';
value: unknown;
options?: unknown;
conditionsOp?: unknown;
field?: string;
op?: RuleConditionOp;
value?: string | string[] | number | boolean;
options?: {
inflow?: boolean;
outflow?: boolean;
month?: boolean;
year?: boolean;
};
conditionsOp?: string;
type?: string;
customName?: string;
}

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [carkom]
---
Split out mega-file FiltersMenu.jsx into separate elements and converted them all to Typescript.