Newer
Older
// @ts-strict-ignore
type Dispatch,
type ReactElement,
} from 'react';
import { useSelector } from 'react-redux';
import { type State } from 'loot-core/src/client/state-types';
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);
for (let i = from; i <= to; i++) {
func(i);
}
}
type SelectedState = {
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;
Joel Jeremy Marquez
committed
export function useSelected<T extends Item>(
name: string,
items: T[],
initialSelectedIds: string[],
selectAllFilter?: (item: T) => boolean,
(state: SelectedState, action: Actions) => {
const { selectedRange } = state;
const selectedItems = new Set(state.selectedItems);
const { id, event } = action;
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>;
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
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,
},
};
} else {
let range = null;
if (!selectedItems.delete(id)) {
selectedItems.add(id);
range = { start: id, end: null };
}
return {
...state,
selectedItems,
selectedRange: range,
return { ...state, selectedItems: new Set<string>() };
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);
}
selectedItems: new Set(selectedItems),
selectedRange:
action.ids && action.ids.length === 1
? { start: action.ids[0], end: null }
throw new Error('Unexpected action: ' + JSON.stringify(action));
selectedItems: new Set<string>(initialSelectedIds || []),
selectedRange:
initialSelectedIds && initialSelectedIds.length === 1
? { start: initialSelectedIds[0], end: null }
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 =
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));
// 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');
undo.setUndoState('selectedItems', { name, items: state.selectedItems });
return () => undo.setUndoState('selectedItems', prevState);
}, [state.selectedItems]);
const lastUndoState = useSelector((state: State) => state.app.lastUndoState);
function onUndo({ messages, undoTag }: UndoState) {
const tagged = undo.getTaggedState(undoTag);
messages
.filter(msg => msg.column === 'tombstone' && msg.value === 1)
.map(msg => msg.row),
if (tagged?.selectedItems?.name === name) {
dispatch({
type: 'select-all',
// Coerce the Set into an array
ids: [...tagged.selectedItems.items].filter(
id => !deletedIds.has(id),
),
});
}
}
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);
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>) {
useEffect(() => {
latestItems.current = instance.items;
}, [instance.items]);
async (action: Actions) => {
if (!action.event && isNonProductionEnvironment()) {
throw new Error('SelectedDispatch actions must have an event');
}
if (action.type === 'select-all') {
if (latestItems.current && latestItems.current.size > 0) {
return instance.dispatch({
type: 'select-none',
event: action.event,
});
} else {
if (fetchAllIds) {
return instance.dispatch({
type: 'select-all',
ids: await fetchAllIds(),
event: action.event,
return instance.dispatch({ type: 'select-all', event: action.event });
[instance.dispatch, fetchAllIds],
);
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;
children: ReactElement;
};
// This can be helpful in class components if you cannot use the
// custom hook
export function SelectedProviderWithItems<T extends Item>({
name,
items,
initialSelectedIds,
fetchAllIds,
registerDispatch,
}: SelectedProviderWithItemsProps<T>) {
name,
items,
initialSelectedIds,
selectAllFilter,
);
Matiss Janis Aboltins
committed
<SelectedProvider<T> instance={selected} fetchAllIds={fetchAllIds}>
{children}
</SelectedProvider>