refactor(listbox): Enable integration of Listbox by providing additional options (#742)

* refactor: support custom selections api

* refactor: add update as option and unit tests

* test: for listbox

* docs: update

* fix: user-select none and make example run

* fix: prevent conditional hooks calls

* refactor: prevent executing unnecessary code
This commit is contained in:
Johan Lahti
2022-01-25 13:32:17 +01:00
committed by GitHub
parent 0dce36daab
commit 024b8a451b
10 changed files with 424 additions and 53 deletions

View File

@@ -15,8 +15,17 @@ import useLayout from '../../hooks/useLayout';
import Row from './ListBoxRow';
import Column from './ListBoxColumn';
import { selectValues } from './listbox-selections';
export default function ListBox({ model, selections, direction, height, width, listLayout = 'vertical' }) {
export default function ListBox({
model,
selections,
direction,
height,
width,
listLayout = 'vertical',
update = undefined,
}) {
const [layout] = useLayout(model);
const [pages, setPages] = useState(null);
const loaderRef = useRef(null);
@@ -35,11 +44,11 @@ export default function ListBox({ model, selections, direction, height, width, l
return;
}
const elemNumber = +e.currentTarget.getAttribute('data-n');
const elemNumbers = [elemNumber];
const isSingleSelect = layout.qListObject.qDimensionInfo.qIsOneAndOnlyOne;
if (!Number.isNaN(elemNumber)) {
selections.select({
method: 'selectListObjectValues',
params: ['/qListObjectDef', [elemNumber], !layout.qListObject.qDimensionInfo.qIsOneAndOnlyOne],
});
selectValues({ selections, elemNumbers, isSingleSelect });
}
},
[
@@ -55,8 +64,9 @@ export default function ListBox({ model, selections, direction, height, width, l
return false;
}
local.current.checkIdx = index;
const page = pages.filter((p) => p.qArea.qTop <= index && index < p.qArea.qTop + p.qArea.qHeight)[0];
return page && page.qArea.qTop <= index && index < page.qArea.qTop + page.qArea.qHeight;
const isLoaded = (p) => p.qArea.qTop <= index && index < p.qArea.qTop + p.qArea.qHeight;
const page = pages.filter((p) => isLoaded(p))[0];
return page && isLoaded(page);
},
[layout, pages]
);
@@ -102,7 +112,7 @@ export default function ListBox({ model, selections, direction, height, width, l
[layout]
);
useEffect(() => {
const fetchData = () => {
local.current.queue = [];
local.current.validPages = false;
if (loaderRef.current) {
@@ -113,6 +123,15 @@ export default function ListBox({ model, selections, direction, height, width, l
}
loaderRef.current._listRef.scrollToItem(0);
}
};
if (update) {
// Hand over the update function for manual refresh from hosting application.
update.call(null, fetchData);
}
useEffect(() => {
fetchData();
}, [layout]);
if (!layout) {
@@ -137,6 +156,7 @@ export default function ListBox({ model, selections, direction, height, width, l
return (
<FixedSizeList
direction={direction}
data-testid="fixed-size-list"
useIsScrolling
style={{}}
height={listHeight}

View File

@@ -22,6 +22,7 @@ const useStyles = makeStyles((theme) => ({
whiteSpace: 'nowrap',
fontSize: '12px',
lineHeight: '16px',
userSelect: 'none',
},
overflow: 'hidden',
textOverflow: 'ellipsis',

View File

@@ -29,7 +29,18 @@ export default function ListBoxPortal({ app, fieldIdentifier, stateName, element
}
export function ListBoxInline({ app, fieldIdentifier, stateName = '$', options = {} }) {
const { title, direction, listLayout, search = true, toolbar = true, properties = {} } = options;
const {
title,
direction,
listLayout,
search = true,
toolbar = true,
properties = {},
sessionModel = undefined,
selectionsApi = undefined,
update = undefined,
} = options;
const listdef = {
qInfo: {
qType: 'njsListbox',
@@ -59,9 +70,9 @@ export function ListBoxInline({ app, fieldIdentifier, stateName = '$', options =
title,
...properties,
};
let fieldName;
// Something something lib dimension
let fieldName;
if (fieldIdentifier.qLibraryId) {
listdef.qListObjectDef.qLibraryId = fieldIdentifier.qLibraryId;
fieldName = fieldIdentifier.qLibraryId;
@@ -70,8 +81,17 @@ export function ListBoxInline({ app, fieldIdentifier, stateName = '$', options =
fieldName = fieldIdentifier;
}
let [model] = useSessionModel(listdef, sessionModel ? null : app, fieldName, stateName);
if (sessionModel) {
model = sessionModel;
}
let selections = useObjectSelections(selectionsApi ? {} : app, model)[0];
if (selectionsApi) {
selections = selectionsApi;
}
const theme = useTheme();
const [model] = useSessionModel(listdef, app, fieldName, stateName);
const lock = useCallback(() => {
model.lock('/qListObjectDef');
@@ -83,7 +103,7 @@ export function ListBoxInline({ app, fieldIdentifier, stateName = '$', options =
const { translator } = useContext(InstanceContext);
const moreAlignTo = useRef();
const [selections] = useObjectSelections(app, model);
const [layout] = useLayout(model);
const [showToolbar, setShowToolbar] = useState(false);
@@ -193,6 +213,7 @@ export function ListBoxInline({ app, fieldIdentifier, stateName = '$', options =
listLayout={listLayout}
height={height}
width={width}
update={update}
/>
)}
</AutoSizer>

View File

@@ -22,6 +22,7 @@ const useStyles = makeStyles((theme) => ({
whiteSpace: 'nowrap',
fontSize: '12px',
lineHeight: '16px',
userSelect: 'none',
},
overflow: 'hidden',
textOverflow: 'ellipsis',

View File

@@ -1,34 +1,36 @@
import React from 'react';
import { create, act } from 'react-test-renderer';
import { IconButton, Grid, Typography } from '@material-ui/core';
describe('<ListboxInline />', () => {
let sandbox;
const app = {};
const app = { key: 'app' };
const fieldIdentifier = { qLibraryId: 'qLibraryId' };
const stateName = '$';
const options = {
title: 'title',
direction: 'vertical',
listLayout: 'vertical',
search: true,
toolbar: true,
properties: {},
};
let customSelectionsKey;
let customSessionModelKey;
let options;
let ListBoxInline;
let useState;
let useEffect;
let useCallback;
let useContext;
let useRef;
let sessionModel;
let selections;
let ActionsToolbar;
let ListBoxSearch;
let createListboxSelectionToolbar;
let useTheme;
let theme;
let layout;
let useSessionModel;
let useObjectSelections;
let selections;
let renderer;
let render;
const InstanceContext = React.createContext();
before(() => {
sandbox = sinon.createSandbox({ useFakeTimers: true });
@@ -36,22 +38,16 @@ describe('<ListboxInline />', () => {
useState = sandbox.stub(React, 'useState');
useEffect = sandbox.stub(React, 'useEffect');
useCallback = sandbox.stub(React, 'useCallback');
useContext = sandbox.stub(React, 'useContext');
useRef = sandbox.stub(React, 'useRef');
useSessionModel = sandbox.stub();
useObjectSelections = sandbox.stub();
sessionModel = {
key: 'session-model',
lock: sandbox.stub(),
unlock: sandbox.stub(),
};
selections = {
key: 'selections',
isModal: () => false,
isActive: () => 'isActive',
on: sandbox.stub().callsFake((event, func) => (eventTriggered) => {
if (event === eventTriggered) func();
}),
};
ActionsToolbar = sandbox.stub();
ListBoxSearch = sandbox.stub();
createListboxSelectionToolbar = sandbox.stub();
@@ -82,15 +78,16 @@ describe('<ListboxInline />', () => {
Typography,
}),
],
[require.resolve('react-virtualized-auto-sizer'), () => (props) => props.children],
[require.resolve('react-virtualized-auto-sizer'), () => () => <div data-testid="virtualized-auto-sizer" />],
[require.resolve('@nebula.js/ui/icons/unlock'), () => () => 'unlock'],
[require.resolve('@nebula.js/ui/icons/lock'), () => () => 'lock'],
[require.resolve('@nebula.js/ui/theme'), () => ({ makeStyles: () => () => ({ icon: 'icon' }), useTheme })],
[require.resolve('../../../contexts/InstanceContext'), () => 'context'],
[require.resolve('../../../hooks/useObjectSelections'), () => () => [selections]],
[require.resolve('../../../hooks/useSessionModel'), () => () => [sessionModel]],
[require.resolve('../../../contexts/InstanceContext'), () => InstanceContext],
[require.resolve('../../../hooks/useObjectSelections'), () => useObjectSelections],
[require.resolve('../../../hooks/useSessionModel'), () => useSessionModel],
[require.resolve('../../../hooks/useLayout'), () => () => [layout]],
[require.resolve('../../ActionsToolbar'), () => ActionsToolbar],
[require.resolve('../ListBox'), () => <div className="TheListBox" />],
[require.resolve('../ListBoxSearch'), () => ListBoxSearch],
[require.resolve('../listbox-selection-toolbar'), () => createListboxSelectionToolbar],
],
@@ -99,9 +96,31 @@ describe('<ListboxInline />', () => {
});
beforeEach(() => {
selections = {
key: 'selections',
isModal: () => false,
isActive: () => 'isActive',
on: sandbox.stub().callsFake((event, func) => (eventTriggered) => {
if (event === eventTriggered) func();
}),
};
useSessionModel.returns([sessionModel]);
useObjectSelections.returns([selections]);
options = {
title: 'title',
direction: 'vertical',
listLayout: 'vertical',
search: true,
toolbar: true,
properties: {},
sessionModel: undefined,
selectionsApi: undefined,
update: undefined,
};
theme.spacing.returns('padding');
useState.callsFake((startValue) => [startValue, () => {}]);
useContext.returns({ translator: 'translator' });
useRef.returns({ current: 'current' });
useTheme.returns(theme);
createListboxSelectionToolbar.returns('actions');
@@ -112,24 +131,24 @@ describe('<ListboxInline />', () => {
useEffect
.onCall(0)
.callsFake((effectFunc, watchArr) => {
expect(watchArr[0].key).to.equal('selections');
expect(watchArr[0].key).to.equal(customSelectionsKey || 'selections');
effectFunc();
})
.onCall(1)
.callsFake((effectFunc, watchArr) => {
expect(watchArr[0].key).to.equal('selections');
expect(watchArr[0].key).to.equal(customSelectionsKey || 'selections');
effectFunc();
});
useCallback
.onCall(0)
.callsFake((effectFunc, watchArr) => {
expect(watchArr).to.deep.equal([sessionModel]);
expect(watchArr[0].key).to.equal(customSessionModelKey || 'session-model');
return effectFunc;
})
.onCall(1)
.callsFake((effectFunc, watchArr) => {
expect(watchArr).to.deep.equal([sessionModel]);
expect(watchArr[0].key).to.equal(customSessionModelKey || 'session-model');
return effectFunc;
});
});
@@ -142,16 +161,97 @@ describe('<ListboxInline />', () => {
sandbox.restore();
});
it('should call expected stuff', () => {
const response = ListBoxInline({ app, fieldIdentifier, stateName, options });
describe('Check rendering with different options', () => {
beforeEach(() => {
render = async () => {
await act(async () => {
renderer = create(
<InstanceContext.Provider value={{ translator: { get: (s) => s, language: () => 'sv' } }}>
<ListBoxInline app={app} fieldIdentifier={fieldIdentifier} stateName={stateName} options={options} />
</InstanceContext.Provider>
);
});
};
});
expect(response).to.be.an('object');
expect(useEffect).calledTwice;
expect(useState).calledOnce.calledWith(false);
expect(useCallback).calledTwice;
expect(theme.spacing).calledOnce;
expect(sessionModel.lock).not.called;
expect(sessionModel.unlock).not.called;
expect(selections.on).calledTwice;
it('should render with everything included', async () => {
await render();
const actionToolbars = renderer.root.findAllByType(ActionsToolbar);
expect(actionToolbars.length).to.equal(1);
const typographs = renderer.root.findAllByType(Typography);
expect(typographs.length).to.equal(1);
const listBoxSearches = renderer.root.findAllByType(ListBoxSearch);
expect(listBoxSearches.length).to.equal(1);
const autoSizers = renderer.root.findAllByProps({ 'data-testid': 'virtualized-auto-sizer' });
expect(autoSizers.length).to.equal(1);
expect(useSessionModel).calledOnce;
expect(useSessionModel.args[0][1], 'app should not be null as when using a custom sessionModel').to.deep.equal({
key: 'app',
});
expect(useObjectSelections).calledOnce;
});
it('should render without toolbar', async () => {
options.toolbar = false;
await render();
const actionToolbars = renderer.root.findAllByType(ActionsToolbar);
expect(actionToolbars.length).to.equal(0);
const typographs = renderer.root.findAllByType(Typography);
expect(typographs.length).to.equal(0);
const listBoxSearches = renderer.root.findAllByType(ListBoxSearch);
expect(listBoxSearches.length).to.equal(1);
});
it('should render without search', async () => {
options.search = false;
await render();
const actionToolbars = renderer.root.findAllByType(ActionsToolbar);
expect(actionToolbars.length).to.equal(1);
const typographs = renderer.root.findAllByType(Typography);
expect(typographs.length).to.equal(1);
const listBoxSearches = renderer.root.findAllByType(ListBoxSearch);
expect(listBoxSearches.length).to.equal(0);
});
it('should use a custom selectionsApi and sessionModel', async () => {
const isModal = sandbox.stub();
const on = sandbox.stub();
const isActive = sandbox.stub();
customSelectionsKey = 'custom-selections';
customSessionModelKey = 'custom-session-model';
options.selectionsApi = {
key: 'custom-selections',
isModal,
on,
isActive,
};
options.sessionModel = {
key: 'custom-session-model',
lock: sandbox.stub(),
unlock: sandbox.stub(),
};
await render();
const actionToolbars = renderer.root.findAllByType(ActionsToolbar);
expect(actionToolbars.length).to.equal(1);
expect(isModal).calledOnce;
expect(selections.on, 'should not use default selections api').not.called;
expect(on, 'should use custom selections api').calledTwice;
expect(isActive).calledOnce;
expect(useSessionModel).calledOnce;
expect(useSessionModel.args[0][1], 'app should be null to prevent unncessary rendering').to.equal(null);
expect(useObjectSelections).calledOnce;
const [, ourSessionModel] = useObjectSelections.args[0];
expect(ourSessionModel.key, 'should use custom session model').to.equal('custom-session-model');
});
});
});

View File

@@ -0,0 +1,66 @@
import * as listboxSelections from '../listbox-selections';
describe('Listbox selections', () => {
let sandbox;
before(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.reset();
});
after(() => {
sandbox.restore();
});
describe('selectValues', () => {
let selections;
beforeEach(() => {
selections = { select: sandbox.stub().resolves(true) };
});
it('should call select', () => {
listboxSelections
.selectValues({ selections, elemNumbers: [1, 2, 4], isSingleSelect: false })
.then((successStory) => {
expect(successStory).to.equal(true);
});
expect(selections.select).calledOnce.calledWithExactly({
method: 'selectListObjectValues',
params: ['/qListObjectDef', [1, 2, 4], true],
});
});
it('should call select with toggle false when single select', () => {
listboxSelections.selectValues({ selections, elemNumbers: [2], isSingleSelect: true }).then((successStory) => {
expect(successStory).to.equal(true);
});
expect(selections.select).calledOnce.calledWithExactly({
method: 'selectListObjectValues',
params: ['/qListObjectDef', [2], false],
});
});
it('should not call select when NaN values exist', () => {
listboxSelections
.selectValues({ selections, elemNumbers: [1, NaN, 4], isSingleSelect: false })
.then((successStory) => {
expect(successStory).to.equal(false);
});
expect(selections.select).not.called;
});
it('should handle select failure and then resolve false', () => {
selections.select.rejects(false);
listboxSelections
.selectValues({ selections, elemNumbers: [1, 2, 4], isSingleSelect: false })
.then((successStory) => {
expect(successStory).to.equal(false);
});
expect(selections.select).calledOnce;
});
});
});

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { create, act } from 'react-test-renderer';
describe('<Listbox />', () => {
let sandbox;
let args;
let layout;
let selectValues;
let selections;
let renderer;
let ListBox;
let render;
let pages;
let FixedSizeList;
before(() => {
sandbox = sinon.createSandbox({ useFakeTimers: true });
selectValues = sandbox.stub();
layout = {
qSelectionInfo: { qInSelections: false },
qListObject: {
qSize: { qcy: 2 },
qDimensionInfo: { qLocked: false, qIsOneAndOnlyOne: false },
qStateCounts: { qSelected: 2, qSelectedExcluded: 10, qLocked: 0, qLockedExcluded: 0 },
},
};
FixedSizeList = sandbox.stub().callsFake((funcArgs) => {
const { children } = funcArgs;
const RowOrColumn = children;
return <RowOrColumn index={funcArgs.itemCount} style={funcArgs.style} data={funcArgs.itemData} />;
});
[{ default: ListBox }] = aw.mock(
[
[require.resolve('react-window'), () => ({ FixedSizeList })],
[require.resolve('../../../hooks/useLayout'), () => () => [layout]],
[require.resolve('../listbox-selections'), () => ({ selectValues })],
[
require.resolve('react-window-infinite-loader'),
() => (props) => {
const func = props.children;
return func({ onItemsRendered: {}, ref: {} });
},
],
[require.resolve('../ListBoxRow'), () => () => <div className="a-value-row" />],
[require.resolve('../ListBoxColumn'), () => () => <div className="a-value-column" />],
],
['../ListBox']
);
});
beforeEach(() => {
pages = [{ qArea: { qTop: 1, qHeight: 100 } }];
selections = { key: 'selections' };
args = {
model: {
getListObjectData: sandbox.stub().resolves(pages),
},
selections,
direction: 'ltr',
height: 200,
width: 100,
listLayout: 'vertical',
update: sandbox.stub(),
};
});
afterEach(() => {
sandbox.reset();
});
after(() => {
sandbox.restore();
});
describe('Check rendering with different options', () => {
beforeEach(() => {
render = async () => {
await act(async () => {
renderer = create(
<ListBox
selections={args.selections}
direction={args.direction}
height={args.height}
width={args.width}
listLayout={args.listLayout}
update={args.update}
/>
);
});
};
});
afterEach(() => {
sandbox.reset();
renderer.unmount();
});
it('should render and call stuff', async () => {
await render();
// check rendering
const fixedSizeLists = renderer.root.findAllByType(FixedSizeList);
expect(fixedSizeLists.length).to.equal(1);
const [Container] = fixedSizeLists;
const rows = Container.findAllByProps({ className: 'a-value-row' });
const columns = Container.findAllByProps({ className: 'a-value-column' });
expect(rows.length).to.equal(1);
expect(columns.length).to.equal(0);
// onClick with valid numbers should call selectValues
const { onClick } = Container.props.itemData;
expect(selectValues).not.called;
onClick({
currentTarget: { getAttribute: () => 1 },
});
expect(selectValues).calledOnce.calledWithExactly({
selections,
elemNumbers: [1],
isSingleSelect: false,
});
// Test on click with NaN values
selectValues.reset();
expect(selectValues).not.called;
onClick({
currentTarget: { getAttribute: () => NaN },
});
expect(selectValues).not.called;
});
});
});

View File

@@ -0,0 +1,17 @@
// eslint-disable-next-line import/prefer-default-export
export async function selectValues({ selections, elemNumbers, isSingleSelect = false }) {
const SUCCESS = false;
let resolved = Promise.resolve(SUCCESS);
const hasNanValues = elemNumbers.some((elemNumber) => Number.isNaN(elemNumber));
if (!hasNanValues) {
const elemNumbersToSelect = elemNumbers;
resolved = selections
.select({
method: 'selectListObjectValues',
params: ['/qListObjectDef', elemNumbersToSelect, !isSingleSelect],
})
.then((success) => success !== false)
.catch(() => false);
}
return resolved;
}

View File

@@ -326,6 +326,10 @@ function nuked(configuration = {}) {
throw new Error(`Field identifier must be provided`);
}
/**
* @typedef {function(function)} ReceiverFunction A callback function which receives another function as input.
*/
/**
* @class
* @alias FieldInstance
@@ -344,6 +348,9 @@ function nuked(configuration = {}) {
* @param {boolean=} [options.toolbar=true] To show the toolbar
* @param {boolean=} [options.stateName="$"] Sets the state to make selections in
* @param {object=} [options.properties={}] Properties object to extend default properties with
* @param {object} [options.sessionModel] Use a custom sessionModel.
* @param {object} [options.selectionsApi] Use a custom selectionsApi to customize how values are selected.
* @param {ReceiverFunction} [options.update] A function which receives an update function which upon call will trigger a data fetch.
* @since 1.1.0
* @example
* fieldInstance.mount(element);

View File

@@ -8,13 +8,13 @@
"build": "parcel build index.html"
},
"dependencies": {
"@nebula.js/stardust": "latest",
"@nebula.js/sn-bar-chart": "latest",
"@nebula.js/sn-line-chart": "latest",
"@nebula.js/stardust": "latest",
"enigma.js": "2.7.2"
},
"devDependencies": {
"@babel/core": "7.12.3",
"parcel": "^2.0.0-rc.0"
"parcel-bundler": "^1.12.5"
}
}