mirror of
https://github.com/qlik-oss/nebula.js.git
synced 2025-12-19 17:58:43 -05:00
feat: long running queries (#194)
* feat: long running queries * fix: snapshotting and exporting * chore: treat console as error We default to error for console now Added overrides for current use cases This ensure not getting console logs in without overriding
This commit is contained in:
committed by
GitHub
parent
397cbc4294
commit
7f45fbc6c5
@@ -21,6 +21,12 @@
|
||||
"import/no-dynamic-require": 0
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["apis/**/*"],
|
||||
"rules": {
|
||||
"no-console": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.{int,spec}.{js,jsx}"],
|
||||
"env": {
|
||||
|
||||
@@ -10,24 +10,18 @@ describe('viz', () => {
|
||||
],
|
||||
['../viz.js']
|
||||
);
|
||||
|
||||
describe('api', () => {
|
||||
let api;
|
||||
before(() => {
|
||||
const [{ default: create }] = doMock();
|
||||
|
||||
const { api: foo } = create({
|
||||
const [foo] = create({
|
||||
model: 'a',
|
||||
config: {},
|
||||
context: {},
|
||||
});
|
||||
api = foo;
|
||||
});
|
||||
|
||||
it('should have a reference to the model', () => {
|
||||
expect(api.model).to.equal('a');
|
||||
});
|
||||
|
||||
it('should have a mount method', () => {
|
||||
expect(api.mount).to.be.a('function');
|
||||
});
|
||||
@@ -100,8 +94,10 @@ describe('viz', () => {
|
||||
const [{ default: create }] = doMock({ getter, getPatches });
|
||||
const model = {
|
||||
applyPatches: sinon.spy(),
|
||||
on: sinon.spy(),
|
||||
once: sinon.spy(),
|
||||
};
|
||||
const { api } = create({
|
||||
const [api] = create({
|
||||
model,
|
||||
context: {},
|
||||
});
|
||||
@@ -117,8 +113,10 @@ describe('viz', () => {
|
||||
const [{ default: create }] = doMock({ getter, getPatches });
|
||||
const model = {
|
||||
applyPatches: sinon.spy(),
|
||||
on: sinon.spy(),
|
||||
once: sinon.spy(),
|
||||
};
|
||||
const { api } = create({
|
||||
const [api] = create({
|
||||
model,
|
||||
context: {},
|
||||
});
|
||||
|
||||
@@ -1,20 +1,75 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import React, { forwardRef, useImperativeHandle, useEffect, useState, useContext, useReducer } from 'react';
|
||||
|
||||
import { Grid, Paper } from '@material-ui/core';
|
||||
import { useTheme } from '@nebula.js/ui/theme';
|
||||
|
||||
import CError from './Error';
|
||||
import LongRunningQuery from './LongRunningQuery';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import Supernova from './Supernova';
|
||||
|
||||
import useRect from '../hooks/useRect';
|
||||
import useLayout from '../hooks/useLayout';
|
||||
import useSupernova from '../hooks/useSupernova';
|
||||
import useSelectionsModal from '../hooks/useSelectionsModal';
|
||||
import useLayoutError from '../hooks/useLayoutError';
|
||||
import LocaleContext from '../contexts/LocaleContext';
|
||||
import { createObjectSelectionAPI } from '../selections';
|
||||
|
||||
const initialState = {
|
||||
loading: false,
|
||||
loaded: false,
|
||||
longRunningQuery: false,
|
||||
error: null,
|
||||
sn: null,
|
||||
};
|
||||
|
||||
const contentReducer = (state, action) => {
|
||||
// console.log(action.type);
|
||||
switch (action.type) {
|
||||
case 'LOADING': {
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
case 'LOADED': {
|
||||
return {
|
||||
...state,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
longRunningQuery: false,
|
||||
error: null,
|
||||
sn: action.sn,
|
||||
};
|
||||
}
|
||||
case 'RENDER': {
|
||||
return {
|
||||
...state,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
longRunningQuery: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
case 'LONG_RUNNING_QUERY': {
|
||||
return {
|
||||
...state,
|
||||
longRunningQuery: true,
|
||||
};
|
||||
}
|
||||
case 'ERROR': {
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
longRunningQuery: false,
|
||||
error: action.error,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unhandled type: ${action.type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Loading = ({ delay = 750 }) => {
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
@@ -28,41 +83,194 @@ const Loading = ({ delay = 750 }) => {
|
||||
return showLoading && <div>loading...</div>;
|
||||
};
|
||||
|
||||
export default function Cell({ nebulaContext, model, snContext, snOptions, onMount }) {
|
||||
const translator = useContext(LocaleContext);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [layout] = useLayout(model);
|
||||
const [sn, snErr] = useSupernova({
|
||||
model,
|
||||
nebulaContext,
|
||||
genericObjectType: layout && layout.visualization,
|
||||
genericObjectVersion: layout && layout.version,
|
||||
});
|
||||
const [layoutError, requirementsError] = useLayoutError({ sn, layout });
|
||||
const [contentRef /* contentRect */, , contentNode] = useRect();
|
||||
const theme = useTheme();
|
||||
useSelectionsModal({ sn, model, layout });
|
||||
|
||||
useEffect(() => {
|
||||
onMount();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (snErr) {
|
||||
setErr(snErr);
|
||||
} else if (layoutError.length) {
|
||||
setErr({ message: '', data: layoutError });
|
||||
} else if (requirementsError.length) {
|
||||
setErr({ message: translator.get('Supernova.Incomplete'), data: [] });
|
||||
} else {
|
||||
setErr(null);
|
||||
const handleModal = ({ sn, layout, model }) => {
|
||||
const selections = sn && sn.component && sn.component.selections;
|
||||
if (!selections || !selections.id || !model.id) {
|
||||
return;
|
||||
}
|
||||
if (selections.id === model.id) {
|
||||
selections.setLayout(layout);
|
||||
if (layout && layout.qSelectionInfo && layout.qSelectionInfo.qInSelections && !selections.isModal()) {
|
||||
sn.selections.goModal('/qHyperCubeDef'); // TODO - use path from data targets
|
||||
}
|
||||
if (!layout.qSelectionInfo || !layout.qSelectionInfo.qInSelections) {
|
||||
if (selections.isModal()) {
|
||||
selections.noModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filterData = d => (d.qError ? d.qError.qErrorCode === 7005 : true);
|
||||
|
||||
const validateTargets = (translator, layout, { targets }) => {
|
||||
const layoutErrors = [];
|
||||
const requirementsError = [];
|
||||
targets.forEach(def => {
|
||||
const minD = def.dimensions.min();
|
||||
const minM = def.measures.min();
|
||||
const hc = def.resolveLayout(layout);
|
||||
const d = (hc.qDimensionInfo || []).filter(filterData);
|
||||
const m = (hc.qMeasureInfo || []).filter(filterData);
|
||||
const path = def.layoutPath;
|
||||
if (hc.qError) {
|
||||
layoutErrors.push({ path, error: hc.qError });
|
||||
}
|
||||
if (d.length < minD || m.length < minM) {
|
||||
requirementsError.push(path);
|
||||
}
|
||||
});
|
||||
const showError = !!(layoutErrors.length || requirementsError.length);
|
||||
const title = requirementsError.length ? translator.get('Supernova.Incomplete') : 'Error';
|
||||
const data = requirementsError.length ? requirementsError : layoutErrors;
|
||||
return [showError, { title, data }];
|
||||
};
|
||||
|
||||
const getType = async ({ types, name, version }) => {
|
||||
const SN = await types
|
||||
.get({
|
||||
name,
|
||||
version,
|
||||
})
|
||||
.supernova();
|
||||
return SN;
|
||||
};
|
||||
|
||||
const loadType = async ({ dispatch, types, name, version, layout, model, app }) => {
|
||||
try {
|
||||
const snType = await getType({ types, name, version });
|
||||
// Layout might have changed since we requested the new type -> quick return
|
||||
if (layout.visualization !== name) {
|
||||
return undefined;
|
||||
}
|
||||
const selections = createObjectSelectionAPI(model, app);
|
||||
const sn = snType.create({
|
||||
model,
|
||||
app,
|
||||
selections,
|
||||
});
|
||||
return sn;
|
||||
} catch (err) {
|
||||
dispatch({ type: 'ERROR', error: { title: err.message } });
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const Cell = forwardRef(({ nebulaContext, model, initialSnContext, initialSnOptions, onMount }, ref) => {
|
||||
const {
|
||||
app,
|
||||
nebbie: { types },
|
||||
} = nebulaContext;
|
||||
|
||||
const translator = useContext(LocaleContext);
|
||||
const theme = useTheme();
|
||||
const [state, dispatch] = useReducer(contentReducer, initialState);
|
||||
const [layout, validating, cancel, retry] = useLayout({ app, model });
|
||||
const [contentRef, contentRect, , contentNode] = useRect();
|
||||
const [snContext, setSnContext] = useState(initialSnContext);
|
||||
const [snOptions, setSnOptions] = useState(initialSnOptions);
|
||||
|
||||
useEffect(() => {
|
||||
const validate = sn => {
|
||||
const [showError, error] = validateTargets(translator, layout, sn.generator.qae.data);
|
||||
if (showError) {
|
||||
dispatch({ type: 'ERROR', error });
|
||||
} else {
|
||||
dispatch({ type: 'RENDER' });
|
||||
}
|
||||
handleModal({ sn: state.sn, layout, model });
|
||||
};
|
||||
const load = async (withLayout, version) => {
|
||||
const sn = await loadType({ dispatch, types, name: withLayout.visualization, version, layout, model, app });
|
||||
if (sn) {
|
||||
dispatch({ type: 'LOADED', sn });
|
||||
onMount();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (!layout) {
|
||||
dispatch({ type: 'LOADING' });
|
||||
return undefined;
|
||||
}
|
||||
if (state.sn) {
|
||||
validate(state.sn);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Load supernova h
|
||||
const withVersion = types.getSupportedVersion(layout.visualization, layout.version);
|
||||
if (!withVersion) {
|
||||
dispatch({
|
||||
type: 'ERROR',
|
||||
error: {
|
||||
title: `Could not find a version of '${layout.visualization}' that supports current object version. Did you forget to register ${layout.visualization}?`,
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
load(layout, withVersion);
|
||||
|
||||
return () => {};
|
||||
}, [types, state.sn, model, layout]);
|
||||
|
||||
// Long running query
|
||||
useEffect(() => {
|
||||
if (!validating) {
|
||||
return undefined;
|
||||
}
|
||||
const handle = setTimeout(() => dispatch({ type: 'LONG_RUNNING_QUERY' }), 2000);
|
||||
return () => clearTimeout(handle);
|
||||
}, [validating]);
|
||||
|
||||
// Expose cell ref api
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
setSnContext,
|
||||
setSnOptions,
|
||||
takeSnapshot: async () => {
|
||||
const snapshot = {
|
||||
...layout,
|
||||
snapshotData: {
|
||||
object: {
|
||||
size: {
|
||||
w: contentRect.width,
|
||||
h: contentRect.height,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
if (typeof state.sn.component.setSnapshotData === 'function') {
|
||||
return (await state.sn.component.setSnapshotData(snapshot)) || snapshot;
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
}),
|
||||
[state.sn, contentRect, layout]
|
||||
);
|
||||
|
||||
let Content = null;
|
||||
if (state.loading) {
|
||||
Content = <Loading />;
|
||||
} else if (state.error) {
|
||||
Content = <CError {...state.error} />;
|
||||
} else if (state.loaded) {
|
||||
Content = (
|
||||
<Supernova
|
||||
key={layout.visualization}
|
||||
sn={state.sn}
|
||||
snContext={snContext}
|
||||
snOptions={snOptions}
|
||||
layout={layout}
|
||||
parentNode={contentNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [snErr, layoutError, requirementsError]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
style={{ position: 'relative', width: '100%', height: '100%' }}
|
||||
style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}
|
||||
elevation={0}
|
||||
square
|
||||
className="nebulajs-cell"
|
||||
@@ -71,9 +279,15 @@ export default function Cell({ nebulaContext, model, snContext, snOptions, onMou
|
||||
container
|
||||
direction="column"
|
||||
spacing={0}
|
||||
style={{ position: 'relative', width: '100%', height: '100%', padding: theme.spacing(1) }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: theme.spacing(1),
|
||||
...(state.longRunningQuery ? { opacity: '0.3' } : {}),
|
||||
}}
|
||||
>
|
||||
<Header layout={layout} sn={sn}>
|
||||
<Header layout={layout} sn={state.sn}>
|
||||
|
||||
</Header>
|
||||
<Grid
|
||||
@@ -84,21 +298,12 @@ export default function Cell({ nebulaContext, model, snContext, snOptions, onMou
|
||||
}}
|
||||
ref={contentRef}
|
||||
>
|
||||
{sn === null && err === null && <Loading />}
|
||||
{err && <CError {...err} />}
|
||||
{sn && !err && (
|
||||
<Supernova
|
||||
key={layout.visualization}
|
||||
sn={sn}
|
||||
snContext={snContext}
|
||||
snOptions={snOptions}
|
||||
layout={layout}
|
||||
parentNode={contentNode}
|
||||
/>
|
||||
)}
|
||||
{Content}
|
||||
</Grid>
|
||||
<Footer layout={layout} />
|
||||
</Grid>
|
||||
{state.longRunningQuery && <LongRunningQuery onCancel={cancel} onRetry={retry} />}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
});
|
||||
export default Cell;
|
||||
|
||||
@@ -32,23 +32,23 @@ export default function Error({ title = 'Error', message = '', data = [] }) {
|
||||
spacing={1}
|
||||
>
|
||||
<Grid item>
|
||||
<WarningTriangle style={{ fontSize: '48px' }} />
|
||||
<WarningTriangle style={{ fontSize: '38px' }} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="h4" align="center">
|
||||
<Typography variant="h6" align="center">
|
||||
{title}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="h6" align="center">
|
||||
<Typography variant="subtitle1" align="center">
|
||||
{message}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
{data.map((d, ix) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Typography key={ix} variant="h6" align="center">
|
||||
{d.path} - {d.error.qErrorCode}
|
||||
<Typography key={ix} variant="subtitle2" align="center">
|
||||
{d.path} {d.error && '-'} {d.error && d.error.qErrorCode}
|
||||
</Typography>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
95
apis/nucleus/src/components/LongRunningQuery.jsx
Normal file
95
apis/nucleus/src/components/LongRunningQuery.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import React, { useState } from 'react';
|
||||
import { makeStyles, Grid, Typography, Button } from '@material-ui/core';
|
||||
import WarningTriangle from '@nebula.js/ui/icons/warning-triangle-2';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
stripes: {
|
||||
'&::before': {
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
content: '""',
|
||||
backgroundSize: '14.14px 14.14px',
|
||||
backgroundImage:
|
||||
'linear-gradient(135deg, currentColor 10%, rgba(0,0,0,0) 10%, rgba(0,0,0,0) 50%, currentColor 50%, currentColor 59%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 103%)',
|
||||
opacity: 0.1,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const Cancel = ({ cancel, ...props }) => (
|
||||
<>
|
||||
<Grid item>
|
||||
<Typography variant="h4" align="center">
|
||||
Long running query...
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item {...props}>
|
||||
<Button variant="outlined" onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
|
||||
const Retry = ({ retry, ...props }) => (
|
||||
<>
|
||||
<Grid item>
|
||||
<WarningTriangle style={{ fontSize: '38px' }} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="h6" align="center">
|
||||
Data update was cancelled
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="subtitle1" align="center">
|
||||
Visualization not updated. Please try again.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button variant="contained" onClick={retry} {...props}>
|
||||
Retry
|
||||
</Button>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
|
||||
export default function LongRunningQuery({ onCancel, onRetry }) {
|
||||
const { stripes, cancel, retry } = useStyles();
|
||||
const [canCancel, setCanCancel] = useState(!!onCancel);
|
||||
const [canRetry, setCanRetry] = useState(!!onRetry);
|
||||
const handleCancel = () => {
|
||||
setCanCancel(false);
|
||||
setCanRetry(true);
|
||||
onCancel();
|
||||
};
|
||||
const handleRetry = () => {
|
||||
setCanRetry(false);
|
||||
setCanCancel(true);
|
||||
onRetry();
|
||||
};
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justify="center"
|
||||
className={stripes}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
spacing={1}
|
||||
>
|
||||
{canCancel && <Cancel cancel={handleCancel} className={cancel} />}
|
||||
{canRetry && <Retry retry={handleRetry} className={retry} />}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ const Supernova = ({ sn, snOptions: options, snContext, layout }) => {
|
||||
|
||||
// Mount / Unmount / ThemeChanged
|
||||
useEffect(() => {
|
||||
if (!snNode || !parentNode) return undefined;
|
||||
if (!snNode || !parentNode || !snContext) return undefined;
|
||||
setLogicalSize(constrainElement({ snNode, parentNode, sn, snContext, layout }));
|
||||
component.created({ options, snContext });
|
||||
component.mounted(snNode);
|
||||
@@ -87,7 +87,7 @@ const Supernova = ({ sn, snOptions: options, snContext, layout }) => {
|
||||
component.willUnmount();
|
||||
snContext.theme.removeListener('changed', render);
|
||||
};
|
||||
}, [snNode, parentNode]);
|
||||
}, [snNode, parentNode, snContext]);
|
||||
|
||||
// Render
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,10 +2,18 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Cell from './Cell';
|
||||
|
||||
export default function glue({ nebulaContext, element, model, snContext, snOptions, onMount }) {
|
||||
export default function glue({ nebulaContext, element, model, initialSnContext, initialSnOptions, onMount }) {
|
||||
const { root } = nebulaContext;
|
||||
const cellRef = React.createRef();
|
||||
const portal = ReactDOM.createPortal(
|
||||
<Cell nebulaContext={nebulaContext} model={model} snContext={snContext} snOptions={snOptions} onMount={onMount} />,
|
||||
<Cell
|
||||
ref={cellRef}
|
||||
nebulaContext={nebulaContext}
|
||||
model={model}
|
||||
initialSnContext={initialSnContext}
|
||||
initialSnOptions={initialSnOptions}
|
||||
onMount={onMount}
|
||||
/>,
|
||||
element,
|
||||
model.id
|
||||
);
|
||||
@@ -18,7 +26,10 @@ export default function glue({ nebulaContext, element, model, snContext, snOptio
|
||||
|
||||
root.add(portal);
|
||||
|
||||
return () => {
|
||||
return [
|
||||
() => {
|
||||
unmount();
|
||||
};
|
||||
},
|
||||
cellRef,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import useLayout from '../../hooks/useLayout';
|
||||
import Row from './ListBoxRow';
|
||||
|
||||
export default function ListBox({ model, selections, direction }) {
|
||||
const [layout] = useLayout(model);
|
||||
const [layout] = useLayout({ model });
|
||||
const [pages, setPages] = useState(null);
|
||||
const loaderRef = useRef(null);
|
||||
const local = useRef({
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function ListBoxPopover({ alignTo, show, close, app, fieldName, s
|
||||
model.unlock('/qListObjectDef');
|
||||
}, [model]);
|
||||
|
||||
const [layout] = useLayout(model);
|
||||
const [layout] = useLayout({ model });
|
||||
|
||||
const translator = useContext(LocaleContext);
|
||||
const direction = useContext(DirectionContext);
|
||||
|
||||
@@ -1,20 +1,96 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useReducer, useEffect } from 'react';
|
||||
|
||||
import { observe, unObserve } from '../object/observer';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const sleep = delay => {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
};
|
||||
|
||||
export default function useLayout(model) {
|
||||
const [layout, setLayout] = useState(null);
|
||||
const layoutReducer = (state, action) => {
|
||||
// console.log(action.type);
|
||||
switch (action.type) {
|
||||
case 'INVALID': {
|
||||
return {
|
||||
...state,
|
||||
validating: true,
|
||||
cancel: action.cancel,
|
||||
};
|
||||
}
|
||||
case 'VALID': {
|
||||
return {
|
||||
validating: false,
|
||||
layout: action.layout,
|
||||
};
|
||||
}
|
||||
case 'CANCELLED': {
|
||||
return {
|
||||
...state,
|
||||
validating: false,
|
||||
cancelled: true,
|
||||
retry: action.retry,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unhandled type: ${action.type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
const getLayout = ({ dispatch, app, model }) => {
|
||||
let canCancel = true;
|
||||
const rpc = model.getLayout();
|
||||
canCancel = false;
|
||||
dispatch({
|
||||
type: 'INVALID',
|
||||
cancel: async () => {
|
||||
if (canCancel) {
|
||||
const global = app.session.getObjectApi({ handle: -1 });
|
||||
await global.cancelRequest(rpc.requestId);
|
||||
dispatch({
|
||||
type: 'CANCELLED',
|
||||
retry: () => getLayout({ dispatch, app, model })(),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return async () => {
|
||||
canCancel = true;
|
||||
try {
|
||||
const layout = await rpc;
|
||||
// await sleep(15000);
|
||||
canCancel = false;
|
||||
dispatch({ type: 'VALID', layout });
|
||||
} catch (err) {
|
||||
// TODO - this can happen for requested aborted
|
||||
// console.info(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
validating: false,
|
||||
cancelled: false,
|
||||
cancel: null,
|
||||
retry: null,
|
||||
layout: null,
|
||||
};
|
||||
|
||||
export default function useLayout({ app, model }) {
|
||||
const [state, dispatch] = useReducer(layoutReducer, initialState);
|
||||
const onChanged = () => {
|
||||
getLayout({ dispatch, app, model })();
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!model) {
|
||||
return undefined;
|
||||
}
|
||||
observe(model, setLayout);
|
||||
|
||||
model.on('changed', onChanged);
|
||||
onChanged({ dispatch, app, model });
|
||||
return () => {
|
||||
unObserve(model, setLayout);
|
||||
model.removeListener('changed', onChanged);
|
||||
};
|
||||
}, [model && model.id]);
|
||||
|
||||
return [layout];
|
||||
return [state.layout, state.validating, state.cancel, state.retry];
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const filterData = d => (d.qError ? d.qError.qErrorCode === 7005 : true);
|
||||
|
||||
export default function useLayoutError({ sn, layout }, deps = []) {
|
||||
const [layoutError, setLayoutError] = useState([]);
|
||||
const [requirementError, setRequirementError] = useState([]);
|
||||
useEffect(() => {
|
||||
if (!sn || !sn.generator || !sn.generator.qae || !layout) {
|
||||
return;
|
||||
}
|
||||
const { targets } = sn.generator.qae.data;
|
||||
const layoutErrors = [];
|
||||
const requirementsError = [];
|
||||
targets.forEach(def => {
|
||||
const minD = def.dimensions.min();
|
||||
const minM = def.measures.min();
|
||||
const hc = def.resolveLayout(layout);
|
||||
const d = (hc.qDimensionInfo || []).filter(filterData);
|
||||
const m = (hc.qMeasureInfo || []).filter(filterData);
|
||||
const path = def.layoutPath;
|
||||
if (hc.qError) {
|
||||
layoutErrors.push({ path, error: hc.qError });
|
||||
}
|
||||
if (d.length < minD || m.length < minM) {
|
||||
requirementsError.push(path);
|
||||
}
|
||||
});
|
||||
setLayoutError(layoutErrors);
|
||||
setRequirementError(requirementsError);
|
||||
}, [sn, layout, ...deps]);
|
||||
|
||||
return [layoutError, requirementError];
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function useSelectionModal({ sn, layout, model }, deps = []) {
|
||||
useEffect(() => {
|
||||
if (sn && sn.component.selections && sn.component.selections.id === model.id) {
|
||||
sn.component.selections.setLayout(layout);
|
||||
if (layout.qSelectionInfo && layout.qSelectionInfo.qInSelections && !sn.component.selections.isModal()) {
|
||||
sn.selections.goModal('/qHyperCubeDef'); // TODO - use path from data targets
|
||||
}
|
||||
if (!layout.qSelectionInfo || !layout.qSelectionInfo.qInSelections) {
|
||||
if (sn.component.selections.isModal()) {
|
||||
sn.component.selections.noModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [sn && sn.component.selections, layout, model, ...deps]);
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import useSupernovaType from './userSupernovaType';
|
||||
import { createObjectSelectionAPI } from '../selections';
|
||||
|
||||
export default function useSupernova({ model, nebulaContext, genericObjectType, genericObjectVersion }, deps = []) {
|
||||
const [supernova, setSupernova] = useState(null);
|
||||
const [snErr, setSnErr] = useState(null);
|
||||
const [snType, snTypeErr] = useSupernovaType({ nebulaContext, genericObjectType, genericObjectVersion });
|
||||
|
||||
useEffect(() => {
|
||||
if (!snType || snTypeErr) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { app } = nebulaContext;
|
||||
const selections = createObjectSelectionAPI(model, app);
|
||||
const sn = snType.create({
|
||||
model,
|
||||
app,
|
||||
selections,
|
||||
});
|
||||
setSupernova(sn);
|
||||
} catch (err) {
|
||||
setSnErr(err);
|
||||
}
|
||||
}, [snType, snTypeErr, ...deps]);
|
||||
|
||||
return [supernova, snTypeErr, snErr];
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function useSupernovaType({ nebulaContext, genericObjectType, genericObjectVersion }, deps = []) {
|
||||
const [snType, setSnType] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!genericObjectType) {
|
||||
return;
|
||||
}
|
||||
const withVersion = nebulaContext.nebbie.types.getSupportedVersion(genericObjectType, genericObjectVersion);
|
||||
if (!withVersion) {
|
||||
setErr({
|
||||
message: `Could not find a version of '${genericObjectType}' that supports current object version. Did you forget to register ${genericObjectType}?`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
|
||||
const get = async () => {
|
||||
const SN = await nebulaContext.nebbie.types
|
||||
.get({
|
||||
name: genericObjectType,
|
||||
version: withVersion,
|
||||
})
|
||||
.supernova();
|
||||
setSnType(SN);
|
||||
};
|
||||
get();
|
||||
}, [genericObjectType, genericObjectVersion, ...deps]);
|
||||
|
||||
return [snType, err];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import vizualizationAPI from '../viz';
|
||||
|
||||
const cache = {};
|
||||
/**
|
||||
* @typedef {object} GetObjectConfig
|
||||
* @property {string} id
|
||||
@@ -13,24 +14,22 @@ import vizualizationAPI from '../viz';
|
||||
* @property {Array<'passive'|'select'|'interact'|'fetch'>} [context.permissions]
|
||||
* @property {object=} properties
|
||||
*/
|
||||
|
||||
export default function initiate({ id }, optional, context) {
|
||||
return context.app.getObject(id).then(model => {
|
||||
const viz = vizualizationAPI({
|
||||
export default async function initiate({ id }, optional, context) {
|
||||
const cacheKey = `${context.app.id}/${id}`;
|
||||
const model = cache[cacheKey] || (await context.app.getObject(id));
|
||||
const [viz] = vizualizationAPI({
|
||||
model,
|
||||
context,
|
||||
});
|
||||
|
||||
if (optional.element) {
|
||||
await viz.mount(optional.element);
|
||||
}
|
||||
if (optional.options) {
|
||||
viz.api.options(optional.options);
|
||||
viz.options(optional.options);
|
||||
}
|
||||
if (optional.context) {
|
||||
viz.api.context(optional.context);
|
||||
viz.context(optional.context);
|
||||
}
|
||||
if (optional.element) {
|
||||
return viz.api.mount(optional.element).then(() => viz.api);
|
||||
}
|
||||
|
||||
return viz.api;
|
||||
});
|
||||
cache[cacheKey] = model;
|
||||
return viz;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export default function hcHandler({ hc, def }) {
|
||||
// TODO - rename 'add' to 'added' since the callback is run after the dimension has been added
|
||||
def.dimensions.add(dimension, objectProperties);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Should add dimension to layout exclude');
|
||||
// add to layout exclude
|
||||
}
|
||||
@@ -124,6 +125,7 @@ export default function hcHandler({ hc, def }) {
|
||||
|
||||
def.measures.add(measure, objectProperties);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Should add measure to layout exclude');
|
||||
// add to layout exclude
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export function load(name, version, config, loader) {
|
||||
return sn;
|
||||
})
|
||||
.catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
throw new Error(`Failed to load supernova: ${name}`);
|
||||
});
|
||||
|
||||
@@ -3,68 +3,58 @@ import glueCell from './components/glue';
|
||||
import { get } from './object/observer';
|
||||
import getPatches from './utils/patcher';
|
||||
|
||||
import eventMixin from './selections/event-mixin';
|
||||
|
||||
const noopi = () => {};
|
||||
|
||||
export default function viz({ model, context: nebulaContext, initialUserProps = {} } = {}) {
|
||||
export default function viz({ model, context: nebulaContext } = {}) {
|
||||
let unmountCell = noopi;
|
||||
let cellRef = null;
|
||||
let mountedReference = null;
|
||||
let onMount = null;
|
||||
const mounted = new Promise(resolve => {
|
||||
onMount = resolve;
|
||||
});
|
||||
|
||||
let userProps = {
|
||||
options: {},
|
||||
context: {
|
||||
let initialSnContext = {
|
||||
theme: nebulaContext.theme,
|
||||
permissions: [],
|
||||
},
|
||||
...initialUserProps,
|
||||
};
|
||||
let initialSnOptions = {};
|
||||
|
||||
const setSnOptions = async opts => {
|
||||
if (mountedReference) {
|
||||
(async () => {
|
||||
await mounted;
|
||||
cellRef.current.setSnOptions({
|
||||
...initialSnOptions,
|
||||
...opts,
|
||||
});
|
||||
})();
|
||||
} else {
|
||||
// Handle setting options before mount
|
||||
initialSnOptions = {
|
||||
...initialSnOptions,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let objectProps = {
|
||||
layout: null,
|
||||
sn: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const vizGlue = {
|
||||
userProps() {
|
||||
return userProps;
|
||||
},
|
||||
objectProps() {
|
||||
return objectProps;
|
||||
},
|
||||
};
|
||||
|
||||
eventMixin(vizGlue);
|
||||
|
||||
const update = () => {
|
||||
vizGlue.emit('changed');
|
||||
};
|
||||
|
||||
const setUserProps = up => {
|
||||
userProps = {
|
||||
...userProps,
|
||||
...up,
|
||||
context: {
|
||||
// DO NOT MAKE A DEEP COPY OF THEME AS IT WOULD MESS UP THE INSTANCE
|
||||
...(userProps || {}).context,
|
||||
...(up || {}).context,
|
||||
const setSnContext = async ctx => {
|
||||
if (mountedReference) {
|
||||
(async () => {
|
||||
await mounted;
|
||||
cellRef.current.setSnContext({
|
||||
...initialSnContext,
|
||||
...ctx,
|
||||
theme: nebulaContext.theme,
|
||||
},
|
||||
});
|
||||
})();
|
||||
} else {
|
||||
// Handle setting context before mount
|
||||
initialSnContext = {
|
||||
...initialSnContext,
|
||||
...ctx,
|
||||
};
|
||||
update();
|
||||
};
|
||||
|
||||
const setObjectProps = p => {
|
||||
objectProps = {
|
||||
...objectProps,
|
||||
...p,
|
||||
};
|
||||
update();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -86,12 +76,12 @@ export default function viz({ model, context: nebulaContext, initialUserProps =
|
||||
throw new Error('Already mounted');
|
||||
}
|
||||
mountedReference = element;
|
||||
unmountCell = glueCell({
|
||||
[unmountCell, cellRef] = glueCell({
|
||||
nebulaContext,
|
||||
element,
|
||||
model,
|
||||
snContext: userProps.context,
|
||||
snOptions: userProps.options,
|
||||
initialSnContext,
|
||||
initialSnOptions,
|
||||
onMount,
|
||||
});
|
||||
return mounted;
|
||||
@@ -111,48 +101,19 @@ export default function viz({ model, context: nebulaContext, initialUserProps =
|
||||
if (patches.length) {
|
||||
return model.applyPatches(patches, true);
|
||||
}
|
||||
return Promise.resolve();
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
options(opts) {
|
||||
setUserProps({
|
||||
options: opts,
|
||||
});
|
||||
setSnOptions(opts);
|
||||
return api;
|
||||
},
|
||||
context(ctx) {
|
||||
setUserProps({
|
||||
context: ctx,
|
||||
});
|
||||
setSnContext(ctx);
|
||||
return api;
|
||||
},
|
||||
takeSnapshot() {
|
||||
if (mountedReference) {
|
||||
const content = mountedReference.querySelector('.nebulajs-sn');
|
||||
if (content) {
|
||||
const rect = content.getBoundingClientRect();
|
||||
if (objectProps.sn) {
|
||||
const snapshot = {
|
||||
...objectProps.layout,
|
||||
snapshotData: {
|
||||
object: {
|
||||
size: {
|
||||
w: rect.width,
|
||||
h: rect.height,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof objectProps.sn.component.setSnapshotData === 'function') {
|
||||
return Promise.resolve(objectProps.sn.component.setSnapshotData(snapshot)).then(v => v || snapshot);
|
||||
}
|
||||
return Promise.resolve(snapshot);
|
||||
}
|
||||
return Promise.reject(new Error('No content'));
|
||||
}
|
||||
}
|
||||
return Promise.reject(new Error('Not mounted yet'));
|
||||
return cellRef.current.takeSnapshot();
|
||||
},
|
||||
|
||||
// QVisualization API
|
||||
@@ -166,8 +127,5 @@ export default function viz({ model, context: nebulaContext, initialUserProps =
|
||||
// toggleDataView() {},
|
||||
};
|
||||
|
||||
return {
|
||||
setObjectProps,
|
||||
api,
|
||||
};
|
||||
return [api, mounted];
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ function snapshotter({ host, port }) {
|
||||
await page.waitFor(
|
||||
() =>
|
||||
document.querySelector('.nebulajs-sn') &&
|
||||
document.querySelector('.nebulajs-sn').getAttribute('data-rendered') === '1'
|
||||
+document.querySelector('.nebulajs-sn').getAttribute('data-render-count') > 0
|
||||
);
|
||||
const image = await page.screenshot();
|
||||
images[snap.key] = image;
|
||||
|
||||
@@ -10,7 +10,7 @@ import Cell from './Cell';
|
||||
|
||||
export default function Collection({ types, cache }) {
|
||||
const app = useContext(AppContext);
|
||||
const [layout] = useLayout(app);
|
||||
const [layout] = useLayout({ app });
|
||||
const [objects, setObjects] = useState(null);
|
||||
|
||||
const { expandedObject } = useContext(VizContext);
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function FieldsPopover({ alignTo, show, close, onSelected, type }
|
||||
app
|
||||
);
|
||||
|
||||
const [layout] = useLayout(model, app);
|
||||
const [layout] = useLayout({ model, app });
|
||||
|
||||
const fields = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -33,6 +33,6 @@ export default function list(app, type = 'dimension') {
|
||||
const def = type === 'dimension' ? D : M;
|
||||
|
||||
const [model] = useModel(def, app);
|
||||
const [layout] = useLayout(model, app);
|
||||
const [layout] = useLayout({ model, app });
|
||||
return [layout ? (layout.qDimensionList || layout.qMeasureList).qItems || [] : []];
|
||||
}
|
||||
|
||||
@@ -111,6 +111,9 @@ const config = isEsm => {
|
||||
'useEffect',
|
||||
'useLayoutEffect',
|
||||
'useRef',
|
||||
'useReducer',
|
||||
'useImperativeHandle',
|
||||
'forwardRef',
|
||||
'useContext',
|
||||
'useCallback',
|
||||
'useMemo',
|
||||
|
||||
Reference in New Issue
Block a user