add custom rowId support to lists

This commit is contained in:
jeffvli
2025-11-13 02:55:00 -08:00
parent bba61298ce
commit 9b939b5dbb
9 changed files with 383 additions and 184 deletions
@@ -122,7 +122,7 @@ const CompactItemCard = ({
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const isSelected = const isSelected =
data && internalState && typeof data === 'object' && 'id' in data data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected((data as any).id) ? internalState.isSelected(internalState.extractRowId(data) || '')
: false; : false;
if (data) { if (data) {
@@ -226,7 +226,7 @@ const DefaultItemCard = ({
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const isSelected = const isSelected =
data && internalState && typeof data === 'object' && 'id' in data data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected((data as any).id) ? internalState.isSelected(internalState.extractRowId(data) || '')
: false; : false;
if (data) { if (data) {
@@ -331,7 +331,7 @@ const PosterItemCard = ({
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const isSelected = const isSelected =
data && internalState && typeof data === 'object' && 'id' in data data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected((data as any).id) ? internalState.isSelected(internalState.extractRowId(data) || '')
: false; : false;
const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({ const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({
@@ -0,0 +1,28 @@
/**
* Creates a function to extract row ID from an item based on the getRowId configuration.
*
* @param getRowId - Either a string property name, a function that extracts the ID, or undefined to use default 'id' property
* @returns A function that extracts the row ID from an item
*/
export const createExtractRowId = (
getRowId?: ((item: unknown) => string) | string,
): ((item: unknown) => string | undefined) => {
return (item: unknown): string | undefined => {
if (!item || typeof item !== 'object') {
return undefined;
}
if (getRowId === undefined) {
// Default behavior: use 'id' property
return (item as any).id;
}
if (typeof getRowId === 'string') {
// getRowId is a property name
return (item as any)[getRowId];
}
// getRowId is a function
return getRowId(item);
};
};
@@ -46,12 +46,18 @@ export const getDraggedItems = (
return []; return [];
} }
const rowId = internalState.extractRowId(data);
if (!rowId) {
return [];
}
const draggedItem = data as ItemListStateItemWithRequiredProperties; const draggedItem = data as ItemListStateItemWithRequiredProperties;
const previouslySelected = internalState.getSelected(); const previouslySelected = internalState.getSelected();
const isDraggingSelectedItem = previouslySelected.some((selected) => { const isDraggingSelectedItem = previouslySelected.some((selected) => {
if (hasRequiredDragProperties(selected)) { if (hasRequiredDragProperties(selected)) {
return selected.id === data.id; return internalState.extractRowId(selected) === rowId;
} }
return false; return false;
}); });
@@ -15,12 +15,16 @@ export const useDefaultItemListControls = () => {
return; return;
} }
// Use the full item instead of converting to minimal // Extract rowId from the item
const rowId = internalState.extractRowId(item);
if (!rowId) return;
// Use the item directly (rowId is separate, used only as key in state)
const itemListItem = item as ItemListStateItemWithRequiredProperties; const itemListItem = item as ItemListStateItemWithRequiredProperties;
// Check if ctrl/cmd key is held for multi-selection // Check if ctrl/cmd key is held for multi-selection
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey) {
const isCurrentlySelected = internalState.isSelected(item.id); const isCurrentlySelected = internalState.isSelected(rowId);
if (isCurrentlySelected) { if (isCurrentlySelected) {
// Remove this item from selection // Remove this item from selection
@@ -31,8 +35,7 @@ export const useDefaultItemListControls = () => {
): selectedItem is ItemListStateItemWithRequiredProperties => ): selectedItem is ItemListStateItemWithRequiredProperties =>
typeof selectedItem === 'object' && typeof selectedItem === 'object' &&
selectedItem !== null && selectedItem !== null &&
'id' in selectedItem && internalState.extractRowId(selectedItem) !== rowId,
(selectedItem as any).id !== item.id,
); );
internalState.setSelected(filteredSelected); internalState.setSelected(filteredSelected);
} else { } else {
@@ -58,19 +61,18 @@ export const useDefaultItemListControls = () => {
if ( if (
lastSelectedItem && lastSelectedItem &&
typeof lastSelectedItem === 'object' && typeof lastSelectedItem === 'object' &&
lastSelectedItem !== null && lastSelectedItem !== null
'id' in lastSelectedItem
) { ) {
// Get the data array from internalState // Get the data array from internalState
const data = internalState.getData(); const data = internalState.getData();
// Filter out null/undefined values (e.g., header row) // Filter out null/undefined values (e.g., header row)
const validData = data.filter( const validData = data.filter((d) => d && typeof d === 'object');
(d) => d && typeof d === 'object' && 'id' in d,
);
// Find the indices of the last selected item and current item // Find the indices of the last selected item and current item
const lastIndex = internalState.findItemIndex((lastSelectedItem as any).id); const lastRowId = internalState.extractRowId(lastSelectedItem);
const currentIndex = internalState.findItemIndex(item.id); if (!lastRowId) return;
const lastIndex = internalState.findItemIndex(lastRowId);
const currentIndex = internalState.findItemIndex(rowId);
if (lastIndex !== -1 && currentIndex !== -1) { if (lastIndex !== -1 && currentIndex !== -1) {
// Create range selection - select ALL items in the range // Create range selection - select ALL items in the range
@@ -83,13 +85,15 @@ export const useDefaultItemListControls = () => {
if ( if (
rangeItem && rangeItem &&
typeof rangeItem === 'object' && typeof rangeItem === 'object' &&
'id' in rangeItem &&
'_serverId' in rangeItem && '_serverId' in rangeItem &&
'itemType' in rangeItem 'itemType' in rangeItem
) { ) {
rangeItems.push( const rangeRowId = internalState.extractRowId(rangeItem);
rangeItem as ItemListStateItemWithRequiredProperties, if (rangeRowId) {
); rangeItems.push(
rangeItem as ItemListStateItemWithRequiredProperties,
);
}
} }
} }
@@ -104,9 +108,12 @@ export const useDefaultItemListControls = () => {
), ),
]; ];
rangeItems.forEach((rangeItem) => { rangeItems.forEach((rangeItem) => {
const rangeRowId = internalState.extractRowId(rangeItem);
if ( if (
rangeRowId &&
!newSelected.some( !newSelected.some(
(selected) => (selected as any).id === rangeItem.id, (selected) =>
internalState.extractRowId(selected) === rangeRowId,
) )
) { ) {
newSelected.push(rangeItem); newSelected.push(rangeItem);
@@ -126,8 +133,7 @@ export const useDefaultItemListControls = () => {
selectedItems.length === 1 && selectedItems.length === 1 &&
typeof selectedItems[0] === 'object' && typeof selectedItems[0] === 'object' &&
selectedItems[0] !== null && selectedItems[0] !== null &&
'id' in selectedItems[0] && internalState.extractRowId(selectedItems[0]) === rowId;
(selectedItems[0] as any).id === item.id;
if (isOnlySelected) { if (isOnlySelected) {
internalState.clearSelected(); internalState.clearSelected();
@@ -146,9 +152,14 @@ export const useDefaultItemListControls = () => {
return; return;
} }
return internalState?.toggleExpanded( // Extract rowId from the item
item as ItemListStateItemWithRequiredProperties, const rowId = internalState.extractRowId(item);
); if (!rowId) return;
// Use the item directly (rowId is separate, used only as key in state)
const itemListItem = item as ItemListStateItemWithRequiredProperties;
return internalState?.toggleExpanded(itemListItem);
}, },
onFavorite: ({ onFavorite: ({
@@ -21,27 +21,47 @@ export const itemGridActions = {
type: 'CLEAR_SELECTED', type: 'CLEAR_SELECTED',
}), }),
setDragging: (items: ItemListStateItemWithRequiredProperties[]): ItemListAction => ({ setDragging: (
items: ItemListStateItemWithRequiredProperties[],
extractRowId: (item: unknown) => string | undefined,
): ItemListAction => ({
extractRowId,
payload: items, payload: items,
type: 'SET_DRAGGING', type: 'SET_DRAGGING',
}), }),
setExpanded: (items: ItemListStateItemWithRequiredProperties[]): ItemListAction => ({ setExpanded: (
items: ItemListStateItemWithRequiredProperties[],
extractRowId: (item: unknown) => string | undefined,
): ItemListAction => ({
extractRowId,
payload: items, payload: items,
type: 'SET_EXPANDED', type: 'SET_EXPANDED',
}), }),
setSelected: (items: ItemListStateItemWithRequiredProperties[]): ItemListAction => ({ setSelected: (
items: ItemListStateItemWithRequiredProperties[],
extractRowId: (item: unknown) => string | undefined,
): ItemListAction => ({
extractRowId,
payload: items, payload: items,
type: 'SET_SELECTED', type: 'SET_SELECTED',
}), }),
toggleExpanded: (item: ItemListStateItemWithRequiredProperties): ItemListAction => ({ toggleExpanded: (
item: ItemListStateItemWithRequiredProperties,
extractRowId: (item: unknown) => string | undefined,
): ItemListAction => ({
extractRowId,
payload: item, payload: item,
type: 'TOGGLE_EXPANDED', type: 'TOGGLE_EXPANDED',
}), }),
toggleSelected: (item: ItemListStateItemWithRequiredProperties): ItemListAction => ({ toggleSelected: (
item: ItemListStateItemWithRequiredProperties,
extractRowId: (item: unknown) => string | undefined,
): ItemListAction => ({
extractRowId,
payload: item, payload: item,
type: 'TOGGLE_SELECTED', type: 'TOGGLE_SELECTED',
}), }),
@@ -104,16 +124,16 @@ export const itemGridSelectors = {
return state.selected.size > 0; return state.selected.size > 0;
}, },
isDragging: (state: ItemListState, itemId: string): boolean => { isDragging: (state: ItemListState, rowId: string): boolean => {
return state.dragging.has(itemId); return state.dragging.has(rowId);
}, },
isExpanded: (state: ItemListState, itemId: string): boolean => { isExpanded: (state: ItemListState, rowId: string): boolean => {
return state.expanded.has(itemId); return state.expanded.has(rowId);
}, },
isSelected: (state: ItemListState, itemId: string): boolean => { isSelected: (state: ItemListState, rowId: string): boolean => {
return state.selected.has(itemId); return state.selected.has(rowId);
}, },
}; };
@@ -121,15 +141,15 @@ export const itemListUtils = {
/** /**
* Check if all items in a list are selected * Check if all items in a list are selected
*/ */
areAllSelected: (state: ItemListState, itemIds: string[]): boolean => { areAllSelected: (state: ItemListState, rowIds: string[]): boolean => {
return itemIds.every((id) => state.selected.has(id)); return rowIds.every((id) => state.selected.has(id));
}, },
/** /**
* Check if any items in a list are selected * Check if any items in a list are selected
*/ */
areAnySelected: (state: ItemListState, itemIds: string[]): boolean => { areAnySelected: (state: ItemListState, rowIds: string[]): boolean => {
return itemIds.some((id) => state.selected.has(id)); return rowIds.some((id) => state.selected.has(id));
}, },
/** /**
@@ -152,9 +172,15 @@ export const itemListUtils = {
toggleAllExpanded: ( toggleAllExpanded: (
items: ItemListStateItemWithRequiredProperties[], items: ItemListStateItemWithRequiredProperties[],
currentState: ItemListState, currentState: ItemListState,
extractRowId: (item: unknown) => string | undefined,
): ItemListAction => { ): ItemListAction => {
const allExpanded = items.every((item) => currentState.expanded.has(item.id)); const allExpanded = items.every((item) => {
return allExpanded ? itemGridActions.clearExpanded() : itemGridActions.setExpanded(items); const rowId = extractRowId(item);
return rowId ? currentState.expanded.has(rowId) : false;
});
return allExpanded
? itemGridActions.clearExpanded()
: itemGridActions.setExpanded(items, extractRowId);
}, },
/** /**
@@ -163,8 +189,14 @@ export const itemListUtils = {
toggleAllSelected: ( toggleAllSelected: (
items: ItemListStateItemWithRequiredProperties[], items: ItemListStateItemWithRequiredProperties[],
currentState: ItemListState, currentState: ItemListState,
extractRowId: (item: unknown) => string | undefined,
): ItemListAction => { ): ItemListAction => {
const allSelected = items.every((item) => currentState.selected.has(item.id)); const allSelected = items.every((item) => {
return allSelected ? itemGridActions.clearSelected() : itemGridActions.setSelected(items); const rowId = extractRowId(item);
return rowId ? currentState.selected.has(rowId) : false;
});
return allSelected
? itemGridActions.clearSelected()
: itemGridActions.setSelected(items, extractRowId);
}, },
}; };
@@ -4,11 +4,31 @@ import { itemGridSelectors } from '/@/renderer/components/item-list/helpers/item
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
export type ItemListAction = export type ItemListAction =
| { payload: ItemListStateItemWithRequiredProperties; type: 'TOGGLE_EXPANDED' } | {
| { payload: ItemListStateItemWithRequiredProperties; type: 'TOGGLE_SELECTED' } extractRowId: (item: unknown) => string | undefined;
| { payload: ItemListStateItemWithRequiredProperties[]; type: 'SET_DRAGGING' } payload: ItemListStateItemWithRequiredProperties;
| { payload: ItemListStateItemWithRequiredProperties[]; type: 'SET_EXPANDED' } type: 'TOGGLE_EXPANDED';
| { payload: ItemListStateItemWithRequiredProperties[]; type: 'SET_SELECTED' } }
| {
extractRowId: (item: unknown) => string | undefined;
payload: ItemListStateItemWithRequiredProperties;
type: 'TOGGLE_SELECTED';
}
| {
extractRowId: (item: unknown) => string | undefined;
payload: ItemListStateItemWithRequiredProperties[];
type: 'SET_DRAGGING';
}
| {
extractRowId: (item: unknown) => string | undefined;
payload: ItemListStateItemWithRequiredProperties[];
type: 'SET_EXPANDED';
}
| {
extractRowId: (item: unknown) => string | undefined;
payload: ItemListStateItemWithRequiredProperties[];
type: 'SET_SELECTED';
}
| { type: 'CLEAR_ALL' } | { type: 'CLEAR_ALL' }
| { type: 'CLEAR_DRAGGING' } | { type: 'CLEAR_DRAGGING' }
| { type: 'CLEAR_EXPANDED' } | { type: 'CLEAR_EXPANDED' }
@@ -29,7 +49,8 @@ export interface ItemListStateActions {
clearDragging: () => void; clearDragging: () => void;
clearExpanded: () => void; clearExpanded: () => void;
clearSelected: () => void; clearSelected: () => void;
findItemIndex: (itemId: string) => number; extractRowId: (item: unknown) => string | undefined;
findItemIndex: (rowId: string) => number;
getData: () => unknown[]; getData: () => unknown[];
getDragging: () => unknown[]; getDragging: () => unknown[];
getDraggingIds: () => string[]; getDraggingIds: () => string[];
@@ -41,9 +62,9 @@ export interface ItemListStateActions {
hasDragging: () => boolean; hasDragging: () => boolean;
hasExpanded: () => boolean; hasExpanded: () => boolean;
hasSelected: () => boolean; hasSelected: () => boolean;
isDragging: (itemId: string) => boolean; isDragging: (rowId: string) => boolean;
isExpanded: (itemId: string) => boolean; isExpanded: (rowId: string) => boolean;
isSelected: (itemId: string) => boolean; isSelected: (rowId: string) => boolean;
setDragging: (items: ItemListStateItemWithRequiredProperties[]) => void; setDragging: (items: ItemListStateItemWithRequiredProperties[]) => void;
setExpanded: (items: ItemListStateItemWithRequiredProperties[]) => void; setExpanded: (items: ItemListStateItemWithRequiredProperties[]) => void;
setSelected: (items: ItemListStateItemWithRequiredProperties[]) => void; setSelected: (items: ItemListStateItemWithRequiredProperties[]) => void;
@@ -110,8 +131,11 @@ export const itemListReducer = (state: ItemListState, action: ItemListAction): I
const newDraggingItems = new Map<string, unknown>(); const newDraggingItems = new Map<string, unknown>();
action.payload.forEach((item) => { action.payload.forEach((item) => {
newDragging.add(item.id); const rowId = action.extractRowId(item);
newDraggingItems.set(item.id, item); if (rowId) {
newDragging.add(rowId);
newDraggingItems.set(rowId, item);
}
}); });
return { return {
@@ -128,8 +152,11 @@ export const itemListReducer = (state: ItemListState, action: ItemListAction): I
if (action.payload.length > 0) { if (action.payload.length > 0) {
const firstItem = action.payload[0]; const firstItem = action.payload[0];
newExpanded.add(firstItem.id); const rowId = action.extractRowId(firstItem);
newExpandedItems.set(firstItem.id, firstItem); if (rowId) {
newExpanded.add(rowId);
newExpandedItems.set(rowId, firstItem);
}
} }
return { return {
@@ -145,8 +172,11 @@ export const itemListReducer = (state: ItemListState, action: ItemListAction): I
const newSelectedItems = new Map<string, unknown>(); const newSelectedItems = new Map<string, unknown>();
action.payload.forEach((item) => { action.payload.forEach((item) => {
newSelected.add(item.id); const rowId = action.extractRowId(item);
newSelectedItems.set(item.id, item); if (rowId) {
newSelected.add(rowId);
newSelectedItems.set(rowId, item);
}
}); });
return { return {
@@ -161,13 +191,18 @@ export const itemListReducer = (state: ItemListState, action: ItemListAction): I
const newExpanded = new Set<string>(); const newExpanded = new Set<string>();
const newExpandedItems = new Map<string, unknown>(); const newExpandedItems = new Map<string, unknown>();
const rowId = action.extractRowId(action.payload);
if (!rowId) {
return state;
}
// If the item is already expanded, collapse it // If the item is already expanded, collapse it
if (state.expanded.has(action.payload.id)) { if (state.expanded.has(rowId)) {
// Item is expanded, so collapse it (leave sets empty) // Item is expanded, so collapse it (leave sets empty)
} else { } else {
// Item is not expanded, so expand it (clear others first for single expansion) // Item is not expanded, so expand it (clear others first for single expansion)
newExpanded.add(action.payload.id); newExpanded.add(rowId);
newExpandedItems.set(action.payload.id, action.payload); newExpandedItems.set(rowId, action.payload);
} }
return { return {
@@ -182,12 +217,17 @@ export const itemListReducer = (state: ItemListState, action: ItemListAction): I
const newSelected = new Set(state.selected); const newSelected = new Set(state.selected);
const newSelectedItems = new Map(state.selectedItems); const newSelectedItems = new Map(state.selectedItems);
if (newSelected.has(action.payload.id)) { const rowId = action.extractRowId(action.payload);
newSelected.delete(action.payload.id); if (!rowId) {
newSelectedItems.delete(action.payload.id); return state;
}
if (newSelected.has(rowId)) {
newSelected.delete(rowId);
newSelectedItems.delete(rowId);
} else { } else {
newSelected.add(action.payload.id); newSelected.add(rowId);
newSelectedItems.set(action.payload.id, action.payload); newSelectedItems.set(rowId, action.payload);
} }
return { return {
@@ -213,39 +253,91 @@ export const initialItemListState: ItemListState = {
version: 0, version: 0,
}; };
export const useItemListState = (getDataFn?: () => unknown[]): ItemListStateActions => { export const useItemListState = (
getDataFn?: () => unknown[],
extractRowId?: (item: unknown) => string | undefined,
): ItemListStateActions => {
const [state, dispatch] = useReducer(itemListReducer, initialItemListState); const [state, dispatch] = useReducer(itemListReducer, initialItemListState);
const setExpanded = useCallback((items: ItemListStateItemWithRequiredProperties[]) => { const extractRowIdFn = useCallback(
dispatch({ payload: items, type: 'SET_EXPANDED' }); (item: unknown) => {
}, []); if (extractRowId) {
return extractRowId(item);
}
// Fallback to id if extractRowId is not provided
if (item && typeof item === 'object' && 'id' in item) {
return (item as any).id;
}
return undefined;
},
[extractRowId],
);
const setDragging = useCallback((items: ItemListStateItemWithRequiredProperties[]) => { const setExpanded = useCallback(
dispatch({ payload: items, type: 'SET_DRAGGING' }); (items: ItemListStateItemWithRequiredProperties[]) => {
}, []); dispatch({
extractRowId: extractRowIdFn,
payload: items,
type: 'SET_EXPANDED',
});
},
[extractRowIdFn],
);
const setSelected = useCallback((items: ItemListStateItemWithRequiredProperties[]) => { const setDragging = useCallback(
dispatch({ payload: items, type: 'SET_SELECTED' }); (items: ItemListStateItemWithRequiredProperties[]) => {
}, []); dispatch({
extractRowId: extractRowIdFn,
payload: items,
type: 'SET_DRAGGING',
});
},
[extractRowIdFn],
);
const toggleExpanded = useCallback((item: ItemListStateItemWithRequiredProperties) => { const setSelected = useCallback(
dispatch({ payload: item, type: 'TOGGLE_EXPANDED' }); (items: ItemListStateItemWithRequiredProperties[]) => {
}, []); dispatch({
extractRowId: extractRowIdFn,
payload: items,
type: 'SET_SELECTED',
});
},
[extractRowIdFn],
);
const toggleSelected = useCallback((item: ItemListStateItemWithRequiredProperties) => { const toggleExpanded = useCallback(
dispatch({ payload: item, type: 'TOGGLE_SELECTED' }); (item: ItemListStateItemWithRequiredProperties) => {
}, []); dispatch({
extractRowId: extractRowIdFn,
payload: item,
type: 'TOGGLE_EXPANDED',
});
},
[extractRowIdFn],
);
const toggleSelected = useCallback(
(item: ItemListStateItemWithRequiredProperties) => {
dispatch({
extractRowId: extractRowIdFn,
payload: item,
type: 'TOGGLE_SELECTED',
});
},
[extractRowIdFn],
);
const isExpanded = useCallback( const isExpanded = useCallback(
(itemId: string) => { (rowId: string) => {
return itemGridSelectors.isExpanded(state, itemId); return itemGridSelectors.isExpanded(state, rowId);
}, },
[state], [state],
); );
const isSelected = useCallback( const isSelected = useCallback(
(itemId: string) => { (rowId: string) => {
return itemGridSelectors.isSelected(state, itemId); return itemGridSelectors.isSelected(state, rowId);
}, },
[state], [state],
); );
@@ -307,8 +399,8 @@ export const useItemListState = (getDataFn?: () => unknown[]): ItemListStateActi
}, [state]); }, [state]);
const isDragging = useCallback( const isDragging = useCallback(
(itemId: string) => { (rowId: string) => {
return itemGridSelectors.isDragging(state, itemId); return itemGridSelectors.isDragging(state, rowId);
}, },
[state], [state],
); );
@@ -318,13 +410,17 @@ export const useItemListState = (getDataFn?: () => unknown[]): ItemListStateActi
}, [getDataFn]); }, [getDataFn]);
const findItemIndex = useCallback( const findItemIndex = useCallback(
(itemId: string) => { (rowId: string) => {
const data = getDataFn ? getDataFn() : []; const data = getDataFn ? getDataFn() : [];
// Filter out null/undefined values (e.g., header row) // Filter out null/undefined values (e.g., header row)
const validData = data.filter((d) => d && typeof d === 'object' && 'id' in d); const validData = data.filter((d) => d && typeof d === 'object');
return validData.findIndex((d) => (d as any).id === itemId); if (!extractRowId) {
// Fallback to id if extractRowId is not provided
return validData.findIndex((d) => (d as any).id === rowId);
}
return validData.findIndex((d) => extractRowId(d) === rowId);
}, },
[getDataFn], [getDataFn, extractRowId],
); );
return useMemo( return useMemo(
@@ -333,6 +429,7 @@ export const useItemListState = (getDataFn?: () => unknown[]): ItemListStateActi
clearDragging, clearDragging,
clearExpanded, clearExpanded,
clearSelected, clearSelected,
extractRowId: extractRowIdFn,
findItemIndex, findItemIndex,
getData, getData,
getDragging, getDragging,
@@ -359,6 +456,7 @@ export const useItemListState = (getDataFn?: () => unknown[]): ItemListStateActi
clearDragging, clearDragging,
clearExpanded, clearExpanded,
clearSelected, clearSelected,
extractRowIdFn,
findItemIndex, findItemIndex,
getData, getData,
getDragging, getDragging,
@@ -34,10 +34,11 @@ import {
} from '/@/renderer/components/item-card/item-card'; } from '/@/renderer/components/item-card/item-card';
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container'; import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item'; import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { import {
ItemListStateActions, ItemListStateActions,
ItemListStateItem, ItemListStateItemWithRequiredProperties,
useItemListState, useItemListState,
} from '/@/renderer/components/item-list/helpers/item-list-state'; } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls, ItemListHandle } from '/@/renderer/components/item-list/types'; import { ItemControls, ItemListHandle } from '/@/renderer/components/item-list/types';
@@ -249,6 +250,7 @@ export interface ItemGridListProps {
enableExpansion?: boolean; enableExpansion?: boolean;
enableSelection?: boolean; enableSelection?: boolean;
gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
getRowId?: ((item: unknown) => string) | string;
initialTop?: number; initialTop?: number;
itemsPerRow?: number; itemsPerRow?: number;
itemType: LibraryItem; itemType: LibraryItem;
@@ -264,6 +266,7 @@ export const ItemGridList = ({
enableExpansion = true, enableExpansion = true,
enableSelection = true, enableSelection = true,
gap = 'sm', gap = 'sm',
getRowId,
initialTop, initialTop,
itemsPerRow, itemsPerRow,
itemType, itemType,
@@ -284,7 +287,9 @@ export const ItemGridList = ({
return data; return data;
}, [data]); }, [data]);
const internalState = useItemListState(getDataFn); const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]);
const internalState = useItemListState(getDataFn, extractRowId);
const [initialize] = useOverlayScrollbars({ const [initialize] = useOverlayScrollbars({
defer: false, defer: false,
@@ -372,13 +377,13 @@ export const ItemGridList = ({
if (selected.length > 0) { if (selected.length > 0) {
const lastSelected = selected[selected.length - 1]; const lastSelected = selected[selected.length - 1];
currentIndex = data.findIndex( const lastRowId = internalState.extractRowId(lastSelected);
(d: any) => if (lastRowId) {
d && currentIndex = data.findIndex((d: any) => {
typeof d === 'object' && const rowId = internalState.extractRowId(d);
'id' in d && return rowId === lastRowId;
d.id === (lastSelected as any).id, });
); }
} }
// Calculate grid position // Calculate grid position
@@ -474,85 +479,87 @@ export const ItemGridList = ({
if (lastSelectedItem) { if (lastSelectedItem) {
// Find the indices of the last selected item and new item // Find the indices of the last selected item and new item
const lastIndex = data.findIndex( const lastRowId = internalState.extractRowId(lastSelectedItem);
(d: any) => if (!lastRowId) return;
d &&
typeof d === 'object' && const lastIndex = data.findIndex((d: any) => {
'id' in d && const rowId = internalState.extractRowId(d);
d.id === (lastSelectedItem as any).id, return rowId === lastRowId;
); });
if (lastIndex !== -1 && newIndex !== -1) { if (lastIndex !== -1 && newIndex !== -1) {
// Create range selection from last selected to new position // Create range selection from last selected to new position
const startIndex = Math.min(lastIndex, newIndex); const startIndex = Math.min(lastIndex, newIndex);
const stopIndex = Math.max(lastIndex, newIndex); const stopIndex = Math.max(lastIndex, newIndex);
const rangeItems: ItemListStateItem[] = []; const rangeItems: ItemListStateItemWithRequiredProperties[] = [];
for (let i = startIndex; i <= stopIndex; i++) { for (let i = startIndex; i <= stopIndex; i++) {
const rangeItem = data[i]; const rangeItem = data[i];
if ( if (
rangeItem && rangeItem &&
typeof rangeItem === 'object' && typeof rangeItem === 'object' &&
'id' in rangeItem && '_serverId' in rangeItem &&
'serverId' in rangeItem 'itemType' in rangeItem &&
internalState.extractRowId(rangeItem)
) { ) {
rangeItems.push({ rangeItems.push(
_serverId: (rangeItem as any).serverId, rangeItem as ItemListStateItemWithRequiredProperties,
id: (rangeItem as any).id, );
itemType,
});
} }
} }
// Add range items to selection (matching shift+click behavior) // Add range items to selection (matching shift+click behavior)
const currentSelected = internalState.getSelected(); const currentSelected = internalState.getSelected();
const newSelected = [...currentSelected]; const newSelected: ItemListStateItemWithRequiredProperties[] = [
...currentSelected.filter(
(item): item is ItemListStateItemWithRequiredProperties =>
typeof item === 'object' && item !== null,
),
];
rangeItems.forEach((rangeItem) => { rangeItems.forEach((rangeItem) => {
const rangeRowId = internalState.extractRowId(rangeItem);
if ( if (
!newSelected.some((selected: any) => selected.id === rangeItem.id) rangeRowId &&
!newSelected.some(
(selected) =>
internalState.extractRowId(selected) === rangeRowId,
)
) { ) {
newSelected.push(rangeItem); newSelected.push(rangeItem);
} }
}); });
// Ensure the last item in selection is the item at newIndex for incremental extension // Ensure the last item in selection is the item at newIndex for incremental extension
const newItemListItem: ItemListStateItem = { const newItemListItem = newItem as ItemListStateItemWithRequiredProperties;
_serverId: newItem.serverId, const newItemRowId = internalState.extractRowId(newItemListItem);
id: newItem.id, if (newItemRowId) {
itemType, // Remove the new item from its current position if it exists
}; const filteredSelected = newSelected.filter(
// Remove the new item from its current position if it exists (item) => internalState.extractRowId(item) !== newItemRowId,
const filteredSelected = newSelected.filter( );
(item: any) => item.id !== newItemListItem.id, // Add it at the end so it becomes the last selected item
); filteredSelected.push(newItemListItem);
// Add it at the end so it becomes the last selected item internalState.setSelected(filteredSelected);
filteredSelected.push(newItemListItem); }
internalState.setSelected(filteredSelected as any);
} }
} else { } else {
// No previous selection, just select the new item // No previous selection, just select the new item
internalState.setSelected([ const newItemListItem = newItem as ItemListStateItemWithRequiredProperties;
{ if (internalState.extractRowId(newItemListItem)) {
_serverId: newItem.serverId, internalState.setSelected([newItemListItem]);
id: newItem.id, }
itemType,
},
]);
} }
} else { } else {
// Without Shift: select only the new item // Without Shift: select only the new item
internalState.setSelected([ const newItemListItem = newItem as ItemListStateItemWithRequiredProperties;
{ if (internalState.extractRowId(newItemListItem)) {
_serverId: newItem.serverId, internalState.setSelected([newItemListItem]);
id: newItem.id, }
itemType,
},
]);
} }
scrollToIndex(newIndex); scrollToIndex(newIndex);
}, },
[data, enableSelection, internalState, itemType, tableMeta, scrollToIndex], [data, enableSelection, internalState, tableMeta, scrollToIndex],
); );
const imperativeHandle: ItemListHandle = useMemo(() => { const imperativeHandle: ItemListHandle = useMemo(() => {
@@ -335,7 +335,7 @@ export const TableColumnTextContainer = (
const item = isDataRow ? props.data[props.rowIndex] : null; const item = isDataRow ? props.data[props.rowIndex] : null;
const isSelected = const isSelected =
item && typeof item === 'object' && 'id' in item item && typeof item === 'object' && 'id' in item
? props.internalState.isSelected((item as any).id) ? props.internalState.isSelected(props.internalState.extractRowId(item) || '')
: false; : false;
const isDragging = props.isDragging ?? false; const isDragging = props.isDragging ?? false;
@@ -503,7 +503,7 @@ export const TableColumnContainer = (
const item = isDataRow ? props.data[props.rowIndex] : null; const item = isDataRow ? props.data[props.rowIndex] : null;
const isSelected = const isSelected =
item && typeof item === 'object' && 'id' in item item && typeof item === 'object' && 'id' in item
? props.internalState.isSelected((item as any).id) ? props.internalState.isSelected(props.internalState.extractRowId(item) || '')
: false; : false;
const isDragging = props.isDragging ?? false; const isDragging = props.isDragging ?? false;
@@ -23,6 +23,7 @@ import styles from './item-table-list.module.css';
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container'; import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item'; import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { import {
ItemListStateActions, ItemListStateActions,
@@ -70,7 +71,9 @@ const hasRequiredStateItemProperties = (
'_serverId' in item && '_serverId' in item &&
typeof (item as any)._serverId === 'string' && typeof (item as any)._serverId === 'string' &&
'itemType' in item && 'itemType' in item &&
typeof (item as any).itemType === 'string' typeof (item as any).itemType === 'string' &&
'rowId' in item &&
typeof (item as any).rowId === 'string'
); );
}; };
@@ -498,6 +501,7 @@ interface ItemTableListProps {
enableRowHoverHighlight?: boolean; enableRowHoverHighlight?: boolean;
enableSelection?: boolean; enableSelection?: boolean;
enableVerticalBorders?: boolean; enableVerticalBorders?: boolean;
getRowId?: ((item: unknown) => string) | string;
headerHeight?: number; headerHeight?: number;
initialTop?: { initialTop?: {
behavior?: 'auto' | 'smooth'; behavior?: 'auto' | 'smooth';
@@ -527,6 +531,7 @@ export const ItemTableList = ({
enableRowHoverHighlight = true, enableRowHoverHighlight = true,
enableSelection = true, enableSelection = true,
enableVerticalBorders = false, enableVerticalBorders = false,
getRowId,
headerHeight = 40, headerHeight = 40,
initialTop, initialTop,
itemType, itemType,
@@ -1086,10 +1091,29 @@ export const ItemTableList = ({
return enableHeader ? [null, ...data] : data; return enableHeader ? [null, ...data] : data;
}, [data, enableHeader]); }, [data, enableHeader]);
const internalState = useItemListState(getDataFn); const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]);
const internalState = useItemListState(getDataFn, extractRowId);
const hasExpanded = internalState.hasExpanded(); const hasExpanded = internalState.hasExpanded();
// Helper function to get ItemListStateItemWithRequiredProperties (rowId is separate, not part of item)
const getStateItem = useCallback(
(item: any): ItemListStateItemWithRequiredProperties | null => {
if (!hasRequiredItemProperties(item)) return null;
if (
typeof item === 'object' &&
item !== null &&
'_serverId' in item &&
'itemType' in item
) {
return item as ItemListStateItemWithRequiredProperties;
}
return null;
},
[],
);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!enableSelection) return; if (!enableSelection) return;
@@ -1127,9 +1151,11 @@ export const ItemTableList = ({
if (lastSelectedItem) { if (lastSelectedItem) {
// Find the indices of the last selected item and new item // Find the indices of the last selected item and new item
const lastIndex = data.findIndex( const lastRowId = lastSelectedItem.rowId;
(d) => hasRequiredItemProperties(d) && d.id === lastSelectedItem.id, const lastIndex = data.findIndex((d) => {
); const rowId = extractRowId(d);
return rowId === lastRowId;
});
if (lastIndex !== -1 && newIndex !== -1) { if (lastIndex !== -1 && newIndex !== -1) {
// Create range selection from last selected to new position // Create range selection from last selected to new position
@@ -1139,12 +1165,9 @@ export const ItemTableList = ({
const rangeItems: ItemListStateItemWithRequiredProperties[] = []; const rangeItems: ItemListStateItemWithRequiredProperties[] = [];
for (let i = startIndex; i <= stopIndex; i++) { for (let i = startIndex; i <= stopIndex; i++) {
const rangeItem = data[i]; const rangeItem = data[i];
if (hasRequiredItemProperties(rangeItem)) { const stateItem = getStateItem(rangeItem);
rangeItems.push({ if (stateItem && extractRowId(stateItem)) {
_serverId: rangeItem.serverId, rangeItems.push(stateItem);
id: rangeItem.id,
itemType,
} as ItemListStateItemWithRequiredProperties);
} }
} }
@@ -1157,21 +1180,24 @@ export const ItemTableList = ({
...validSelected, ...validSelected,
]; ];
rangeItems.forEach((rangeItem) => { rangeItems.forEach((rangeItem) => {
if (!newSelected.some((selected) => selected.id === rangeItem.id)) { const rangeRowId = extractRowId(rangeItem);
if (
rangeRowId &&
!newSelected.some(
(selected) => extractRowId(selected) === rangeRowId,
)
) {
newSelected.push(rangeItem); newSelected.push(rangeItem);
} }
}); });
// Ensure the last item in selection is the item at newIndex for incremental extension // Ensure the last item in selection is the item at newIndex for incremental extension
if (hasRequiredItemProperties(newItem)) { const newItemListItem = getStateItem(newItem);
const newItemListItem: ItemListStateItemWithRequiredProperties = { if (newItemListItem && extractRowId(newItemListItem)) {
_serverId: newItem.serverId, const newItemRowId = extractRowId(newItemListItem);
id: newItem.id,
itemType,
} as ItemListStateItemWithRequiredProperties;
// Remove the new item from its current position if it exists // Remove the new item from its current position if it exists
const filteredSelected = newSelected.filter( const filteredSelected = newSelected.filter(
(item) => item.id !== newItemListItem.id, (item) => extractRowId(item) !== newItemRowId,
); );
// Add it at the end so it becomes the last selected item // Add it at the end so it becomes the last selected item
filteredSelected.push(newItemListItem); filteredSelected.push(newItemListItem);
@@ -1180,26 +1206,16 @@ export const ItemTableList = ({
} }
} else { } else {
// No previous selection, just select the new item // No previous selection, just select the new item
if (hasRequiredItemProperties(newItem)) { const newItemListItem = getStateItem(newItem);
internalState.setSelected([ if (newItemListItem && extractRowId(newItemListItem)) {
{ internalState.setSelected([newItemListItem]);
_serverId: newItem.serverId,
id: newItem.id,
itemType,
} as ItemListStateItemWithRequiredProperties,
]);
} }
} }
} else { } else {
// Without Shift: select only the new item // Without Shift: select only the new item
if (hasRequiredItemProperties(newItem)) { const newItemListItem = getStateItem(newItem);
internalState.setSelected([ if (newItemListItem && extractRowId(newItemListItem)) {
{ internalState.setSelected([newItemListItem]);
_serverId: newItem.serverId,
id: newItem.id,
itemType,
} as ItemListStateItemWithRequiredProperties,
]);
} }
} }
@@ -1210,9 +1226,10 @@ export const ItemTableList = ({
data, data,
enableSelection, enableSelection,
internalState, internalState,
itemType,
calculateScrollTopForIndex, calculateScrollTopForIndex,
scrollToTableOffset, scrollToTableOffset,
extractRowId,
getStateItem,
], ],
); );