Skip to content

Commit

Permalink
♻️ (TypeScript) fix strictFunctionTypes violations (pt 3)
Browse files Browse the repository at this point in the history
  • Loading branch information
MatissJanis committed Dec 12, 2023
1 parent 6707886 commit eacca88
Show file tree
Hide file tree
Showing 6 changed files with 107 additions and 64 deletions.
145 changes: 88 additions & 57 deletions packages/desktop-client/src/components/autocomplete/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import React, {
type ChangeEvent,
} from 'react';

import Downshift from 'downshift';
import Downshift, { type StateChangeTypes } from 'downshift';
import { css } from 'glamor';

import Remove from '../../icons/v2/Remove';
Expand All @@ -20,18 +20,31 @@ import Input from '../common/Input';
import View from '../common/View';
import { Tooltip } from '../tooltips';

const inst: { lastChangeType? } = {};
type Item = {
id?: string;
name: string;
};

const inst: { lastChangeType?: StateChangeTypes } = {};

function findItem(strict, suggestions, value) {
function findItem<T extends Item>(
strict: boolean,
suggestions: T[],
value: T | T['id'],
): T | null {
if (strict) {
const idx = suggestions.findIndex(item => item.id === value);
return idx === -1 ? null : suggestions[idx];
}

if (typeof value === 'string') {
throw new Error('value can be string only if strict = false');
}

return value;
}

function getItemName(item) {
function getItemName(item: null | string | Item): string {
if (item == null) {
return '';
} else if (typeof item === 'string') {
Expand All @@ -40,24 +53,36 @@ function getItemName(item) {
return item.name || '';
}

function getItemId(item) {
function getItemId(item: Item | Item['id']) {
if (typeof item === 'string') {
return item;
}
return item ? item.id : null;
}

export function defaultFilterSuggestion(suggestion, value) {
export function defaultFilterSuggestion<T extends Item>(
suggestion: T,
value: string,
) {
return getItemName(suggestion).toLowerCase().includes(value.toLowerCase());
}

function defaultFilterSuggestions(suggestions, value) {
function defaultFilterSuggestions<T extends Item>(
suggestions: T[],
value: string,
) {
return suggestions.filter(suggestion =>
defaultFilterSuggestion(suggestion, value),
);
}

function fireUpdate(onUpdate, strict, suggestions, index, value) {
function fireUpdate<T extends Item>(
onUpdate: ((selected: string | null, value: string) => void) | undefined,
strict: boolean,
suggestions: T[],
index: number,
value: string,
) {
// If the index is null, look up the id in the suggestions. If the
// value is empty it will select nothing (as expected). If it's not
// empty but nothing is selected, it still resolves to an id. It
Expand All @@ -82,11 +107,15 @@ function fireUpdate(onUpdate, strict, suggestions, index, value) {
onUpdate?.(selected, value);
}

function defaultRenderInput(props) {
function defaultRenderInput(props: ComponentProps<typeof Input>) {
return <Input {...props} />;
}

function defaultRenderItems(items, getItemProps, highlightedIndex) {
function defaultRenderItems<T extends Item>(
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
highlightedIndex: number,
) {
return (
<div>
{items.map((item, index) => {
Expand Down Expand Up @@ -134,47 +163,47 @@ function defaultRenderItems(items, getItemProps, highlightedIndex) {
);
}

function defaultShouldSaveFromKey(e) {
function defaultShouldSaveFromKey(e: KeyboardEvent) {
return e.code === 'Enter';
}

function defaultItemToString(item) {
function defaultItemToString<T extends Item>(item?: T) {
return item ? getItemName(item) : '';
}

type SingleAutocompleteProps = {
type SingleAutocompleteProps<T extends Item> = {
focused?: boolean;
embedded?: boolean;
containerProps?: HTMLProps<HTMLDivElement>;
labelProps?: { id?: string };
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
onChange?: (value: string) => void;
};
suggestions?: unknown[];
suggestions?: T[];
tooltipStyle?: CSSProperties;
tooltipProps?: ComponentProps<typeof Tooltip>;
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
renderItems?: (
items,
getItemProps: (arg: { item: unknown }) => ComponentProps<typeof View>,
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
idx: number,
value?: unknown,
value?: string,
) => ReactNode;
itemToString?: (item) => string;
itemToString?: (item: T) => string;
shouldSaveFromKey?: (e: KeyboardEvent) => boolean;
filterSuggestions?: (suggestions, value: string) => unknown[];
filterSuggestions?: (suggestions: T[], value: string) => T[];
openOnFocus?: boolean;
getHighlightedIndex?: (suggestions) => number | null;
getHighlightedIndex?: (suggestions: T[]) => number | null;
highlightFirst?: boolean;
onUpdate?: (id: unknown, value: string) => void;
onUpdate?: (id: T['id'], value: string) => void;
strict?: boolean;
onSelect: (id: unknown, value: string) => void;
onSelect: (id: T['id'], value: string) => void;
tableBehavior?: boolean;
closeOnBlur?: boolean;
value: unknown[] | string;
value: T | T['id'];
isMulti?: boolean;
};
function SingleAutocomplete({
function SingleAutocomplete<T extends Item>({
focused,
embedded = false,
containerProps,
Expand All @@ -198,7 +227,7 @@ function SingleAutocomplete({
closeOnBlur = true,
value: initialValue,
isMulti = false,
}: SingleAutocompleteProps) {
}: SingleAutocompleteProps<T>) {
const [selectedItem, setSelectedItem] = useState(() =>
findItem(strict, suggestions, initialValue),
);
Expand All @@ -220,9 +249,9 @@ function SingleAutocomplete({
setSelectedItem(findItem(strict, suggestions, initialValue));
}, [initialValue, suggestions, strict]);

function resetState(newValue) {
function resetState(newValue?: string) {
const val = newValue === undefined ? initialValue : newValue;
const selectedItem = findItem(strict, suggestions, val);
const selectedItem = findItem<T>(strict, suggestions, val);

setSelectedItem(selectedItem);
setValue(selectedItem ? getItemName(selectedItem) : '');
Expand Down Expand Up @@ -527,7 +556,12 @@ function SingleAutocomplete({
);
}

function MultiItem({ name, onRemove }) {
type MultiItemProps = {
name: string;
onRemove: () => void;
};

function MultiItem({ name, onRemove }: MultiItemProps) {
return (
<View
style={{
Expand All @@ -547,40 +581,44 @@ function MultiItem({ name, onRemove }) {
);
}

type MultiAutocompleteProps = Omit<
SingleAutocompleteProps,
'value' | 'onSelect'
> & {
value: unknown[];
onSelect: (ids: unknown[], id?: string) => void;
type MultiAutocompleteProps<
T extends Item,
Value = SingleAutocompleteProps<T>['value'],
> = Omit<SingleAutocompleteProps<T>, 'value' | 'onSelect'> & {
value: Value[];
onSelect: (ids: Value[], id?: string) => void;
};
function MultiAutocomplete({
function MultiAutocomplete<T extends Item>({
value: selectedItems,
onSelect,
suggestions,
strict,
...props
}: MultiAutocompleteProps) {
}: MultiAutocompleteProps<T>) {
const [focused, setFocused] = useState(false);
const lastSelectedItems = useRef<unknown[]>();
const lastSelectedItems = useRef<typeof selectedItems>();

useEffect(() => {
lastSelectedItems.current = selectedItems;
});

function onRemoveItem(id) {
function onRemoveItem(id: (typeof selectedItems)[0]) {
const items = selectedItems.filter(i => i !== id);
onSelect(items);
}

function onAddItem(id) {
function onAddItem(id: string) {
if (id) {
id = id.trim();
onSelect([...selectedItems, id], id);
}
}

function onKeyDown(e, prevOnKeyDown) {
function onKeyDown(
e: KeyboardEvent<HTMLInputElement>,
prevOnKeyDown?: ComponentProps<typeof Input>['onKeyDown'],
) {
// @ts-expect-error We're missing `target.value` on KeyboardEvent
if (e.key === 'Backspace' && e.target.value === '') {
onRemoveItem(selectedItems[selectedItems.length - 1]);
}
Expand Down Expand Up @@ -680,31 +718,24 @@ export function AutocompleteFooter({
);
}

type AutocompleteProps =
| ComponentProps<typeof SingleAutocomplete>
| ComponentProps<typeof MultiAutocomplete>;
type AutocompleteProps<T extends Item> =
| ComponentProps<typeof SingleAutocomplete<T>>
| ComponentProps<typeof MultiAutocomplete<T>>;

function isMultiAutocomplete(
props: AutocompleteProps,
function isMultiAutocomplete<T extends Item>(
_props: AutocompleteProps<T>,
multi?: boolean,
): props is ComponentProps<typeof MultiAutocomplete> {
): _props is ComponentProps<typeof MultiAutocomplete<T>> {
return multi;
}

function isSingleAutocomplete(
props: AutocompleteProps,
multi?: boolean,
): props is ComponentProps<typeof SingleAutocomplete> {
return !multi;
}

export default function Autocomplete({
export default function Autocomplete<T extends Item>({
multi,
...props
}: AutocompleteProps & { multi?: boolean }) {
}: AutocompleteProps<T> & { multi?: boolean }) {
if (isMultiAutocomplete(props, multi)) {
return <MultiAutocomplete {...props} />;
} else if (isSingleAutocomplete(props, multi)) {
return <SingleAutocomplete {...props} />;
}

return <SingleAutocomplete {...props} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ export default function PayeeAutocomplete({

const isf = filtered.length > 100;
filtered = filtered.slice(0, 100);
// @ts-expect-error TODO: solve this somehow
filtered.filtered = isf;

if (filtered.length >= 2 && filtered[0].id === 'new') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
import React, { type ComponentProps } from 'react';

import { useFilters } from 'loot-core/src/client/data-hooks/filters';
import { type TransactionFilterEntity } from 'loot-core/src/types/models';

import { theme } from '../../style';
import View from '../common/View';

import Autocomplete from './Autocomplete';

type FilterListProps = {
items: { id: string; name: string }[];
getItemProps: (arg: { item: unknown }) => ComponentProps<typeof View>;
type FilterListProps<T> = {
items: T[];
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>;
highlightedIndex: number;
embedded?: boolean;
};

function FilterList({
function FilterList<T extends { id: string; name: string }>({
items,
getItemProps,
highlightedIndex,
embedded,
}: FilterListProps) {
}: FilterListProps<T>) {
return (
<View>
<View
Expand Down Expand Up @@ -57,7 +58,7 @@ function FilterList({

type SavedFilterAutocompleteProps = {
embedded?: boolean;
} & ComponentProps<typeof Autocomplete>;
} & ComponentProps<typeof Autocomplete<TransactionFilterEntity>>;

export default function SavedFilterAutocomplete({
embedded,
Expand All @@ -73,6 +74,7 @@ export default function SavedFilterAutocomplete({
suggestions={filters}
renderItems={(items, getItemProps, highlightedIndex) => (
<FilterList
// @ts-expect-error This issue will go away when `strictFunctionTypes` is enabled
items={items}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
Expand Down
3 changes: 2 additions & 1 deletion packages/loot-core/src/client/data-hooks/filters.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMemo } from 'react';

import { type TransactionFilterEntity } from '../../types/models';
import q from '../query-helpers';
import { useLiveQuery } from '../query-hooks';

Expand All @@ -17,7 +18,7 @@ function toJS(rows) {
return filters;
}

export function useFilters() {
export function useFilters(): TransactionFilterEntity[] {
const filters = toJS(
useLiveQuery(() => q('transaction_filters').select('*'), []) || [],
);
Expand Down
1 change: 1 addition & 0 deletions packages/loot-core/src/types/models/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export type * from './payee';
export type * from './rule';
export type * from './schedule';
export type * from './transaction';
export type * from './transaction-filter';
7 changes: 7 additions & 0 deletions packages/loot-core/src/types/models/transaction-filter.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface TransactionFilterEntity {
id: string;
name: string;
conditions_op: string;
conditions: unknown;
tombstone: boolean;
}

0 comments on commit eacca88

Please sign in to comment.