mirror of
https://github.com/qlik-oss/nebula.js.git
synced 2025-12-19 09:48:18 -05:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ temp/
|
||||
test/**/__artifacts__/regression
|
||||
test/**/__artifacts__/diff
|
||||
apis/*/core/**/*.js
|
||||
apis/*/core/**/*.js.map
|
||||
apis/locale/all.json
|
||||
|
||||
Search/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
|
||||
140
apis/nucleus/src/components/listbox/ListBoxColumn.jsx
Normal file
140
apis/nucleus/src/components/listbox/ListBoxColumn.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
apis/nucleus/src/components/listbox/ListBoxInline.jsx
Normal file
192
apis/nucleus/src/components/listbox/ListBoxInline.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -120,7 +120,6 @@ export default function OneField({
|
||||
e.stopPropagation();
|
||||
api.clearField(selection.qField, field.states[stateIx]);
|
||||
}}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<Remove />
|
||||
</IconButton>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user