diff --git a/packages/desktop-client/src/hooks/usePrevious.js b/packages/desktop-client/src/hooks/usePrevious.ts similarity index 53% rename from packages/desktop-client/src/hooks/usePrevious.js rename to packages/desktop-client/src/hooks/usePrevious.ts index ed46581cb0..8d5f2dc554 100644 --- a/packages/desktop-client/src/hooks/usePrevious.js +++ b/packages/desktop-client/src/hooks/usePrevious.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; -export default function usePrevious(value) { - const ref = useRef(); +export default function usePrevious(value: T): T | undefined { + const ref = useRef(); useEffect(() => { ref.current = value; diff --git a/packages/desktop-client/src/hooks/useProperFocus.js b/packages/desktop-client/src/hooks/useProperFocus.tsx similarity index 56% rename from packages/desktop-client/src/hooks/useProperFocus.js rename to packages/desktop-client/src/hooks/useProperFocus.tsx index 6d8930fff1..d75f60ac6f 100644 --- a/packages/desktop-client/src/hooks/useProperFocus.js +++ b/packages/desktop-client/src/hooks/useProperFocus.tsx @@ -4,13 +4,16 @@ import React, { useLayoutEffect, useContext, useMemo, + type RefObject, + type ReactElement, + type MutableRefObject, } from 'react'; -function getFocusedKey(el) { - let node = el; +function getFocusedKey(el: HTMLElement): string | null { + let node: HTMLElement | ParentNode = el; // Search up to 10 parent nodes for (let i = 0; i < 10 && node; i++) { - let key = node.dataset && node.dataset.focusKey; + let key = 'dataset' in node ? node.dataset?.focusKey : undefined; if (key) { return key; } @@ -20,7 +23,10 @@ function getFocusedKey(el) { return null; } -function focusElement(el, refocusContext) { +function focusElement( + el: HTMLElement, + refocusContext: AvoidRefocusScrollContextValue, +): void { if (refocusContext) { let key = getFocusedKey(el); el.focus({ preventScroll: key && key === refocusContext.keyRef.current }); @@ -29,17 +35,29 @@ function focusElement(el, refocusContext) { el.focus(); } - if (el.tagName === 'INPUT') { + if (el instanceof HTMLInputElement) { el.setSelectionRange(0, 10000); } } -let AvoidRefocusScrollContext = createContext(null); +type AvoidRefocusScrollContextValue = { + keyRef: MutableRefObject; + onKeyChange: (key: string) => void; +}; -export function AvoidRefocusScrollProvider({ children }) { - let keyRef = useRef(null); +let AvoidRefocusScrollContext = + createContext(null); - let value = useMemo( +type AvoidRefocusScrollProviderProps = { + children: ReactElement; +}; + +export function AvoidRefocusScrollProvider({ + children, +}: AvoidRefocusScrollProviderProps) { + let keyRef = useRef(null); + + let value = useMemo( () => ({ keyRef, onKeyChange: key => { @@ -56,7 +74,10 @@ export function AvoidRefocusScrollProvider({ children }) { ); } -export function useProperFocus(ref, shouldFocus) { +export function useProperFocus( + ref: RefObject, + shouldFocus: boolean, +): void { let context = useContext(AvoidRefocusScrollContext); let prevShouldFocus = useRef(null); @@ -68,7 +89,7 @@ export function useProperFocus(ref, shouldFocus) { let selector = 'input,button,div[tabindex]'; let focusEl = view.matches(selector) ? view - : view.querySelector(selector); + : view.querySelector(selector); if (shouldFocus && focusEl) { focusElement(focusEl, context); diff --git a/packages/desktop-client/src/hooks/useSelected.js b/packages/desktop-client/src/hooks/useSelected.tsx similarity index 77% rename from packages/desktop-client/src/hooks/useSelected.js rename to packages/desktop-client/src/hooks/useSelected.tsx index 31cda5e285..75b0d1b717 100644 --- a/packages/desktop-client/src/hooks/useSelected.js +++ b/packages/desktop-client/src/hooks/useSelected.tsx @@ -5,14 +5,20 @@ import React, { useCallback, useEffect, useRef, + type Dispatch, + type ReactElement, } from 'react'; import { useSelector } from 'react-redux'; import { listen } from 'loot-core/src/platform/client/fetch'; import * as undo from 'loot-core/src/platform/client/undo'; +import { type UndoState } from 'loot-core/src/server/undo'; import { isNonProductionEnvironment } from 'loot-core/src/shared/environment'; -function iterateRange(range, func) { +type Range = { start: T; end: T | null }; +type Item = { id: string }; + +function iterateRange(range: Range, func: (i: number) => void): void { let from = Math.min(range.start, range.end); let to = Math.max(range.start, range.end); @@ -21,9 +27,35 @@ function iterateRange(range, func) { } } -export default function useSelected(name, items, initialSelectedIds) { +type State = { + selectedRange: Range | null; + selectedItems: Set; +}; + +type WithOptionalMouseEvent = { + event?: MouseEvent; +}; +type SelectAction = { + type: 'select'; + id: string; +} & WithOptionalMouseEvent; +type SelectNoneAction = { + type: 'select-none'; +} & WithOptionalMouseEvent; +type SelectAllAction = { + type: 'select-all'; + ids?: string[]; +} & WithOptionalMouseEvent; + +type Actions = SelectAction | SelectNoneAction | SelectAllAction; + +export default function useSelected( + name: string, + items: T[], + initialSelectedIds: string[], +) { let [state, dispatch] = useReducer( - (state, action) => { + (state: State, action: Actions) => { switch (action.type) { case 'select': { let { selectedRange } = state; @@ -34,8 +66,8 @@ export default function useSelected(name, items, initialSelectedIds) { let idx = items.findIndex(p => p.id === id); let startIdx = items.findIndex(p => p.id === selectedRange.start); let endIdx = items.findIndex(p => p.id === selectedRange.end); - let range; - let deleteUntil; + let range: Range; + let deleteUntil: Range; if (endIdx === -1) { range = { start: startIdx, end: idx }; @@ -93,7 +125,7 @@ export default function useSelected(name, items, initialSelectedIds) { } case 'select-none': - return { ...state, selectedItems: new Set() }; + return { ...state, selectedItems: new Set() }; case 'select-all': return { @@ -106,12 +138,12 @@ export default function useSelected(name, items, initialSelectedIds) { }; default: - throw new Error('Unexpected action: ' + action.type); + throw new Error('Unexpected action: ' + JSON.stringify(action)); } }, null, () => ({ - selectedItems: new Set(initialSelectedIds || []), + selectedItems: new Set(initialSelectedIds || []), selectedRange: initialSelectedIds && initialSelectedIds.length === 1 ? { start: initialSelectedIds[0], end: null } @@ -165,7 +197,7 @@ export default function useSelected(name, items, initialSelectedIds) { let lastUndoState = useSelector(state => state.app.lastUndoState); useEffect(() => { - function onUndo({ messages, undoTag }) { + function onUndo({ messages, undoTag }: UndoState) { let tagged = undo.getTaggedState(undoTag); let deletedIds = new Set( @@ -174,11 +206,7 @@ export default function useSelected(name, items, initialSelectedIds) { .map(msg => msg.row), ); - if ( - tagged && - tagged.selectedItems && - tagged.selectedItems.name === name - ) { + if (tagged?.selectedItems?.name === name) { dispatch({ type: 'select-all', // Coerce the Set into an array @@ -198,13 +226,12 @@ export default function useSelected(name, items, initialSelectedIds) { return { items: state.selectedItems, - setItems: state.setSelectedItems, dispatch, }; } -let SelectedDispatch = createContext(null); -let SelectedItems = createContext(null); +let SelectedDispatch = createContext<(action: Actions) => void>(null); +let SelectedItems = createContext>(null); export function useSelectedDispatch() { return useContext(SelectedDispatch); @@ -214,7 +241,17 @@ export function useSelectedItems() { return useContext(SelectedItems); } -export function SelectedProvider({ instance, fetchAllIds, children }) { +type SelectedProviderProps = { + instance: ReturnType>; + fetchAllIds?: () => Promise; + children: ReactElement; +}; + +export function SelectedProvider({ + instance, + fetchAllIds, + children, +}: SelectedProviderProps) { let latestItems = useRef(null); useEffect(() => { @@ -222,7 +259,7 @@ export function SelectedProvider({ instance, fetchAllIds, children }) { }, [instance.items]); let dispatch = useCallback( - async action => { + async (action: Actions) => { if (!action.event && isNonProductionEnvironment()) { throw new Error('SelectedDispatch actions must have an event'); } @@ -257,24 +294,33 @@ export function SelectedProvider({ instance, fetchAllIds, children }) { ); } +type SelectedProviderWithItemsProps = { + name: string; + items: T[]; + initialSelectedIds: string[]; + fetchAllIds: () => Promise; + registerDispatch?: (dispatch: Dispatch) => void; + children: ReactElement; +}; + // This can be helpful in class components if you cannot use the // custom hook -export function SelectedProviderWithItems({ +export function SelectedProviderWithItems({ name, items, initialSelectedIds, fetchAllIds, registerDispatch, children, -}) { - let selected = useSelected(name, items, initialSelectedIds); +}: SelectedProviderWithItemsProps) { + let selected = useSelected(name, items, initialSelectedIds); useEffect(() => { registerDispatch?.(selected.dispatch); }, [registerDispatch]); return ( - instance={selected} fetchAllIds={fetchAllIds} children={children} diff --git a/packages/loot-core/src/platform/client/undo/index.d.ts b/packages/loot-core/src/platform/client/undo/index.d.ts index ef71901760..602a703e1c 100644 --- a/packages/loot-core/src/platform/client/undo/index.d.ts +++ b/packages/loot-core/src/platform/client/undo/index.d.ts @@ -2,16 +2,19 @@ export type UndoState = { id?: string; url: unknown; openModal: unknown; - selectedItems: unknown; + selectedItems: { + name: string; + items: Set; + } | null; }; -export function setUndoState( - name: keyof Omit, - value: unknown, +export function setUndoState>( + name: K, + value: UndoState[K], ): void; export type SetUndoState = typeof setUndoState; -export function getUndoState(name: keyof UndoState): unknown; +export function getUndoState(name: K): UndoState[K]; export type GetUndoState = typeof getUndoState; export function getTaggedState(id: string): UndoState | undefined; diff --git a/upcoming-release-notes/1479.md b/upcoming-release-notes/1479.md new file mode 100644 index 0000000000..d591645caa --- /dev/null +++ b/upcoming-release-notes/1479.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Migrate hooks from native JS to TypeScript