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:
Christoffer Åström
2019-11-28 23:29:17 +01:00
committed by GitHub
parent 397cbc4294
commit 7f45fbc6c5
23 changed files with 549 additions and 310 deletions

View File

@@ -21,6 +21,12 @@
"import/no-dynamic-require": 0
},
"overrides": [
{
"files": ["apis/**/*"],
"rules": {
"no-console": "error"
}
},
{
"files": ["**/*.{int,spec}.{js,jsx}"],
"env": {

View File

@@ -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: {},
});

View File

@@ -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}>
&nbsp;
</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;

View File

@@ -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>

View 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>
);
}

View File

@@ -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(() => {

View File

@@ -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,
];
}

View File

@@ -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({

View File

@@ -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);

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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 [];
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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}`);
});

View File

@@ -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];
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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(
() =>

View File

@@ -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 || [] : []];
}

View File

@@ -111,6 +111,9 @@ const config = isEsm => {
'useEffect',
'useLayoutEffect',
'useRef',
'useReducer',
'useImperativeHandle',
'forwardRef',
'useContext',
'useCallback',
'useMemo',