Convert FiltersMenu to TypeScript (#6091)

This commit is contained in:
Matiss Janis Aboltins
2025-11-10 21:40:05 +00:00
committed by GitHub
parent 717a7db9e7
commit f41386b753
8 changed files with 128 additions and 62 deletions

View File

@@ -30,7 +30,8 @@ import {
FIELD_TYPES,
getValidOps,
} from 'loot-core/shared/rules';
import { titleFirst } from 'loot-core/shared/util';
import { type IntegerAmount, titleFirst } from 'loot-core/shared/util';
import { type RuleConditionEntity } from 'loot-core/types/models';
import { CompactFiltersButton } from './CompactFiltersButton';
import { FiltersButton } from './FiltersButton';
@@ -45,6 +46,18 @@ import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { useTransactionFilters } from '@desktop-client/hooks/useTransactionFilters';
type FilterReducerState<T extends RuleConditionEntity> = Pick<
T,
'value' | 'op' | 'field'
>;
type FilterReducerAction =
| { type: 'close' }
| Parameters<typeof updateFilterReducer>[1];
type AmountInputRef = HTMLInputElement & {
getCurrentAmount: () => IntegerAmount;
};
let isDatepickerClick = false;
const filterFields = [
@@ -60,19 +73,27 @@ const filterFields = [
'transfer',
].map(field => [field, mapField(field)]);
function ConfigureField({
type ConfigureFieldProps<T extends RuleConditionEntity> =
FilterReducerState<T> &
Pick<T, 'options'> & {
initialSubfield?: string;
dispatch: (action: FilterReducerAction) => void;
onApply: (cond: T) => void;
};
function ConfigureField<T extends RuleConditionEntity>({
field,
initialSubfield = field,
op,
value,
dispatch,
onApply,
}) {
}: ConfigureFieldProps<T>) {
const { t } = useTranslation();
const format = useFormat();
const [subfield, setSubfield] = useState(initialSubfield);
const inputRef = useRef();
const prevOp = useRef(null);
const inputRef = useRef<AmountInputRef>(null);
const prevOp = useRef<T['op'] | null>(null);
useEffect(() => {
if (prevOp.current !== op && inputRef.current) {
@@ -94,6 +115,7 @@ function ConfigureField({
if (
field === 'date' &&
subfield === 'month' &&
typeof value === 'string' &&
/^\d{4}-\d{2}$/.test(value)
) {
const [year, month] = value.split('-');
@@ -115,13 +137,11 @@ function ConfigureField({
['amount-inflow', t('Amount (inflow)')],
['amount-outflow', t('Amount (outflow)')],
]
: field === 'date'
? [
['date', t('Date')],
['month', t('Month')],
['year', t('Year')],
]
: null
: [
['date', t('Date')],
['month', t('Month')],
['year', t('Year')],
]
}
value={subfield}
onChange={sub => {
@@ -229,6 +249,7 @@ function ConfigureField({
}
}
// @ts-expect-error - fix me
onApply({
field,
op,
@@ -240,7 +261,9 @@ function ConfigureField({
{type !== 'boolean' && field !== 'payee' && (
<GenericInput
ref={inputRef}
// @ts-expect-error - fix me
field={field === 'date' ? subfield : field}
// @ts-expect-error - fix me
type={
type === 'id' &&
(op === 'contains' ||
@@ -251,14 +274,17 @@ function ConfigureField({
: type
}
numberFormatType="currency"
// @ts-expect-error - fix me
value={
formattedValue ?? (op === 'oneOf' || op === 'notOneOf' ? [] : '')
}
// @ts-expect-error - fix me
multi={op === 'oneOf' || op === 'notOneOf'}
op={op}
options={subfieldToOptions(field, subfield)}
style={{ marginTop: 10 }}
onChange={v => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(v: any) => {
dispatch({ type: 'set-value', value: v });
}}
/>
@@ -266,7 +292,9 @@ function ConfigureField({
{field === 'payee' && (
<PayeeFilter
// @ts-expect-error - fix me
value={formattedValue}
// @ts-expect-error - fix me
op={op}
onChange={v => dispatch({ type: 'set-value', value: v })}
/>
@@ -289,11 +317,22 @@ function ConfigureField({
);
}
export function FilterButton({ onApply, compact, hover, exclude }) {
type FilterButtonProps<T extends RuleConditionEntity> = {
onApply: (cond: T) => void;
compact: boolean;
hover: boolean;
exclude?: string[];
};
export function FilterButton<T extends RuleConditionEntity>({
onApply,
compact,
hover,
exclude,
}: FilterButtonProps<T>) {
const { t } = useTranslation();
const filters = useTransactionFilters();
const triggerRef = useRef(null);
const triggerRef = useRef<HTMLDivElement>(null);
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const translatedFilterFields = useMemo(() => {
@@ -309,7 +348,17 @@ export function FilterButton({ onApply, compact, hover, exclude }) {
}, []);
const [state, dispatch] = useReducer(
(state, action) => {
// @ts-expect-error - fix me
(
state: FilterReducerState<T> & {
fieldsOpen: boolean;
condOpen: boolean;
},
action:
| FilterReducerAction
| { type: 'select-field' }
| { type: 'configure'; field: string },
) => {
switch (action.type) {
case 'select-field':
return { ...state, fieldsOpen: true, condOpen: false };
@@ -335,12 +384,14 @@ export function FilterButton({ onApply, compact, hover, exclude }) {
{ fieldsOpen: false, condOpen: false, field: null, value: null },
);
async function onValidateAndApply(cond) {
async function onValidateAndApply(cond: T) {
// @ts-expect-error - fix me
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
if (cond.type === 'date' && cond.options) {
if (cond.options.month) {
const date = parseDate(
// @ts-expect-error - fix me
cond.value,
getMonthYearFormat(dateFormat),
new Date(),
@@ -352,6 +403,7 @@ export function FilterButton({ onApply, compact, hover, exclude }) {
return;
}
} else if (cond.options.year) {
// @ts-expect-error - fix me
const date = parseDate(cond.value, 'yyyy', new Date());
if (isDateValid(date)) {
cond.value = formatDate(date, 'yyyy');
@@ -363,11 +415,12 @@ export function FilterButton({ onApply, compact, hover, exclude }) {
}
const { error } =
cond.field !== 'saved' &&
(await send('rule-validate', {
conditions: [cond],
actions: [],
}));
cond.field === 'saved'
? { error: null }
: await send('rule-validate', {
conditions: [cond],
actions: [],
});
const saved = filters.find(f => cond.value === f.id);
@@ -375,6 +428,7 @@ export function FilterButton({ onApply, compact, hover, exclude }) {
const field = titleFirst(mapField(cond.field));
alert(field + ': ' + getFieldError(error.conditionErrors[0]));
} else {
// @ts-expect-error - fix me
onApply(saved ? saved : cond);
dispatch({ type: 'close' });
}
@@ -444,6 +498,7 @@ export function FilterButton({ onApply, compact, hover, exclude }) {
// Datepicker selections for some reason register 2x clicks
// We want to keep the popover open after selecting a date.
// So we ignore the "close" event on selection + the subsequent event.
// @ts-expect-error - fix me
if (element.dataset.pikaYear) {
isDatepickerClick = true;
return false;
@@ -472,12 +527,25 @@ export function FilterButton({ onApply, compact, hover, exclude }) {
);
}
export function FilterEditor({ field, op, value, options, onSave, onClose }) {
type FilterEditorProps<T extends RuleConditionEntity> = FilterReducerState<T> &
Pick<T, 'options'> & {
onSave: (cond: T) => void;
onClose: () => void;
};
export function FilterEditor<T extends RuleConditionEntity>({
field,
op,
value,
options,
onSave,
onClose,
}: FilterEditorProps<T>) {
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const { t } = useTranslation();
const [state, dispatch] = useReducer(
(state, action) => {
(state: FilterReducerState<T>, action: FilterReducerAction) => {
switch (action.type) {
case 'close':
onClose();
@@ -486,7 +554,7 @@ export function FilterEditor({ field, op, value, options, onSave, onClose }) {
return updateFilterReducer(state, action);
}
},
{ field, op, value, options },
{ field, op, value },
);
return (
@@ -495,13 +563,18 @@ export function FilterEditor({ field, op, value, options, onSave, onClose }) {
initialSubfield={subfieldFromFilter({ field, options, value })}
op={state.op}
value={state.value}
options={state.options}
options={options}
dispatch={dispatch}
onApply={cond => {
// @ts-expect-error - fix me
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
if (cond.type === 'date' && cond.options) {
if (cond.options.month && !/\d{4}-\d{2}/.test(cond.value)) {
if (
cond.options.month &&
typeof cond.value === 'string' &&
!/\d{4}-\d{2}/.test(cond.value)
) {
const date = parseDate(
cond.value,
getMonthYearFormat(dateFormat),
@@ -514,6 +587,7 @@ export function FilterEditor({ field, op, value, options, onSave, onClose }) {
return;
}
} else if (cond.options.year) {
// @ts-expect-error - fix me
const date = parseDate(cond.value, 'yyyy', new Date());
if (isDateValid(date)) {
cond.value = formatDate(date, 'yyyy');

View File

@@ -1,17 +1,16 @@
import { makeValue, FIELD_TYPES } from 'loot-core/shared/rules';
import { type RuleConditionEntity } from 'loot-core/types/models';
export function updateFilterReducer(
state: Pick<RuleConditionEntity, 'op' | 'field' | 'value'>,
action: { type: 'set-op' | 'set-value' } & Pick<
RuleConditionEntity,
'op' | 'value'
>,
export function updateFilterReducer<T extends RuleConditionEntity>(
state: Pick<T, 'op' | 'field'> & { value: T['value'] | null },
action:
| { type: 'set-op'; op: T['op'] }
| { type: 'set-value'; value: T['value'] },
) {
switch (action.type) {
case 'set-op': {
const type = FIELD_TYPES.get(state.field);
let value: RuleConditionEntity['value'] | null = state.value;
let value = state.value;
if (
(type === 'id' || type === 'string') &&
(action.op === 'contains' ||
@@ -36,6 +35,7 @@ export function updateFilterReducer(
return { ...state, value };
}
default:
// @ts-expect-error - fix me
throw new Error(`Unhandled action type: ${action.type}`);
}
}

View File

@@ -851,7 +851,6 @@ function QueryItem({
compact={false}
onApply={filters.onApply}
hover={false}
exclude={undefined}
/>
</View>
</View>

View File

@@ -41,9 +41,7 @@ type HeaderProps = {
end: TimeFrame['end'],
mode: TimeFrame['mode'],
) => void;
filters?: RuleConditionEntity[];
conditionsOp: 'and' | 'or';
onApply?: (conditions: RuleConditionEntity) => void;
onUpdateFilter: ComponentProps<typeof AppliedFilters>['onUpdate'];
onDeleteFilter: ComponentProps<typeof AppliedFilters>['onDelete'];
onConditionsOpChange: ComponentProps<
@@ -51,7 +49,16 @@ type HeaderProps = {
>['onConditionsOpChange'];
children?: ReactNode;
inlineContent?: ReactNode;
};
} & (
| {
filters: RuleConditionEntity[];
onApply: (conditions: RuleConditionEntity) => void;
}
| {
filters?: never;
onApply?: never;
}
);
export function Header({
start,
@@ -263,7 +270,6 @@ export function Header({
compact={isNarrowWidth}
onApply={onApply}
hover={false}
exclude={undefined}
/>
)}
</SpaceBetween>

View File

@@ -346,7 +346,6 @@ function SummaryInner({ widget }: SummaryInnerProps) {
firstDayOfWeekIdx={firstDayOfWeekIdx}
mode={mode}
onChangeDates={onChangeDates}
onApply={dividendFilters.onApply}
onUpdateFilter={dividendFilters.onUpdate}
onDeleteFilter={dividendFilters.onDelete}
conditionsOp={dividendFilters.conditionsOp}
@@ -644,7 +643,6 @@ function SumWithRange({
compact={false}
onApply={filterObject.onApply}
hover={false}
exclude={undefined}
/>
</View>
</View>

View File

@@ -56,7 +56,7 @@ type FieldInfoConstraint = Record<
{
type: keyof typeof TYPE_INFO;
disallowedOps?: Set<RuleConditionOp>;
internalOps?: Set<RuleConditionOp>;
internalOps?: Set<RuleConditionOp | 'and'>;
}
>;
@@ -103,7 +103,7 @@ export function isValidOp(field: keyof FieldValueTypes, op: RuleConditionOp) {
);
}
export function getValidOps(field: keyof FieldValueTypes) {
export function getValidOps(field: keyof FieldValueTypes): RuleConditionOp[] {
const type = FIELD_TYPES.get(field);
if (!type) {
return [];

View File

@@ -12,24 +12,7 @@ export interface RuleEntity extends NewRuleEntity {
id: string;
}
export type RuleConditionOp =
| 'is'
| 'isNot'
| 'oneOf'
| 'notOneOf'
| 'isapprox'
| 'isbetween'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'doesNotContain'
| 'hasTags'
| 'and'
| 'matches'
| 'onBudget'
| 'offBudget';
export type RuleConditionOp = RuleConditionEntity['op'];
export type FieldValueTypes = {
account: string;

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
TypeScript: refactor FiltersMenu to TS