Tsm/field api (#549)

* feat: add field api and inline listbox

* feat: add autosizer

* chore: add specs for fieldSelections

* feat: support horizontal, set as experimental

* fix: close actions correctly

* chore: correct docs, set as async
This commit is contained in:
Tobias Åström
2020-12-14 16:03:42 +01:00
committed by GitHub
parent 317cc472c1
commit 1116ed52d4
12 changed files with 495 additions and 10 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ temp/
test/**/__artifacts__/regression
test/**/__artifacts__/diff
apis/*/core/**/*.js
apis/*/core/**/*.js.map
apis/locale/all.json
Search/

View File

@@ -18,6 +18,7 @@
"react-test-renderer": "17.0.1",
"react-window": "1.8.6",
"react-window-infinite-loader": "1.0.5",
"react-virtualized-auto-sizer": "^1.0.2",
"semver": "6.3.0"
}
}

View File

@@ -14,8 +14,9 @@ import InfiniteLoader from 'react-window-infinite-loader';
import useLayout from '../../hooks/useLayout';
import Row from './ListBoxRow';
import Column from './ListBoxColumn';
export default function ListBox({ model, selections, direction }) {
export default function ListBox({ model, selections, direction, height, width, listLayout = 'vertical' }) {
const [layout] = useLayout(model);
const [pages, setPages] = useState(null);
const loaderRef = useRef(null);
@@ -117,9 +118,10 @@ export default function ListBox({ model, selections, direction }) {
if (!layout) {
return null;
}
const isVertical = listLayout !== 'horizontal';
const count = layout.qListObject.qSize.qcy;
const ITEM_HEIGHT = 33;
const ITEM_SIZE = isVertical ? 33 : 200;
const listHeight = height || 8 * ITEM_SIZE;
return (
<InfiniteLoader
@@ -137,14 +139,16 @@ export default function ListBox({ model, selections, direction }) {
direction={direction}
useIsScrolling
style={{}}
height={8 * ITEM_HEIGHT}
height={listHeight}
width={width}
itemCount={count}
layout={listLayout}
itemData={{ onClick, pages }}
itemSize={ITEM_HEIGHT}
itemSize={ITEM_SIZE}
onItemsRendered={onItemsRendered}
ref={ref}
>
{Row}
{isVertical ? Row : Column}
</FixedSizeList>
);
}}

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { Grid, Typography } from '@material-ui/core';
import { makeStyles } from '@nebula.js/ui/theme';
import Lock from '@nebula.js/ui/icons/lock';
import Tick from '@nebula.js/ui/icons/tick';
const useStyles = makeStyles((theme) => ({
column: {
flexWrap: 'nowrap',
borderRight: `1px solid ${theme.palette.divider}`,
'&:focus': {
boxShadow: `inset 0 0 0 2px ${theme.palette.custom.focusOutline}`,
outline: 'none',
},
},
cell: {
padding: '8px',
'& span': {
whiteSpace: 'nowrap',
fontSize: '12px',
lineHeight: '16px',
},
overflow: 'hidden',
textOverflow: 'ellipsis',
},
icon: {
padding: theme.spacing(1),
},
S: {
background: theme.palette.selected.main,
color: theme.palette.selected.mainContrastText,
'&:focus': {
boxShadow: `inset 0 0 0 2px rgba(0, 0, 0, 0.3)`,
outline: 'none',
},
},
A: {
background: theme.palette.selected.alternative,
color: theme.palette.selected.alternativeContrastText,
},
X: {
background: theme.palette.selected.excluded,
color: theme.palette.selected.excludedContrastText,
},
highlighted: {
backgroundColor: '#FFC72A',
},
}));
export default function Column({ index, style, data }) {
const classes = useStyles();
const classArr = [classes.column];
let label = '';
const { onClick, pages } = data;
let cell;
if (pages) {
const page = pages.filter((p) => p.qArea.qTop <= index && index < p.qArea.qTop + p.qArea.qHeight)[0];
if (page) {
const area = page.qArea;
if (index >= area.qTop && index < area.qTop + area.qHeight) {
[cell] = page.qMatrix[index - area.qTop];
}
}
}
let locked = false;
let selected = false;
if (cell) {
label = cell.qText;
locked = cell.qState === 'L' || cell.qState === 'XL';
selected = cell.qState === 'S' || cell.qState === 'XS';
if (cell.qState === 'S' || cell.qState === 'L') {
classArr.push(classes.S);
} else if (cell.qState === 'A') {
classArr.push(classes.A);
} else if (cell.qState === 'X' || cell.qState === 'XS' || cell.qState === 'XL') {
classArr.push(classes.X);
}
}
// Handle search highlights
const ranges =
(cell && cell.qHighlightRanges && cell.qHighlightRanges.qRanges.sort((a, b) => a.qCharPos - b.qCharPos)) || [];
const labels = ranges.reduce((acc, curr, ix) => {
// First non highlighted segment
if (curr.qCharPos > 0 && ix === 0) {
acc.push([label.slice(0, curr.qCharPos)]);
}
// Previous non highlighted segment
const prev = ranges[ix - 1];
if (prev) {
acc.push([label.slice(prev.qCharPos + prev.qCharPos + 1, curr.qCharPos)]);
}
// Highlighted segment
acc.push([label.slice(curr.qCharPos, curr.qCharPos + curr.qCharCount), classes.highlighted]);
// Last non highlighted segment
if (ix === ranges.length - 1 && curr.qCharPos + curr.qCharCount < label.length) {
acc.push([label.slice(curr.qCharPos + curr.qCharCount)]);
}
return acc;
}, []);
return (
<Grid
container
spacing={0}
className={classArr.join(' ').trim()}
style={style}
onClick={onClick}
alignItems="center"
role="row"
tabIndex={0}
data-n={cell && cell.qElemNumber}
>
<Grid item style={{ minWidth: 0, flexGrow: 1 }} className={classes.cell} title={`${label}`}>
{ranges.length === 0 ? (
<Typography component="span" noWrap color="inherit">{`${label}`}</Typography>
) : (
labels.map(([l, highlighted], ix) => (
// eslint-disable-next-line react/no-array-index-key
<Typography component="span" key={ix} className={highlighted} noWrap>
{l}
</Typography>
))
)}
</Grid>
<Grid item className={classes.icon}>
{locked && <Lock size="small" />}
{selected && <Tick size="small" />}
</Grid>
</Grid>
);
}

View File

@@ -0,0 +1,192 @@
import React, { useContext, useCallback, useRef, useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import AutoSizer from 'react-virtualized-auto-sizer';
import Lock from '@nebula.js/ui/icons/lock';
import Unlock from '@nebula.js/ui/icons/unlock';
import { IconButton, Grid, Typography } from '@material-ui/core';
import { useTheme } from '@nebula.js/ui/theme';
import useSessionModel from '../../hooks/useSessionModel';
import useLayout from '../../hooks/useLayout';
import ListBox from './ListBox';
import createListboxSelectionToolbar from './listbox-selection-toolbar';
import ActionsToolbar from '../ActionsToolbar';
import InstanceContext from '../../contexts/InstanceContext';
import ListBoxSearch from './ListBoxSearch';
import useObjectSelections from '../../hooks/useObjectSelections';
export default function ListBoxPortal({ app, fieldName, stateName, element, options }) {
return ReactDOM.createPortal(
<ListBoxInline app={app} fieldName={fieldName} stateName={stateName} options={options} />,
element
);
}
export function ListBoxInline({ app, fieldName, stateName = '$', options = {} }) {
const theme = useTheme();
const { title, direction, listLayout, search = true } = options;
const [model] = useSessionModel(
{
qInfo: {
qType: 'njsListbox',
},
qListObjectDef: {
qStateName: stateName,
qShowAlternatives: true,
qInitialDataFetch: [
{
qTop: 0,
qLeft: 0,
qWidth: 0,
qHeight: 0,
},
],
qDef: {
qSortCriterias: [
{
qSortByState: 1,
qSortByAscii: 1,
qSortByNumeric: 1,
qSortByLoadOrder: 1,
},
],
qFieldDefs: [fieldName],
},
},
},
app,
fieldName,
title,
stateName
);
const lock = useCallback(() => {
model.lock('/qListObjectDef');
}, [model]);
const unlock = useCallback(() => {
model.unlock('/qListObjectDef');
}, [model]);
const { translator } = useContext(InstanceContext);
const moreAlignTo = useRef();
const [selections] = useObjectSelections(app, model);
const [layout] = useLayout(model);
const [showToolbar, setShowToolbar] = useState(false);
useEffect(() => {
if (selections) {
if (!selections.isModal(model)) {
selections.goModal('/qListObjectDef');
selections.on('deactivated', () => {
setShowToolbar(false);
});
selections.on('activated', () => {
setShowToolbar(true);
});
}
}
}, [selections]);
useEffect(() => {
if (selections) {
setShowToolbar(selections.isActive());
}
}, [selections]);
if (!model || !layout || !translator) {
return null;
}
const isLocked = layout.qListObject.qDimensionInfo.qLocked === true;
const listboxSelectionToolbarItems = createListboxSelectionToolbar({
layout,
model,
translator,
});
const counts = layout.qListObject.qDimensionInfo.qStateCounts;
const hasSelections = counts.qSelected + counts.qSelectedExcluded + counts.qLocked + counts.qLockedExcluded > 0;
const showTitle = true;
const minHeight = 49 + (search ? 40 : 0) + 49;
return (
<Grid container direction="column" spacing={0} style={{ height: '100%', minHeight: `${minHeight}px` }}>
<Grid item container style={{ padding: theme.spacing(1), borderBottom: `1px solid ${theme.palette.divider}` }}>
<Grid item>
{isLocked ? (
<IconButton onClick={unlock} disabled={!isLocked}>
<Lock />
</IconButton>
) : (
<IconButton onClick={lock} disabled={!hasSelections}>
<Unlock />
</IconButton>
)}
</Grid>
<Grid item>
{showTitle && (
<Typography variant="h6" noWrap>
{layout.title || fieldName}
</Typography>
)}
</Grid>
<Grid item xs />
<Grid item>
<ActionsToolbar
more={{
enabled: !isLocked,
actions: listboxSelectionToolbarItems,
alignTo: moreAlignTo,
popoverProps: {
elevation: 0,
},
popoverPaperStyle: {
boxShadow: '0 12px 8px -8px rgba(0, 0, 0, 0.2)',
minWidth: '250px',
},
}}
selections={{
show: showToolbar,
api: selections,
onConfirm: () => {},
onCancel: () => {},
}}
/>
</Grid>
</Grid>
{search ? (
<Grid item>
<ListBoxSearch model={model} />
</Grid>
) : (
''
)}
<Grid item xs>
<div ref={moreAlignTo} />
<AutoSizer>
{({ height, width }) => (
<ListBox
model={model}
selections={selections}
direction={direction}
listLayout={listLayout}
height={height}
width={width}
/>
)}
</AutoSizer>
</Grid>
</Grid>
);
}

View File

@@ -118,7 +118,7 @@ export default function Row({ index, style, data }) {
tabIndex={0}
data-n={cell && cell.qElemNumber}
>
<Grid item style={{ minWidth: 0, flexGrow: 1 }} className={classes.cell}>
<Grid item style={{ minWidth: 0, flexGrow: 1 }} className={classes.cell} title={`${label}`}>
{ranges.length === 0 ? (
<Typography component="span" noWrap color="inherit">{`${label}`}</Typography>
) : (

View File

@@ -120,7 +120,6 @@ export default function OneField({
e.stopPropagation();
api.clearField(selection.qField, field.states[stateIx]);
}}
style={{ zIndex: 1 }}
>
<Remove />
</IconButton>

View File

@@ -4,6 +4,7 @@ import appThemeFn from './app-theme';
import bootNebulaApp from './components/NebulaApp';
import AppSelectionsPortal from './components/selections/AppSelections';
import ListBoxPortal from './components/listbox/ListBoxInline';
import create from './object/create-session-object';
import get from './object/get-object';
@@ -309,6 +310,66 @@ function nuked(configuration = {}) {
}
return selectionsApi;
},
/**
* Gets the instance of the specified field
* @param {string} fieldName
* @returns {Promise<FieldSelections>}
* @experimental
* @example
* const field = await n.field("MyField");
* field.mount(element, { title: "Hello Field"});
*/
field: async (fieldName) => {
/**
* @class
* @hideconstructor
* @alias FieldSelections
* @experimental
*/
const fieldSels = {
fieldName,
/**
* Mounts the field as a listbox into the provided HTMLElement.
* @param {HTMLElement} element
* @param {object=} options Settings for the embedded listbox
* @param {string=} options.title Custom title, defaults to fieldname
* @param {string=} [options.direction=ltr] Direction setting ltr|rtl.
* @param {string=} [options.listLayout=vertical] Layout direction vertical|horizontal
* @param {boolean=} [options.search=true] To show the search bar
* @experimental
* @example
* field.mount(element);
*/
mount(element, options = {}) {
if (!element) {
throw new Error(`Element for ${fieldName} not provided`);
}
if (this._instance) {
throw new Error(`Field ${fieldName} already mounted`);
}
this._instance = ListBoxPortal({
element,
app,
fieldName,
options,
});
root.add(this._instance);
},
/**
* Unmounts the field listbox from the DOM.
* @experimental
* @example
* field.unmount();
*/
unmount() {
if (this._instance) {
root.remove(this._instance);
this._instance = null;
}
},
};
return fieldSels;
},
__DO_NOT_USE__: {
types,
},

View File

@@ -3,7 +3,7 @@
"info": {
"name": "@nebula.js/stardust",
"description": "Product and framework agnostic integration API for Qlik's Associative Engine",
"version": "1.0.2-alpha.0",
"version": "1.0.2-alpha.1",
"license": "MIT",
"stability": "stable"
},
@@ -649,6 +649,26 @@
"// limit constraints\nn.context({ constraints: { active: true } });"
]
},
"field": {
"description": "Gets the instance of the specified field",
"stability": "experimental",
"kind": "function",
"params": [
{
"name": "fieldName",
"type": "string"
}
],
"returns": {
"type": "Promise",
"generics": [
{
"type": "#/definitions/FieldSelections"
}
]
},
"examples": ["const field = await n.field(\"MyField\");\nfield.mount(element, { title: \"Hello Field\"});"]
},
"render": {
"description": "Renders a visualization into an HTMLElement.",
"kind": "function",
@@ -716,6 +736,67 @@
],
"type": "any"
},
"FieldSelections": {
"stability": "experimental",
"kind": "class",
"constructor": {
"kind": "function",
"params": []
},
"entries": {},
"staticEntries": {
"mount": {
"description": "Mounts the field as a listbox into the provided HTMLElement.",
"stability": "experimental",
"kind": "function",
"params": [
{
"name": "element",
"type": "HTMLElement"
},
{
"name": "options",
"description": "Settings for the embedded listbox",
"optional": true,
"kind": "object",
"entries": {
"title": {
"description": "Custom title, defaults to fieldname",
"optional": true,
"type": "string"
},
"direction": {
"description": "Direction setting ltr|rtl.",
"optional": true,
"defaultValue": "ltr",
"type": "string"
},
"listLayout": {
"description": "Layout direction vertical|horizontal",
"optional": true,
"defaultValue": "vertical",
"type": "string"
},
"search": {
"description": "To show the search bar",
"optional": true,
"defaultValue": true,
"type": "boolean"
}
}
}
],
"examples": ["field.mount(element);"]
},
"unmount": {
"description": "Unmounts the field listbox from the DOM.",
"stability": "experimental",
"kind": "function",
"params": [],
"examples": ["field.unmount();"]
}
}
},
"FieldTarget": {
"kind": "interface",
"entries": {

View File

@@ -51,6 +51,7 @@
"react-test-renderer": "17.0.1",
"react-window": "1.8.6",
"react-window-infinite-loader": "1.0.5",
"react-virtualized-auto-sizer": "^1.0.2",
"regenerator-runtime": "0.13.7",
"semver": "6.3.0"
}

View File

@@ -108,7 +108,7 @@ const config = ({ format = 'umd', debug = false, file, targetPkg }) => {
format,
exports: ['test-utils', 'stardust'].indexOf(targetName) !== -1 ? 'named' : 'default',
name: umdName,
sourcemap: false,
sourcemap: true,
banner,
globals,
},

View File

@@ -15685,6 +15685,11 @@ react-transition-group@^4.4.0:
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-virtualized-auto-sizer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd"
integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==
react-window-infinite-loader@1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/react-window-infinite-loader/-/react-window-infinite-loader-1.0.5.tgz#6fe094d538a88978c2c9b623052bc50cb28c2abc"