refactor(listbox): range selections (#751)

* feat: working range selections

* refactor: support single select

* test: cover with unit tests

* test: use act instead of render

* Update apis/nucleus/src/components/listbox/listbox-selections.js

Co-authored-by: Tobias Linsefors <T-Wizard@users.noreply.github.com>
This commit is contained in:
Johan Lahti
2022-02-07 14:52:24 +01:00
committed by GitHub
parent ea50d8cf56
commit b756b6b6b6
9 changed files with 367 additions and 60 deletions

View File

@@ -1,12 +1,6 @@
/* eslint no-underscore-dangle:0 */
import React, {
useEffect,
useState,
useCallback,
useRef,
// useMemo,
} from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { FixedSizeList } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
@@ -25,6 +19,7 @@ export default function ListBox({
height,
width,
listLayout = 'vertical',
rangeSelect = true,
update = undefined,
}) {
const [layout] = useLayout(model);
@@ -34,6 +29,7 @@ export default function ListBox({
layout,
selections,
pages,
rangeSelect,
doc: document,
});
const loaderRef = useRef(null);
@@ -125,7 +121,7 @@ export default function ListBox({
}, [layout]);
useEffect(() => {
if (!instantPages && isLoadingData) {
if (!instantPages || isLoadingData) {
return;
}
setPages(instantPages);

View File

@@ -56,7 +56,7 @@ export default function Column({ index, style, data }) {
const classArr = [classes.column];
let label = '';
const { onMouseDown, pages } = data;
const { onMouseDown, onMouseUp, onMouseEnter, pages } = data;
let cell;
if (pages) {
const page = pages.filter((p) => p.qArea.qTop <= index && index < p.qArea.qTop + p.qArea.qHeight)[0];
@@ -116,6 +116,8 @@ export default function Column({ index, style, data }) {
style={style}
alignItems="center"
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseEnter={onMouseEnter}
role="row"
tabIndex={0}
data-n={cell && cell.qElemNumber}

View File

@@ -35,6 +35,7 @@ export function ListBoxInline({ app, fieldIdentifier, stateName = '$', options =
listLayout,
search = true,
toolbar = true,
rangeSelect = true,
properties = {},
sessionModel = undefined,
selectionsApi = undefined,
@@ -211,6 +212,7 @@ export function ListBoxInline({ app, fieldIdentifier, stateName = '$', options =
selections={selections}
direction={direction}
listLayout={listLayout}
rangeSelect={rangeSelect}
height={height}
width={width}
update={update}

View File

@@ -56,7 +56,7 @@ export default function Row({ index, style, data }) {
const classArr = [classes.row];
let label = '';
const { onMouseDown, pages } = data;
const { onMouseDown, onMouseUp, onMouseEnter, pages } = data;
let cell;
if (pages) {
const page = pages.filter((p) => p.qArea.qTop <= index && index < p.qArea.qTop + p.qArea.qHeight)[0];
@@ -115,6 +115,8 @@ export default function Row({ index, style, data }) {
className={classArr.join(' ').trim()}
style={style}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseEnter={onMouseEnter}
role="row"
tabIndex={0}
data-n={cell && cell.qElemNumber}

View File

@@ -74,6 +74,7 @@ describe('<Listbox />', () => {
width: 100,
listLayout: 'vertical',
update: sandbox.stub(),
rangeSelect: false,
};
});
@@ -86,7 +87,7 @@ describe('<Listbox />', () => {
});
describe('Check rendering with different options', () => {
beforeEach(() => {
before(() => {
render = async () => {
await act(async () => {
renderer = create(
@@ -95,6 +96,7 @@ describe('<Listbox />', () => {
direction={args.direction}
height={args.height}
width={args.width}
rangeSelect={args.rangeSelect}
listLayout={args.listLayout}
update={args.update}
/>
@@ -104,11 +106,12 @@ describe('<Listbox />', () => {
});
afterEach(() => {
sandbox.reset();
sandbox.restore();
renderer.unmount();
});
it('should render and call stuff', async () => {
args.rangeSelect = false;
await render();
// check rendering
@@ -124,6 +127,19 @@ describe('<Listbox />', () => {
layout,
selections,
pages: [],
rangeSelect: false,
doc: 'document',
});
});
it('should call useSelectionsInteractions with rangeSelect true', async () => {
args.rangeSelect = true;
await render();
expect(useSelectionsInteractions.args[useSelectionsInteractions.callCount - 1][0]).to.deep.equal({
layout,
selections,
pages: [],
rangeSelect: true,
doc: 'document',
});
});

View File

@@ -176,4 +176,44 @@ describe('use-listbox-interactions', () => {
}).not.to.throw();
});
});
describe('getElemNumbersFromPages', () => {
let pages;
beforeEach(() => {
pages = [
{
qMatrix: [
[{ qState: 'S', qElemNumber: 0 }],
[{ qState: 'XS', qElemNumber: 1 }],
[{ qState: 'L', qElemNumber: 2 }],
[{ qState: 'A', qElemNumber: 3 }],
[{ qState: 'XL', qElemNumber: 4 }],
[{ qState: 'XS', qElemNumber: 5 }],
[{ qState: 'A', qElemNumber: 6 }],
],
},
];
});
it('listboxSelections', () => {
expect(listboxSelections.getElemNumbersFromPages(undefined)).to.deep.equal([]);
const resp = listboxSelections.getElemNumbersFromPages(pages);
expect(resp).to.deep.equal([0, 1, 2, 3, 4, 5, 6]);
});
});
describe('fillRange', () => {
it('should fill the numbers according to ground-truth array', () => {
expect(listboxSelections.fillRange([], [])).to.deep.equal([]);
expect(listboxSelections.fillRange([1, 10], []), 'without ground-truth no range').to.deep.equal([]);
expect(listboxSelections.fillRange([0, 5], [0, 1, 2, 3, 4, 5, 6])).to.deep.equal([0, 1, 2, 3, 4, 5]);
expect(listboxSelections.fillRange([0], [0, 1, 2, 3, 4, 5, 6])).to.deep.equal([0]);
expect(listboxSelections.fillRange([], [0, 1, 2, 3, 4, 5, 6])).to.deep.equal([]);
expect(listboxSelections.fillRange([1, 6], [0, 1, 8, 16, 6, 2]), 'should fill using ground-truth').to.deep.equal([
1, 8, 16, 6,
]);
});
});
});

View File

@@ -22,6 +22,9 @@ describe('use-listbox-interactions', () => {
let getUniques;
let selectValues;
let applySelectionsOnPages;
let fillRange;
let getSelectedValues;
let getElemNumbersFromPages;
before(() => {
sandbox = sinon.createSandbox({ useFakeTimers: true });
@@ -29,6 +32,9 @@ describe('use-listbox-interactions', () => {
getUniques = sandbox.stub(listboxSelections, 'getUniques');
selectValues = sandbox.stub(listboxSelections, 'selectValues');
applySelectionsOnPages = sandbox.stub(listboxSelections, 'applySelectionsOnPages');
fillRange = sandbox.stub(listboxSelections, 'fillRange');
getSelectedValues = sandbox.stub(listboxSelections, 'getSelectedValues');
getElemNumbersFromPages = sandbox.stub(listboxSelections, 'getElemNumbersFromPages');
});
beforeEach(() => {
@@ -39,7 +45,10 @@ describe('use-listbox-interactions', () => {
getUniques.callsFake((input) => input);
selectValues.resolves();
applySelectionsOnPages.returns('instant pages');
applySelectionsOnPages.returns([]);
fillRange.callsFake((arr) => arr);
getSelectedValues.returns([]);
getElemNumbersFromPages.returns([]);
layout = {
qListObject: { qDimensionInfo: { qIsOneAndOnlyOne: false } },
@@ -48,13 +57,13 @@ describe('use-listbox-interactions', () => {
pages = [];
ref = React.createRef();
render = async () => {
render = async (overrides = {}) => {
await act(async () => {
create(
<TestHook
ref={ref}
hook={useSelectionsInteractions}
hookProps={[{ layout, selections, pages, doc: global.document }]}
hookProps={[{ layout, selections, rangeSelect: false, pages, doc: global.document, ...overrides }]}
/>
);
});
@@ -70,35 +79,151 @@ describe('use-listbox-interactions', () => {
sandbox.restore();
});
describe('it should behave', () => {
it('should behave', async () => {
describe('it should behave without range select', () => {
it('should return expected stuff', async () => {
await render();
const arg0 = ref.current.result;
expect(Object.keys(arg0)).to.deep.equal(['instantPages', 'interactionEvents']);
expect(arg0.instantPages).to.equal('instant pages');
expect(arg0.instantPages).to.deep.equal([]);
expect(Object.keys(arg0.interactionEvents)).to.deep.equal(['onMouseDown']);
});
expect(applySelectionsOnPages).calledOnce.calledWithExactly([], [], false, false);
expect(global.document.addEventListener.args[0][0]).to.equal('mouseup');
it('should select a value', async () => {
await render();
const arg0 = ref.current.result;
expect(applySelectionsOnPages).not.called;
const [eventName, docMouseUpListener] = global.document.addEventListener.args[0];
expect(eventName).to.equal('mouseup');
expect(global.document.removeEventListener).not.called;
expect(listboxSelections.selectValues).not.called;
arg0.interactionEvents.onMouseDown({
currentTarget: {
getAttribute: sandbox.stub().withArgs('data-n').returns(23),
},
act(() => {
arg0.interactionEvents.onMouseDown({
currentTarget: {
getAttribute: sandbox.stub().withArgs('data-n').returns(23),
},
});
});
await render();
const arg1 = ref.current.result;
expect(listboxSelections.selectValues, 'since mouseup has not been called yet').not.called;
expect(arg1.instantPages).to.deep.equal([]);
act(() => {
docMouseUpListener(); // trigger doc mouseup listener to set mouseDown => false
});
const arg2 = ref.current.result;
expect(listboxSelections.selectValues).calledOnce.calledWithExactly({
selections: { key: 'selections' },
elemNumbers: [23],
isSingleSelect: false,
});
const arg1 = ref.current.result;
expect(applySelectionsOnPages).calledThrice;
expect(applySelectionsOnPages.args[1]).to.deep.equal([[], [23], false, true]);
expect(arg1.instantPages).to.equal('instant pages');
expect(applySelectionsOnPages).calledOnce;
expect(applySelectionsOnPages.args[0]).to.deep.equal([[], [23]]);
expect(arg2.instantPages).to.deep.equal([]);
});
it('should unselect a value', async () => {
getSelectedValues.returns([24]); // mock element nbr 24 as already selected
await render();
const arg0 = ref.current.result;
act(() => {
arg0.interactionEvents.onMouseDown({
currentTarget: {
getAttribute: sandbox.stub().withArgs('data-n').returns(24), // fake mousedown on element nbr 24
},
});
});
expect(applySelectionsOnPages).calledOnce;
expect(applySelectionsOnPages.args[0]).to.deep.equal([[], [24]]);
expect(listboxSelections.selectValues, 'should only preselect - not select - while mousedown').not.called;
const [, docMouseUpListener] = global.document.addEventListener.args[0];
act(() => {
docMouseUpListener();
});
expect(listboxSelections.selectValues).calledOnce.calledWithExactly({
selections: { key: 'selections' },
elemNumbers: [24],
isSingleSelect: false,
});
expect(applySelectionsOnPages, 'should not set instant pages again (after mouseup)').calledOnce;
});
});
describe('it should behave with range select', () => {
it('should return expected stuff', async () => {
await render({ rangeSelect: true });
const arg0 = ref.current.result;
expect(Object.keys(arg0)).to.deep.equal(['instantPages', 'interactionEvents']);
expect(arg0.instantPages).to.deep.equal([]);
expect(Object.keys(arg0.interactionEvents)).to.deep.equal(['onMouseDown', 'onMouseUp', 'onMouseEnter']);
});
it('should select a range (in theory)', async () => {
getElemNumbersFromPages.returns([24, 25, 26, 27, 28, 29, 30, 31]);
await render({ rangeSelect: true });
expect(applySelectionsOnPages.callCount).to.equal(0);
// Simulate a typical select range scenario.
act(() => {
ref.current.result.interactionEvents.onMouseDown({
currentTarget: {
getAttribute: sandbox.stub().withArgs('data-n').returns(24),
},
});
});
expect(applySelectionsOnPages.callCount).to.equal(1);
act(() => {
ref.current.result.interactionEvents.onMouseEnter({
currentTarget: {
getAttribute: sandbox.stub().withArgs('data-n').returns(25),
},
});
});
expect(applySelectionsOnPages.callCount).to.equal(2);
act(() => {
ref.current.result.interactionEvents.onMouseEnter({
currentTarget: {
getAttribute: sandbox.stub().withArgs('data-n').returns(28),
},
});
});
act(() => {
ref.current.result.interactionEvents.onMouseUp({
currentTarget: {
getAttribute: sandbox.stub().withArgs('data-n').returns(30),
},
});
});
act(() => {
const [, docMouseUpListener] = global.document.addEventListener.args.pop();
docMouseUpListener();
});
expect(applySelectionsOnPages.callCount, 'should pre-select once for each new value').to.equal(4);
expect(listboxSelections.selectValues).calledOnce.calledWithExactly({
selections: { key: 'selections' },
elemNumbers: [24, 25, 28, 30], // without mocking fillRange this range would be filled
isSingleSelect: false,
});
// Should pre-select "cumulative", once for each new value
expect(applySelectionsOnPages.args[0]).to.deep.equal([[], [24]]);
expect(applySelectionsOnPages.args[1]).to.deep.equal([[], [24, 25]]);
expect(applySelectionsOnPages.args[2]).to.deep.equal([[], [24, 25, 28]]);
expect(applySelectionsOnPages.args[3]).to.deep.equal([[], [24, 25, 28, 30]]);
});
});
});

View File

@@ -1,6 +1,6 @@
const SELECTED_STATES = ['S', 'XS'];
const flattenArr = (arr) => arr.reduce((prev, cur) => prev.concat(cur));
const flatten = (arr) => arr.reduce((prev, cur) => prev.concat(cur));
function isStateSelected(qState) {
return SELECTED_STATES.includes(qState);
@@ -11,7 +11,7 @@ export function getUniques(arr) {
}
export function getSelectedValues(pages) {
if (!pages) {
if (!pages || !pages.length) {
return [];
}
const elementNbrs = pages.map((page) => {
@@ -21,11 +21,11 @@ export function getSelectedValues(pages) {
});
return elementNumbers.filter((n) => n !== false);
});
return flattenArr(elementNbrs);
return flatten(elementNbrs);
}
export function applySelectionsOnPages(pages, elmNumbers, toggle = false) {
const getNewSelectionState = (qState) => (toggle && elmNumbers.length === 1 && isStateSelected(qState) ? 'A' : 'S');
export function applySelectionsOnPages(pages, elmNumbers) {
const getNewSelectionState = (qState) => (elmNumbers.length <= 1 && isStateSelected(qState) ? 'A' : 'S');
const matrices = pages.map((page) => {
const qMatrix = page.qMatrix.map((p) => {
const [p0] = p;
@@ -53,3 +53,49 @@ export async function selectValues({ selections, elemNumbers, isSingleSelect = f
}
return resolved;
}
export function getElemNumbersFromPages(pages) {
if (!pages || !pages.length) {
return [];
}
const elemNumbersArr = pages.map((page) => {
const qElemNumbers = page.qMatrix.map((p) => {
const [{ qElemNumber }] = p;
return qElemNumber;
});
return qElemNumbers;
});
const elemNumbers = flatten(elemNumbersArr);
return elemNumbers;
}
/**
* Returns the min and max indices of elemNumbersOrdered which contains
* all numbers in elementNbrs.
*
* @param {array(number)} elementNbrs
* @param {array(number)} elemNumbersOrdered
* @returns { min: {number}, max: {number} }
*/
function getMinMax(elementNbrs, elemNumbersOrdered) {
let min = Infinity;
let max = -Infinity;
elementNbrs.forEach((nbr) => {
const index = elemNumbersOrdered.indexOf(nbr);
min = index < min ? index : min;
max = index > max ? index : max;
});
return { min, max };
}
export function fillRange(elementNbrs, elemNumbersOrdered) {
if (!elementNbrs) {
return [];
}
if (elementNbrs.length <= 1) {
return elementNbrs;
}
// Interpolate values algorithm
const { min, max } = getMinMax(elementNbrs, elemNumbersOrdered);
return elemNumbersOrdered.slice(min, max + 1);
}

View File

@@ -1,17 +1,47 @@
import { useEffect, useState, useCallback } from 'react';
import { getUniques, selectValues, applySelectionsOnPages } from './listbox-selections';
import {
getUniques,
selectValues,
applySelectionsOnPages,
fillRange,
getSelectedValues,
getElemNumbersFromPages,
} from './listbox-selections';
export default function useSelectionsInteractions({ layout, selections, pages = [], doc = document }) {
const [mouseDown, setMouseDown] = useState(false);
const [selectedElementNumbers, setSelectedElementNumbers] = useState([]);
const [selectingValues, setSelectingValues] = useState(false);
export default function useSelectionsInteractions({
layout,
selections,
pages = [],
rangeSelect = true,
doc = document,
}) {
const [instantPages, setInstantPages] = useState(pages);
const [mouseDown, setMouseDown] = useState(false);
const [selectingValues, setSelectingValues] = useState(false);
const [selected, setSelected] = useState([]);
const [isRangeSelection, setIsRangeSelection] = useState(false);
const [preSelected, setPreSelected] = useState([]);
const select = ({ elemNumbers }) => {
const elemNumbersOrdered = getElemNumbersFromPages(pages);
const getIsSingleSelect = () => !!(layout && layout.qListObject.qDimensionInfo.qIsOneAndOnlyOne);
// Select values for real, by calling the backend.
const select = async (elemNumbers = [], additive = false) => {
setSelectingValues(true);
const isSingleSelect = layout.qListObject.qDimensionInfo.qIsOneAndOnlyOne;
return selectValues({ selections, elemNumbers, isSingleSelect }).then(() => {
setSelectingValues(false);
const isSingleSelect = getIsSingleSelect();
const filtered = additive ? elemNumbers.filter((n) => !selected.includes(n)) : elemNumbers;
await selectValues({ selections, elemNumbers: filtered, isSingleSelect });
setSelectingValues(false);
};
// Show estimated selection states instantly before applying the selections for real.
const preSelect = (elemNumbers, additive = false) => {
setPreSelected((existing) => {
const uniques = getUniques([...existing, ...elemNumbers]);
const filtered = additive ? uniques.filter((n) => !selected.includes(n)) : uniques;
const filled = additive ? fillRange(uniques, elemNumbersOrdered) : filtered;
return filled;
});
};
@@ -20,26 +50,66 @@ export default function useSelectionsInteractions({ layout, selections, pages =
if (selectingValues) {
return;
}
const elemNumber = +event.currentTarget.getAttribute('data-n');
setSelectedElementNumbers([elemNumber]);
setIsRangeSelection(false);
setMouseDown(true);
const elemNumber = +event.currentTarget.getAttribute('data-n');
setPreSelected([elemNumber]);
},
[selectingValues, layout]
[selectingValues]
);
const onMouseUp = useCallback(
(event) => {
const elemNumber = +event.currentTarget.getAttribute('data-n');
if (
getIsSingleSelect() ||
!rangeSelect ||
!mouseDown ||
selectingValues ||
(preSelected.length === 1 && preSelected[0] === elemNumber) // prevent toggling again on mouseup
) {
return;
}
preSelect([elemNumber]);
},
[mouseDown, selectingValues, preSelected, selected, isRangeSelection]
);
const onMouseUpDoc = useCallback(() => {
// Ensure we end interactions when mouseup happens outside the Listbox.
setMouseDown(false);
setSelectingValues(false);
}, []);
const onMouseEnter = useCallback(
(event) => {
if (getIsSingleSelect() || !mouseDown || selectingValues) {
return;
}
setIsRangeSelection(true);
const elemNumber = +event.currentTarget.getAttribute('data-n');
preSelect([elemNumber], true);
},
[
mouseDown,
selectingValues,
isRangeSelection,
preSelected,
selected,
layout && layout.qListObject.qDimensionInfo.qIsOneAndOnlyOne,
]
);
useEffect(() => {
if (!mouseDown) {
// Perform selections of pre-selected values. This can
// happen only when interactions have finished (mouseup).
const interactionIsFinished = !mouseDown;
if (!preSelected || !preSelected.length || selectingValues || !interactionIsFinished || !layout) {
return;
}
const elemNumbers = getUniques(selectedElementNumbers);
if (elemNumbers.length) {
select({ elemNumbers });
}
}, [selectedElementNumbers, mouseDown]);
select(preSelected, isRangeSelection);
}, [preSelected, mouseDown]);
useEffect(() => {
doc.addEventListener('mouseup', onMouseUpDoc);
@@ -49,22 +119,30 @@ export default function useSelectionsInteractions({ layout, selections, pages =
}, []);
useEffect(() => {
if (selectingValues || !pages) {
if (selectingValues || mouseDown) {
return;
}
const newPages = applySelectionsOnPages(
pages,
selectedElementNumbers,
mouseDown,
selectedElementNumbers.length === 1
);
// Keep track of (truely) selected fields so we can prevent toggling them on range select.
const alreadySelected = getSelectedValues(pages);
setSelected(alreadySelected);
}, [pages]);
useEffect(() => {
if (selectingValues || !pages || !mouseDown) {
return;
}
// Render pre-selections before they have been selected in Engine.
const newPages = applySelectionsOnPages(pages, preSelected);
setInstantPages(newPages);
}, [selectedElementNumbers]);
}, [preSelected]);
const rangeSelectEvents = rangeSelect ? { onMouseUp, onMouseEnter } : {};
return {
instantPages,
interactionEvents: {
onMouseDown,
...rangeSelectEvents,
},
};
}