mirror of
https://github.com/qlik-oss/nebula.js.git
synced 2025-12-19 09:48:18 -05:00
* feat: add basic sheet rendering support * chore: add missing file * fix: correct bg colors for none support * chore: fix test that relied on dark bg * chore: fix ref * chore: api spec update * chore: add todo comments * chore: use memo * chore: a bit less verbose * chore: list * chore: cleaning * chore: add rendering test * chore: enable rendering test * chore: settings * chore: settings * chore: disable rendering tests * chore: revert test tests
554 lines
17 KiB
JavaScript
554 lines
17 KiB
JavaScript
/* eslint-disable react/jsx-props-no-spreading */
|
|
import React, { forwardRef, useImperativeHandle, useEffect, useState, useContext, useReducer, useRef } from 'react';
|
|
|
|
import { Grid, Paper } from '@mui/material';
|
|
import { useTheme } from '@nebula.js/ui/theme';
|
|
|
|
import CError from './Error';
|
|
import LongRunningQuery from './LongRunningQuery';
|
|
import Loading from './Loading';
|
|
import Header from './Header';
|
|
import Footer from './Footer';
|
|
import Supernova from './Supernova';
|
|
|
|
import useRect from '../hooks/useRect';
|
|
import useLayout, { useAppLayout } from '../hooks/useLayout';
|
|
import InstanceContext from '../contexts/InstanceContext';
|
|
import useObjectSelections from '../hooks/useObjectSelections';
|
|
import eventmixin from '../selections/event-mixin';
|
|
import { resolveBgColor, resolveBgImage } from '../utils/background-props';
|
|
|
|
/**
|
|
* @interface
|
|
* @extends HTMLElement
|
|
*/
|
|
const CellElement = {
|
|
/** @type {'njs-cell'} */
|
|
className: 'njs-cell',
|
|
};
|
|
|
|
const initialState = (err) => ({
|
|
loading: false,
|
|
loaded: false,
|
|
longRunningQuery: false,
|
|
error: err ? { title: err.message } : null,
|
|
sn: null,
|
|
visualization: null,
|
|
});
|
|
|
|
const contentReducer = (state, action) => {
|
|
// console.log('content reducer', 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,
|
|
visualization: action.visualization,
|
|
};
|
|
}
|
|
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}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
function LoadingSn({ delay = 750 }) {
|
|
const [showLoading, setShowLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const handle = setTimeout(() => setShowLoading(true), delay);
|
|
|
|
return () => clearTimeout(handle);
|
|
}, []);
|
|
|
|
return showLoading ? <Loading /> : 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) {
|
|
if (layout && layout.qSelectionInfo && layout.qSelectionInfo.qInSelections && !selections.isModal()) {
|
|
const { targets } = sn.generator.qae.data;
|
|
const firstPropertyPath = targets[0].propertyPath;
|
|
selections.goModal(firstPropertyPath);
|
|
}
|
|
if (!layout.qSelectionInfo || !layout.qSelectionInfo.qInSelections) {
|
|
if (selections.isModal()) {
|
|
selections.noModal();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const filterData = (d) => (d.qError ? d.qError.qErrorCode === 7005 : true);
|
|
|
|
const validateInfo = (min, info, getDescription, translatedError, translatedCalcCond) =>
|
|
[...Array(min).keys()].map((i) => {
|
|
const exists = !!(info && info[i]);
|
|
const softError = exists && info[i].qError && info[i].qError.qErrorCode === 7005;
|
|
const error = exists && !softError && info[i].qError;
|
|
const delimiter = ':';
|
|
const calcCondMsg = softError && info[i].qCalcCondMsg;
|
|
const label = `${
|
|
// eslint-disable-next-line no-nested-ternary
|
|
error ? translatedError : softError ? calcCondMsg || translatedCalcCond : (exists && info[i].qFallbackTitle) || ''
|
|
}`;
|
|
const customDescription = getDescription(i);
|
|
const description = customDescription ? `${customDescription}${label.length ? delimiter : ''}` : null;
|
|
return {
|
|
description,
|
|
label,
|
|
missing: (info && !exists && !error && i >= info.length) || softError,
|
|
error,
|
|
};
|
|
});
|
|
|
|
const getInfo = (info) => (info && (Array.isArray(info) ? info : [info])) || [];
|
|
|
|
const validateTarget = (translator, layout, properties, def) => {
|
|
const minD = def.dimensions.min();
|
|
const minM = def.measures.min();
|
|
const c = def.resolveLayout(layout);
|
|
const reqDimErrors = validateInfo(
|
|
minD,
|
|
getInfo(c.qDimensionInfo),
|
|
(i) => def.dimensions.description(properties, i),
|
|
translator.get('Visualization.Invalid.Dimension'),
|
|
translator.get('Visualization.UnfulfilledCalculationCondition')
|
|
);
|
|
const reqMeasErrors = validateInfo(
|
|
minM,
|
|
getInfo(c.qMeasureInfo),
|
|
(i) => def.measures.description(properties, i),
|
|
translator.get('Visualization.Invalid.Measure'),
|
|
translator.get('Visualization.UnfulfilledCalculationCondition')
|
|
);
|
|
return {
|
|
reqDimErrors,
|
|
reqMeasErrors,
|
|
};
|
|
};
|
|
|
|
const validateCubes = (translator, targets, layout) => {
|
|
let hasUnfulfilledErrors = false;
|
|
let aggMinD = 0;
|
|
let aggMinM = 0;
|
|
let hasLayoutErrors = false;
|
|
let hasLayoutUnfulfilledCalculcationCondition = false;
|
|
const layoutErrors = [];
|
|
for (let i = 0; i < targets.length; ++i) {
|
|
const def = targets[i];
|
|
const minD = def.dimensions.min();
|
|
const minM = def.measures.min();
|
|
const c = def.resolveLayout(layout);
|
|
const d = getInfo(c.qDimensionInfo).filter(filterData); // Filter out optional calc conditions
|
|
const m = getInfo(c.qMeasureInfo).filter(filterData); // Filter out optional calc conditions
|
|
aggMinD += minD;
|
|
aggMinM += minM;
|
|
if (d.length < minD || m.length < minM) {
|
|
hasUnfulfilledErrors = true;
|
|
}
|
|
if (c.qError) {
|
|
hasLayoutErrors = true;
|
|
hasLayoutUnfulfilledCalculcationCondition = c.qError.qErrorCode === 7005;
|
|
const title =
|
|
// eslint-disable-next-line no-nested-ternary
|
|
hasLayoutUnfulfilledCalculcationCondition && c.qCalcCondMsg
|
|
? c.qCalcCondMsg
|
|
: hasLayoutUnfulfilledCalculcationCondition
|
|
? translator.get('Visualization.UnfulfilledCalculationCondition')
|
|
: translator.get('Visualization.LayoutError');
|
|
|
|
layoutErrors.push({ title, descriptions: [] });
|
|
}
|
|
}
|
|
return {
|
|
hasUnfulfilledErrors,
|
|
aggMinD,
|
|
aggMinM,
|
|
hasLayoutErrors,
|
|
layoutErrors,
|
|
};
|
|
};
|
|
|
|
const validateTargets = async (translator, layout, { targets }, model) => {
|
|
// Use a flattened requirements structure to combine all targets
|
|
const { hasUnfulfilledErrors, aggMinD, aggMinM, hasLayoutErrors, layoutErrors } = validateCubes(
|
|
translator,
|
|
targets,
|
|
layout
|
|
);
|
|
|
|
const reqDimErrors = [];
|
|
const reqMeasErrors = [];
|
|
let loopCacheProperties = null;
|
|
|
|
for (let i = 0; i < targets.length; ++i) {
|
|
const def = targets[i];
|
|
if (!hasLayoutErrors && hasUnfulfilledErrors) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const properties = loopCacheProperties || (await model.getProperties());
|
|
loopCacheProperties = properties;
|
|
const res = validateTarget(translator, layout, properties, def);
|
|
reqDimErrors.push(...res.reqDimErrors);
|
|
reqMeasErrors.push(...res.reqMeasErrors);
|
|
}
|
|
}
|
|
const fulfilledDims = reqDimErrors.filter((e) => !(e.missing || e.error)).length;
|
|
const reqDimErrorsTitle = translator.get('Visualization.Incomplete.Dimensions', [fulfilledDims, aggMinD]);
|
|
const fulfilledMeas = reqMeasErrors.filter((e) => !(e.missing || e.error)).length;
|
|
const reqMeasErrorsTitle = translator.get('Visualization.Incomplete.Measures', [fulfilledMeas, aggMinM]);
|
|
const reqErrors = [
|
|
{ title: reqDimErrorsTitle, descriptions: [...reqDimErrors] },
|
|
{ title: reqMeasErrorsTitle, descriptions: [...reqMeasErrors] },
|
|
];
|
|
|
|
const showError = hasLayoutErrors || hasUnfulfilledErrors;
|
|
const data = hasLayoutErrors ? layoutErrors : reqErrors;
|
|
const title = hasLayoutErrors ? layoutErrors[0].title : translator.get('Visualization.Incomplete');
|
|
|
|
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, visualization, version, model, app, selections, nebbie, focusHandler }) => {
|
|
try {
|
|
const snType = await getType({ types, name: visualization, version });
|
|
const sn = snType.create({
|
|
model,
|
|
app,
|
|
selections,
|
|
nebbie,
|
|
focusHandler,
|
|
});
|
|
return sn;
|
|
} catch (err) {
|
|
dispatch({ type: 'ERROR', error: { title: err.message } });
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const Cell = forwardRef(
|
|
({ halo, model, initialSnOptions, initialSnPlugins, initialError, onMount, currentId }, ref) => {
|
|
const { app, types } = halo;
|
|
const { nebbie } = halo.public;
|
|
const { disableCellPadding = false } = halo.context || {};
|
|
|
|
const { theme: themeName, translator, language, keyboardNavigation } = useContext(InstanceContext);
|
|
const theme = useTheme();
|
|
const [cellRef, cellRect, cellNode] = useRect();
|
|
const [state, dispatch] = useReducer(contentReducer, initialState(initialError));
|
|
const [layout, { validating, canCancel, canRetry }, longrunning] = useLayout(model);
|
|
const [appLayout] = useAppLayout(app);
|
|
const [contentRef, contentRect, contentNode] = useRect();
|
|
const [snOptions, setSnOptions] = useState(initialSnOptions);
|
|
const [snPlugins, setSnPlugins] = useState(initialSnPlugins);
|
|
const cellElementId = `njs-cell-${currentId}`;
|
|
const [selections] = useObjectSelections(app, model, [`#${cellElementId}`, '.njs-action-toolbar-popover']); // elements which will not trigger the click out listener
|
|
const [hovering, setHover] = useState(false);
|
|
const hoveringDebouncer = useRef({ enter: null, leave: null });
|
|
const [bgColor, setBgColor] = useState(undefined);
|
|
const [bgImage, setBgImage] = useState(undefined); // {url: "", size: "", pos: ""}
|
|
|
|
const focusHandler = useRef({
|
|
focusToolbarButton(last) {
|
|
// eslint-disable-next-line react/no-this-in-sfc
|
|
this.emit(last ? 'focus_toolbar_last' : 'focus_toolbar_first');
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
eventmixin(focusHandler.current);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const bgComp = layout?.components ? layout.components.find((comp) => comp.key === 'general') : null;
|
|
setBgColor(resolveBgColor(bgComp, halo.public.theme));
|
|
setBgImage(resolveBgImage(bgComp, halo.app));
|
|
}, [layout, halo.public.theme, halo.app, themeName]);
|
|
|
|
focusHandler.current.blurCallback = (resetFocus) => {
|
|
halo.root.toggleFocusOfCells();
|
|
if (resetFocus && contentNode) {
|
|
contentNode.focus();
|
|
}
|
|
};
|
|
focusHandler.current.refocusContent = () => {
|
|
state.sn.component && typeof state.sn.component.focus === 'function' && state.sn.component.focus();
|
|
};
|
|
|
|
const handleOnMouseEnter = () => {
|
|
if (hoveringDebouncer.current.leave) {
|
|
clearTimeout(hoveringDebouncer.current.leave);
|
|
}
|
|
if (hoveringDebouncer.enter) return;
|
|
hoveringDebouncer.current.enter = setTimeout(() => {
|
|
setHover(true);
|
|
hoveringDebouncer.current.enter = null;
|
|
}, 250);
|
|
};
|
|
const handleOnMouseLeave = () => {
|
|
if (hoveringDebouncer.current.enter) {
|
|
clearTimeout(hoveringDebouncer.current.enter);
|
|
}
|
|
if (hoveringDebouncer.current.leave) return;
|
|
hoveringDebouncer.current.leave = setTimeout(() => {
|
|
setHover(false);
|
|
hoveringDebouncer.current.leave = null;
|
|
}, 750);
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
// Enter or space
|
|
if (['Enter', ' ', 'Spacebar'].includes(e.key)) {
|
|
halo.root.toggleFocusOfCells(currentId);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (initialError || !appLayout || !layout) {
|
|
return undefined;
|
|
}
|
|
const validate = async (sn) => {
|
|
const [showError, error] = await validateTargets(translator, layout, sn.generator.qae.data, model);
|
|
if (showError) {
|
|
dispatch({ type: 'ERROR', error });
|
|
} else {
|
|
dispatch({ type: 'RENDER' });
|
|
}
|
|
handleModal({ sn: state.sn, layout, model });
|
|
};
|
|
const load = async (visualization, version) => {
|
|
dispatch({ type: 'LOADING' });
|
|
const sn = await loadType({
|
|
dispatch,
|
|
types,
|
|
visualization,
|
|
version,
|
|
model,
|
|
app,
|
|
selections,
|
|
nebbie,
|
|
focusHandler: focusHandler.current,
|
|
});
|
|
if (sn) {
|
|
dispatch({ type: 'LOADED', sn, visualization });
|
|
onMount();
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// Validate if it's still the same type
|
|
if (state.visualization === layout.visualization && state.sn) {
|
|
validate(state.sn);
|
|
return undefined;
|
|
}
|
|
|
|
// Load supernova
|
|
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.visualization, withVersion);
|
|
|
|
return () => {};
|
|
}, [types, state.sn, model, selections, layout, appLayout, language]);
|
|
|
|
// 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,
|
|
() => ({
|
|
getQae() {
|
|
return state.sn.generator.qae;
|
|
},
|
|
toggleFocus(active) {
|
|
if (typeof state.sn.component.focus === 'function') {
|
|
if (active) {
|
|
state.sn.component.focus();
|
|
} else {
|
|
state.sn.component.blur();
|
|
}
|
|
}
|
|
},
|
|
setSnOptions,
|
|
setSnPlugins,
|
|
async takeSnapshot() {
|
|
const { width, height } = cellRect;
|
|
|
|
// clone layout to avoid mutation
|
|
let clonedLayout = JSON.parse(JSON.stringify(layout));
|
|
if (typeof state.sn.component.setSnapshotData === 'function') {
|
|
clonedLayout = (await state.sn.component.setSnapshotData(clonedLayout)) || clonedLayout;
|
|
}
|
|
return {
|
|
// TODO - this snapshot format needs to be documented and governed
|
|
key: String(+Date.now()),
|
|
meta: {
|
|
language: translator.language(),
|
|
theme: theme.name,
|
|
appLayout,
|
|
// direction: 'ltr',
|
|
size: {
|
|
width: Math.round(width),
|
|
height: Math.round(height),
|
|
},
|
|
},
|
|
layout: clonedLayout,
|
|
};
|
|
},
|
|
async exportImage() {
|
|
if (typeof halo.config.snapshot.capture !== 'function') {
|
|
throw new Error('Stardust embed has not been configured with snapshot.capture callback');
|
|
}
|
|
const snapshot = await this.takeSnapshot(); // eslint-disable-line
|
|
return halo.config.snapshot.capture(snapshot);
|
|
},
|
|
}),
|
|
[state.sn, contentRect, cellRect, layout, theme.name, appLayout]
|
|
);
|
|
|
|
// console.log('content', state);
|
|
let Content = null;
|
|
if (state.loading && !state.longRunningQuery) {
|
|
Content = <LoadingSn />;
|
|
} else if (state.error) {
|
|
Content = <CError {...state.error} />;
|
|
} else if (state.loaded) {
|
|
Content = (
|
|
<Supernova
|
|
key={layout.visualization}
|
|
sn={state.sn}
|
|
halo={halo}
|
|
snOptions={snOptions}
|
|
snPlugins={snPlugins}
|
|
layout={layout}
|
|
appLayout={appLayout}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Paper
|
|
style={{
|
|
position: 'relative',
|
|
width: '100%',
|
|
height: '100%',
|
|
overflow: 'hidden',
|
|
backgroundColor: bgColor || 'unset',
|
|
backgroundImage: bgImage && bgImage.url ? `url(${bgImage.url})` : undefined,
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundSize: bgImage && bgImage.size,
|
|
backgroundPosition: bgImage && bgImage.pos,
|
|
}}
|
|
elevation={0}
|
|
square
|
|
className={CellElement.className}
|
|
ref={cellRef}
|
|
id={cellElementId}
|
|
onMouseEnter={handleOnMouseEnter}
|
|
onMouseLeave={handleOnMouseLeave}
|
|
>
|
|
<Grid
|
|
container
|
|
direction="column"
|
|
gap={0}
|
|
style={{
|
|
position: 'relative',
|
|
width: '100%',
|
|
height: '100%',
|
|
...(!disableCellPadding ? { padding: theme.spacing(1) } : {}),
|
|
...(state.longRunningQuery ? { opacity: '0.3' } : {}),
|
|
}}
|
|
>
|
|
{cellNode && layout && state.sn && (
|
|
<Header
|
|
layout={layout}
|
|
sn={state.sn}
|
|
anchorEl={cellNode}
|
|
hovering={hovering}
|
|
focusHandler={focusHandler.current}
|
|
>
|
|
|
|
</Header>
|
|
)}
|
|
<Grid
|
|
tabIndex={keyboardNavigation ? 0 : -1}
|
|
onKeyDown={keyboardNavigation ? handleKeyDown : null}
|
|
item
|
|
xs
|
|
style={{
|
|
height: '100%',
|
|
}}
|
|
ref={contentRef}
|
|
>
|
|
{Content}
|
|
</Grid>
|
|
<Footer layout={layout} />
|
|
</Grid>
|
|
{state.longRunningQuery && <LongRunningQuery canCancel={canCancel} canRetry={canRetry} api={longrunning} />}
|
|
</Paper>
|
|
);
|
|
}
|
|
);
|
|
export default Cell;
|