Skip to content
Snippets Groups Projects
useSelected.tsx 10.1 KiB
Newer Older
  • Learn to ignore specific revisions
  • James Long's avatar
    James Long committed
    import React, {
    
    James Long's avatar
    James Long committed
      useContext,
      useReducer,
      useCallback,
      useEffect,
    
    James Long's avatar
    James Long committed
    } from 'react';
    import { useSelector } from 'react-redux';
    
    import { type State } from 'loot-core/src/client/state-types';
    
    James Long's avatar
    James Long committed
    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';
    
    type Range<T> = { start: T; end: T | null };
    type Item = { id: string };
    
    function iterateRange(range: Range<number>, func: (i: number) => void): void {
    
      const from = Math.min(range.start, range.end);
      const to = Math.max(range.start, range.end);
    
    James Long's avatar
    James Long committed
    
      for (let i = from; i <= to; i++) {
        func(i);
      }
    }
    
    
      selectedRange: Range<string> | null;
      selectedItems: Set<string>;
    };
    
    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;
    
    
      name: string,
      items: T[],
      initialSelectedIds: string[],
    
      selectAllFilter?: (item: T) => boolean,
    
      const [state, dispatch] = useReducer(
    
        (state: SelectedState, action: Actions) => {
    
    James Long's avatar
    James Long committed
          switch (action.type) {
            case 'select': {
    
              const { selectedRange } = state;
              const selectedItems = new Set(state.selectedItems);
              const { id, event } = action;
    
    James Long's avatar
    James Long committed
    
    
              if (event.shiftKey && selectedRange) {
    
                const idx = items.findIndex(p => p.id === id);
                const startIdx = items.findIndex(p => p.id === selectedRange.start);
                const endIdx = items.findIndex(p => p.id === selectedRange.end);
    
                let range: Range<number>;
                let deleteUntil: Range<number>;
    
    James Long's avatar
    James Long committed
    
                if (endIdx === -1) {
                  range = { start: startIdx, end: idx };
                } else if (endIdx < startIdx) {
                  if (idx <= startIdx) {
                    range = { start: startIdx, end: idx };
    
                    if (idx > endIdx) {
                      deleteUntil = { start: idx - 1, end: endIdx };
                    }
                  } else {
                    // Switching directions
                    range = { start: endIdx, end: idx };
                  }
                } else {
                  if (idx >= startIdx) {
                    range = { start: startIdx, end: idx };
    
                    if (idx < endIdx) {
                      deleteUntil = { start: idx + 1, end: endIdx };
                    }
                  } else {
                    // Switching directions
                    range = { start: endIdx, end: idx };
                  }
                }
    
                iterateRange(range, i => selectedItems.add(items[i].id));
    
                if (deleteUntil) {
                  iterateRange(deleteUntil, i => selectedItems.delete(items[i].id));
                }
    
                return {
                  ...state,
                  selectedItems,
                  selectedRange: {
                    start: items[range.start].id,
    
                    end: items[range.end].id,
                  },
    
    James Long's avatar
    James Long committed
                };
              } else {
                let range = null;
                if (!selectedItems.delete(id)) {
                  selectedItems.add(id);
                  range = { start: id, end: null };
                }
    
                return {
                  ...state,
                  selectedItems,
    
    James Long's avatar
    James Long committed
                };
              }
            }
    
            case 'select-none':
    
              return { ...state, selectedItems: new Set<string>() };
    
    James Long's avatar
    James Long committed
    
            case 'select-all':
    
              let selectedItems: string[] = [];
              if (action.ids && items && selectAllFilter) {
                const idsToInclude = new Set(
                  items.filter(selectAllFilter).map(item => item.id),
                );
                selectedItems = action.ids.filter(id => idsToInclude.has(id));
              } else if (items && selectAllFilter) {
                selectedItems = items.filter(selectAllFilter).map(item => item.id);
              } else {
                selectedItems = action.ids || items.map(item => item.id);
              }
    
    James Long's avatar
    James Long committed
              return {
                ...state,
    
                selectedItems: new Set(selectedItems),
    
    James Long's avatar
    James Long committed
                selectedRange:
                  action.ids && action.ids.length === 1
                    ? { start: action.ids[0], end: null }
    
    James Long's avatar
    James Long committed
              };
    
            default:
    
              throw new Error('Unexpected action: ' + JSON.stringify(action));
    
    James Long's avatar
    James Long committed
          }
        },
        null,
        () => ({
    
          selectedItems: new Set<string>(initialSelectedIds || []),
    
    James Long's avatar
    James Long committed
          selectedRange:
            initialSelectedIds && initialSelectedIds.length === 1
              ? { start: initialSelectedIds[0], end: null }
    
      const prevItems = useRef(items);
    
    James Long's avatar
    James Long committed
      useEffect(() => {
        if (state.selectedItems.size > 0) {
          // We need to make sure there are no ids in the selection that
          // aren't valid anymore. This happens if the item has been
          // deleted or otherwise removed from the current view. We do
          // this by cross-referencing the current selection with the
          // available item ids
          //
          // This effect may run multiple times while items is updated, we
          // need to make sure that we don't remove selected ids until the
          // items array *actually* changes. A component may render with
          // new `items` arrays that are the same, just fresh instances, but
          // we need to wait until the actual array changes. This solves
          // the case where undo-ing adds back items, but we remove the
          // selected item too early (because the component rerenders
          // multiple times)
    
    
          const ids = new Set(items.map(item => item.id));
          const isSame =
    
    James Long's avatar
    James Long committed
            prevItems.current.length === items.length &&
            prevItems.current.every(item => ids.has(item.id));
    
          if (!isSame) {
    
            const selected = [...state.selectedItems];
            const filtered = selected.filter(id => ids.has(id));
    
    James Long's avatar
    James Long committed
    
            // If the selected items has changed, update the selection
            if (selected.length !== filtered.length) {
              dispatch({ type: 'select-all', ids: filtered });
            }
          }
        }
    
        prevItems.current = items;
      }, [items, state.selectedItems]);
    
      useEffect(() => {
    
        const prevState = undo.getUndoState('selectedItems');
    
    James Long's avatar
    James Long committed
        undo.setUndoState('selectedItems', { name, items: state.selectedItems });
        return () => undo.setUndoState('selectedItems', prevState);
      }, [state.selectedItems]);
    
    
      const lastUndoState = useSelector((state: State) => state.app.lastUndoState);
    
    James Long's avatar
    James Long committed
    
      useEffect(() => {
    
        function onUndo({ messages, undoTag }: UndoState) {
    
          const tagged = undo.getTaggedState(undoTag);
    
    James Long's avatar
    James Long committed
    
    
          const deletedIds = new Set(
    
    James Long's avatar
    James Long committed
            messages
              .filter(msg => msg.column === 'tombstone' && msg.value === 1)
    
          if (tagged?.selectedItems?.name === name) {
    
    James Long's avatar
    James Long committed
            dispatch({
              type: 'select-all',
              // Coerce the Set into an array
    
              ids: [...tagged.selectedItems.items].filter(
                id => !deletedIds.has(id),
              ),
    
    James Long's avatar
    James Long committed
            });
          }
        }
    
        if (lastUndoState && lastUndoState.current) {
          onUndo(lastUndoState.current);
        }
    
        return listen('undo-event', onUndo);
      }, []);
    
      return {
        items: state.selectedItems,
    
    const SelectedDispatch = createContext<(action: Actions) => void>(null);
    
    const SelectedItems = createContext<Set<string>>(null);
    
    James Long's avatar
    James Long committed
    
    export function useSelectedDispatch() {
      return useContext(SelectedDispatch);
    }
    
    export function useSelectedItems() {
      return useContext(SelectedItems);
    }
    
    
    type SelectedProviderProps<T extends Item> = {
      instance: ReturnType<typeof useSelected<T>>;
      fetchAllIds?: () => Promise<string[]>;
      children: ReactElement;
    };
    
    export function SelectedProvider<T extends Item>({
      instance,
      fetchAllIds,
      children,
    }: SelectedProviderProps<T>) {
    
      const latestItems = useRef(null);
    
    James Long's avatar
    James Long committed
    
      useEffect(() => {
        latestItems.current = instance.items;
      }, [instance.items]);
    
    
      const dispatch = useCallback(
    
          if (!action.event && isNonProductionEnvironment()) {
            throw new Error('SelectedDispatch actions must have an event');
          }
    
    James Long's avatar
    James Long committed
          if (action.type === 'select-all') {
            if (latestItems.current && latestItems.current.size > 0) {
    
              return instance.dispatch({
                type: 'select-none',
                event: action.event,
              });
    
    James Long's avatar
    James Long committed
            } else {
              if (fetchAllIds) {
                return instance.dispatch({
                  type: 'select-all',
    
                  ids: await fetchAllIds(),
    
    James Long's avatar
    James Long committed
                });
              }
    
              return instance.dispatch({ type: 'select-all', event: action.event });
    
    James Long's avatar
    James Long committed
            }
          }
          return instance.dispatch(action);
        },
    
        [instance.dispatch, fetchAllIds],
    
    James Long's avatar
    James Long committed
      );
    
      return (
        <SelectedItems.Provider value={instance.items}>
          <SelectedDispatch.Provider value={dispatch}>
            {children}
          </SelectedDispatch.Provider>
        </SelectedItems.Provider>
      );
    }
    
    
    type SelectedProviderWithItemsProps<T extends Item> = {
      name: string;
      items: T[];
      initialSelectedIds: string[];
      fetchAllIds: () => Promise<string[]>;
      registerDispatch?: (dispatch: Dispatch<Actions>) => void;
    
      selectAllFilter?: (item: T) => boolean;
    
    James Long's avatar
    James Long committed
    // This can be helpful in class components if you cannot use the
    // custom hook
    
    export function SelectedProviderWithItems<T extends Item>({
    
    James Long's avatar
    James Long committed
      name,
      items,
      initialSelectedIds,
      fetchAllIds,
      registerDispatch,
    
    }: SelectedProviderWithItemsProps<T>) {
    
      const selected = useSelected<T>(
    
        name,
        items,
        initialSelectedIds,
        selectAllFilter,
      );
    
    James Long's avatar
    James Long committed
    
      useEffect(() => {
    
        registerDispatch?.(selected.dispatch);
    
    James Long's avatar
    James Long committed
      }, [registerDispatch]);
    
      return (
    
        <SelectedProvider<T> instance={selected} fetchAllIds={fetchAllIds}>
          {children}
        </SelectedProvider>
    
    James Long's avatar
    James Long committed
      );
    }