From 1116ed52d429e11c27e7bf46791ee94d12fbe7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20=C3=85str=C3=B6m?= Date: Mon, 14 Dec 2020 16:03:42 +0100 Subject: [PATCH] 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 --- .gitignore | 1 + apis/nucleus/package.json | 1 + .../src/components/listbox/ListBox.jsx | 16 +- .../src/components/listbox/ListBoxColumn.jsx | 140 +++++++++++++ .../src/components/listbox/ListBoxInline.jsx | 192 ++++++++++++++++++ .../src/components/listbox/ListBoxRow.jsx | 2 +- .../src/components/selections/OneField.jsx | 1 - apis/nucleus/src/index.js | 61 ++++++ apis/stardust/api-spec/spec.json | 83 +++++++- apis/stardust/package.json | 1 + rollup.config.js | 2 +- yarn.lock | 5 + 12 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 apis/nucleus/src/components/listbox/ListBoxColumn.jsx create mode 100644 apis/nucleus/src/components/listbox/ListBoxInline.jsx diff --git a/.gitignore b/.gitignore index 1fa5d1828..56f4f3547 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ temp/ test/**/__artifacts__/regression test/**/__artifacts__/diff apis/*/core/**/*.js +apis/*/core/**/*.js.map apis/locale/all.json Search/ diff --git a/apis/nucleus/package.json b/apis/nucleus/package.json index 20696e3b1..06afa515f 100644 --- a/apis/nucleus/package.json +++ b/apis/nucleus/package.json @@ -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" } } diff --git a/apis/nucleus/src/components/listbox/ListBox.jsx b/apis/nucleus/src/components/listbox/ListBox.jsx index 085bc6726..034aa3fb7 100644 --- a/apis/nucleus/src/components/listbox/ListBox.jsx +++ b/apis/nucleus/src/components/listbox/ListBox.jsx @@ -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 ( - {Row} + {isVertical ? Row : Column} ); }} diff --git a/apis/nucleus/src/components/listbox/ListBoxColumn.jsx b/apis/nucleus/src/components/listbox/ListBoxColumn.jsx new file mode 100644 index 000000000..27ab1abb5 --- /dev/null +++ b/apis/nucleus/src/components/listbox/ListBoxColumn.jsx @@ -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 ( + + + {ranges.length === 0 ? ( + {`${label}`} + ) : ( + labels.map(([l, highlighted], ix) => ( + // eslint-disable-next-line react/no-array-index-key + + {l} + + )) + )} + + + {locked && } + {selected && } + + + ); +} diff --git a/apis/nucleus/src/components/listbox/ListBoxInline.jsx b/apis/nucleus/src/components/listbox/ListBoxInline.jsx new file mode 100644 index 000000000..903d48b95 --- /dev/null +++ b/apis/nucleus/src/components/listbox/ListBoxInline.jsx @@ -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( + , + 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 ( + + + + {isLocked ? ( + + + + ) : ( + + + + )} + + + {showTitle && ( + + {layout.title || fieldName} + + )} + + + + {}, + onCancel: () => {}, + }} + /> + + + {search ? ( + + + + ) : ( + '' + )} + +
+ + {({ height, width }) => ( + + )} + + + + ); +} diff --git a/apis/nucleus/src/components/listbox/ListBoxRow.jsx b/apis/nucleus/src/components/listbox/ListBoxRow.jsx index d70af8595..dfd76b97e 100644 --- a/apis/nucleus/src/components/listbox/ListBoxRow.jsx +++ b/apis/nucleus/src/components/listbox/ListBoxRow.jsx @@ -118,7 +118,7 @@ export default function Row({ index, style, data }) { tabIndex={0} data-n={cell && cell.qElemNumber} > - + {ranges.length === 0 ? ( {`${label}`} ) : ( diff --git a/apis/nucleus/src/components/selections/OneField.jsx b/apis/nucleus/src/components/selections/OneField.jsx index 5f2030e45..597e4fc26 100644 --- a/apis/nucleus/src/components/selections/OneField.jsx +++ b/apis/nucleus/src/components/selections/OneField.jsx @@ -120,7 +120,6 @@ export default function OneField({ e.stopPropagation(); api.clearField(selection.qField, field.states[stateIx]); }} - style={{ zIndex: 1 }} > diff --git a/apis/nucleus/src/index.js b/apis/nucleus/src/index.js index 85e77aca0..7d8db8a0b 100644 --- a/apis/nucleus/src/index.js +++ b/apis/nucleus/src/index.js @@ -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} + * @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, }, diff --git a/apis/stardust/api-spec/spec.json b/apis/stardust/api-spec/spec.json index 413e721a9..ab4ae854d 100644 --- a/apis/stardust/api-spec/spec.json +++ b/apis/stardust/api-spec/spec.json @@ -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": { diff --git a/apis/stardust/package.json b/apis/stardust/package.json index f294b08c7..033353dae 100644 --- a/apis/stardust/package.json +++ b/apis/stardust/package.json @@ -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" } diff --git a/rollup.config.js b/rollup.config.js index fb17598ad..db34d4c1b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -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, }, diff --git a/yarn.lock b/yarn.lock index 8356a6f4e..9602fc19d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"