Project

General

Profile

Download (8.17 KB) Statistics
| Branch: | Tag: | Revision:
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { isEmpty } from 'lodash';
import { useLocation } from 'react-router-dom';

class ReactConnectedSet extends Set {
constructor(initialValue, forceRender) {
super();
this.forceRender = forceRender;
// The constructor would normally call add() with the initial value, but since we
// must call super() at the top, this.forceRender() isn't defined yet.
// So, we call super() above with no argument, then call add() manually below
// after forceRender is defined
if (initialValue) {
if (initialValue.constructor.name === 'Array') {
initialValue.forEach(id => super.add(id));
} else {
super.add(initialValue);
}
}
}

add(value) {
const result = super.add(value); // ensuring these methods have the same API as the superclass
this.forceRender();
return result;
}

clear() {
const result = super.clear();
this.forceRender();
return result;
}

delete(value) {
const result = super.delete(value);
this.forceRender();
return result;
}

onToggle(isOpen, id) {
if (isOpen) {
this.add(id);
} else {
this.delete(id);
}
}

addAll(ids) {
ids.forEach(id => super.add(id));
this.forceRender();
}
}

export const useSet = initialArry => {
const [, setToggle] = useState(Date.now());
// needed because mutating a Ref won't cause React to rerender
const forceRender = () => setToggle(Symbol('useSet'));
const set = useRef(new ReactConnectedSet(initialArry, forceRender));
return set.current;
};

export const useSelectionSet = ({
results,
metadata,
defaultArry = [],
initialArry = [],
idColumn = 'id',
isSelectable = () => true,
}) => {
const selectionSet = useSet(initialArry);
const pageIds = results?.map(result => result[idColumn]) ?? [];
const selectableResults = useMemo(
() => results?.filter(result => isSelectable(result)) ?? [],
[results, isSelectable]
);
const selectedResults = useRef({}); // { id: result }
const canSelect = useCallback(
id => {
const selectableIds = new Set(
selectableResults.map(result => result[idColumn])
);
return selectableIds.has(id);
},
[idColumn, selectableResults]
);
const areAllRowsOnPageSelected = () =>
Number(pageIds?.length) > 0 &&
pageIds.every(result => selectionSet.has(result) || !canSelect(result));

const areAllRowsSelected = () =>
Number(selectionSet.size) > 0 &&
selectionSet.size === Number(metadata.selectable);

const selectPage = () => {
const selectablePageIds = pageIds.filter(canSelect);
selectionSet.addAll(selectablePageIds);
selectableResults.forEach(result => {
selectedResults.current[result[idColumn]] = result;
});
};

const clearSelectedResults = () => {
selectedResults.current = {};
};

const selectNone = () => {
selectionSet.clear();
clearSelectedResults();
};
const selectOne = (isSelected, id, data) => {
if (canSelect(id)) {
if (isSelected) {
if (data) selectedResults.current[id] = data;
selectionSet.add(id);
} else {
delete selectedResults.current[id];
selectionSet.delete(id);
}
}
};
const selectDefault = () => {
selectNone();
selectionSet.addAll(defaultArry);
defaultArry.forEach(id => {
selectedResults.current[id] = results.find(
result => result[idColumn] === id
);
});
};

const selectedCount = selectionSet.size;

const isSelected = useCallback(id => canSelect(id) && selectionSet.has(id), [
canSelect,
selectionSet,
]);

return {
selectOne,
selectedCount,
areAllRowsOnPageSelected,
areAllRowsSelected,
selectPage,
selectNone,
selectDefault,
isSelected,
isSelectable: canSelect,
selectionSet,
selectedResults: Object.values(selectedResults.current),
clearSelectedResults,
};
};

const usePrevious = value => {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
};

export const useBulkSelect = ({
results,
metadata,
initialArry = [],
initialExclusionArry = [],
defaultArry = [],
initialSearchQuery = '',
idColumn = 'id',
filtersQuery = '',
isSelectable,
initialSelectAllMode = false,
}) => {
const { selectionSet: inclusionSet, ...selectOptions } = useSelectionSet({
results,
metadata,
initialArry,
defaultArry,
idColumn,
isSelectable,
});
const exclusionSet = useSet(initialExclusionArry);
const [searchQuery, updateSearchQuery] = useState(initialSearchQuery);
const [selectAllMode, setSelectAllMode] = useState(initialSelectAllMode);
const selectedCount = selectAllMode
? Number(metadata.selectable || metadata.total) - exclusionSet.size
: selectOptions.selectedCount;

const areAllRowsOnPageSelected = () =>
selectAllMode || selectOptions.areAllRowsOnPageSelected();

const areAllRowsSelected = () =>
(selectAllMode && exclusionSet.size === 0) ||
selectOptions.areAllRowsSelected();

const isSelected = useCallback(
id => {
if (!selectOptions.isSelectable(id)) {
return false;
}
if (selectAllMode) {
return !exclusionSet.has(id);
}
return inclusionSet.has(id);
},
[exclusionSet, inclusionSet, selectAllMode, selectOptions]
);

const selectPage = () => {
setSelectAllMode(false);
selectOptions.selectPage();
};

const selectNone = useCallback(() => {
setSelectAllMode(false);
exclusionSet.clear();
inclusionSet.clear();
selectOptions.clearSelectedResults();
}, [exclusionSet, inclusionSet, selectOptions]);

const selectOne = (isRowSelected, id, data) => {
if (selectAllMode) {
if (isRowSelected) {
exclusionSet.delete(id);
} else {
exclusionSet.add(id);
}
} else {
selectOptions.selectOne(isRowSelected, id, data);
}
};

const selectAll = checked => {
setSelectAllMode(checked);
if (checked) {
exclusionSet.clear();
} else {
inclusionSet.clear();
}
};

const selectDefault = () => {
selectNone();
selectOptions.selectDefault();
};

const fetchBulkParams = ({
idColumnName = idColumn,
selectAllQuery = '',
} = {}) => {
const searchQueryWithExclusionSet = () => {
const query = [
searchQuery,
filtersQuery,
!isEmpty(exclusionSet) &&
`${idColumnName} !^ (${[...exclusionSet].join(',')})`,
selectAllQuery,
];
return query.filter(item => item).join(' and ');
};

const searchQueryWithInclusionSet = () => {
if (isEmpty(inclusionSet))
throw new Error('Cannot build a search query with no items selected');
return `${idColumnName} ^ (${[...inclusionSet].join(',')})`;
};
return selectAllMode
? searchQueryWithExclusionSet()
: searchQueryWithInclusionSet();
};

const prevSearchRef = usePrevious({ searchQuery });

useEffect(() => {
// if search value changed and cleared from a string to empty value
// And it was select all -> then reset selections
if (
prevSearchRef &&
!isEmpty(prevSearchRef.searchQuery) &&
isEmpty(searchQuery) &&
selectAllMode
) {
selectNone();
}
}, [searchQuery, selectAllMode, prevSearchRef, selectNone]);

return {
...selectOptions,
selectPage,
selectNone,
selectAll,
selectDefault,
selectAllMode,
isSelected,
selectedCount,
fetchBulkParams,
searchQuery,
updateSearchQuery,
selectOne,
areAllRowsOnPageSelected,
areAllRowsSelected,
inclusionSet,
exclusionSet,
};
};

export const friendlySearchParam = searchParam =>
decodeURIComponent(searchParam.replace(/\+/g, ' '));

// takes a url query like ?type=security&search=name+~+foo
// and returns an object
// {
// type: 'security',
// searchParam: 'name ~ foo'
// }
export const useUrlParams = () => {
const location = useLocation();
const { search: urlSearchParam, ...urlParams } = Object.fromEntries(
new URLSearchParams(location.search).entries()
);
const searchParam = urlSearchParam ? friendlySearchParam(urlSearchParam) : '';

return {
searchParam,
...urlParams,
};
};
(4-4/7)