feat: better incomplete view (#453)

Co-authored-by: Miralem Drek <mek@qlik.com>
This commit is contained in:
Christoffer Åström
2020-06-29 08:04:00 +02:00
committed by GitHub
parent c8a996b2d5
commit e9d11e9887
22 changed files with 332 additions and 86 deletions

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "Unvollständige Visualisierung",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -83,8 +83,32 @@
"value": "Select possible",
"comment": "Action text to select possible selections"
},
"Supernova.Incomplete": {
"Visualization.LayoutError": {
"value": "Error",
"comment": "Status text shown when a visualization has layout errors"
},
"Visualization.Incomplete": {
"value": "Incomplete visualization",
"comment": "Status text shown when a visualization is incomplete"
},
"Visualization.Incomplete.Dimensions": {
"value": "{0} of {1} dimensions",
"comment": "Text showing the number of current {0} dimensions over the total {1} number of dimensions"
},
"Visualization.Incomplete.Measures": {
"value": "{0} of {1} measures",
"comment": "Text showing the number of current {0} measures over the total {1} number of measures"
},
"Visualization.Invalid.Dimension": {
"value": "Invalid dimension",
"comment": "Status text shown when a dimension is not valid"
},
"Visualization.UnfulfilledCalculationCondition": {
"value": "The calculation condition is not fulfilled",
"comment": "Message displayed when a calculation condition is not fulfilled"
},
"Visualization.Invalid.Measure": {
"value": "Invalid measure",
"comment": "Status text shown when a measure is not valid"
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "Visualización incompleta",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "Visualisation incomplète",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "Visualizzazione incompleta",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "未完了のビジュアライゼーション",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "완료되지 않은 시각화",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "Onvolledige visualisatie",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "Niekompletna wizualizacja",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "Visualização incompleta",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "Незавершенная визуализация",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "Ofullständig visualisering",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "Tamamlanmamış görselleştirme",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "不完整的可视化",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -104,9 +104,9 @@
"comment": "Action text to select possible selections",
"version": "UYEx1K96g9h4HCK3GH/6kg=="
},
"Supernova.Incomplete": {
"Visualization.Incomplete": {
"value": "視覺化未完成",
"comment": "Status text shown when a visualization is incomplete",
"version": "SOyzXWKRgL1+6AqW/QkZOA=="
}
}
}

View File

@@ -114,26 +114,112 @@ const handleModal = ({ sn, layout, model }) => {
const filterData = (d) => (d.qError ? d.qError.qErrorCode === 7005 : true);
const validateTargets = (translator, layout, { targets }) => {
const validateInfo = (min, info, getDescription, translatedError, translatedCalcCond) => {
return [...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 description = `${getDescription(i)}${label.length ? delimiter : ''}`;
return {
description,
label,
missing: (info && !exists && !error && i >= info.length) || softError,
error,
};
});
};
const validateTarget = (translator, layout, properties, def) => {
const minD = def.dimensions.min();
const minM = def.measures.min();
const hc = def.resolveLayout(layout);
const reqDimErrors = validateInfo(
minD,
hc.qDimensionInfo,
(i) => def.dimensions.description(properties, i),
translator.get('Visualization.Invalid.Dimension'),
translator.get('Visualization.UnfulfilledCalculationCondition')
);
const reqMeasErrors = validateInfo(
minM,
hc.qMeasureInfo,
(i) => def.measures.description(properties, i),
translator.get('Visualization.Invalid.Measure'),
translator.get('Visualization.UnfulfilledCalculationCondition')
);
return {
reqDimErrors,
reqMeasErrors,
};
};
const validateTargets = async (translator, layout, { targets }, model) => {
const layoutErrors = [];
const requirementsError = [];
targets.forEach((def) => {
// Use a flattened requirements structure to combine all targets
const allRequirements = {
hasErrors: false,
d: {
title: '',
descriptions: [],
min: 0,
},
m: {
title: '',
descriptions: [],
min: 0,
},
};
let loopCacheProperties = null;
for (let i = 0; i < targets.length; ++i) {
const def = targets[i];
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 d = (hc.qDimensionInfo || []).filter(filterData); // Filter out optional calc conditions
const m = (hc.qMeasureInfo || []).filter(filterData); // Filter out optional calc conditions
const path = def.layoutPath;
// layout error
if (hc.qError) {
layoutErrors.push({ path, error: hc.qError });
layoutErrors.push({ title: path, descriptions: [{ message: hc.qError }] });
}
if (d.length < minD || m.length < minM) {
requirementsError.push({ path });
allRequirements.hasErrors = true;
allRequirements.d.min += minD;
allRequirements.m.min += minM;
// eslint-disable-next-line no-await-in-loop
const properties = loopCacheProperties || (await model.getProperties());
loopCacheProperties = properties;
const res = validateTarget(translator, layout, properties, def);
allRequirements.d.descriptions.push(...res.reqDimErrors);
allRequirements.m.descriptions.push(...res.reqMeasErrors);
}
});
const showError = !!(layoutErrors.length || requirementsError.length);
const title = requirementsError.length ? translator.get('Supernova.Incomplete') : 'Error';
const data = requirementsError.length ? requirementsError : layoutErrors;
}
const fulfilledDims = allRequirements.d.descriptions.filter((e) => !(e.missing || e.error)).length;
allRequirements.d.title = translator.get('Visualization.Incomplete.Dimensions', [
fulfilledDims,
allRequirements.d.min,
]);
const fulfilledMeas = allRequirements.m.descriptions.filter((e) => !(e.missing || e.error)).length;
allRequirements.m.title = translator.get('Visualization.Incomplete.Measures', [fulfilledMeas, allRequirements.m.min]);
const showError = !!(layoutErrors.length || allRequirements.hasErrors);
const title = allRequirements.hasErrors
? translator.get('Visualization.Incomplete')
: translator.get('Visualization.LayoutError');
const data = allRequirements.hasErrors ? [allRequirements.d, allRequirements.m] : layoutErrors;
return [showError, { title, data }];
};
@@ -207,8 +293,8 @@ const Cell = forwardRef(({ halo, model, initialSnOptions, initialError, onMount
if (initialError || !appLayout) {
return undefined;
}
const validate = (sn) => {
const [showError, error] = validateTargets(translator, layout, sn.generator.qae.data);
const validate = async (sn) => {
const [showError, error] = await validateTargets(translator, layout, sn.generator.qae.data, model);
if (showError) {
dispatch({ type: 'ERROR', error });
} else {

View File

@@ -1,34 +1,82 @@
/* eslint-disable react/no-array-index-key */
import React from 'react';
import { makeStyles, Grid, Typography } from '@material-ui/core';
import { Grid, Typography, IconButton } from '@material-ui/core';
import WarningTriangle from '@nebula.js/ui/icons/warning-triangle-2';
import Tick from '@nebula.js/ui/icons/tick';
import { useTheme } from '@nebula.js/ui/theme';
const useStyles = makeStyles(() => ({
contentError: {
'&::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 DescriptionRow = ({ d }) => {
const theme = useTheme();
let color = 'inherit';
let styleColor = theme.palette.success.main;
if (d.missing) {
styleColor = theme.palette.warning.main;
} else if (d.error) {
color = 'error';
styleColor = theme.palette.error.main;
}
const style = { color: styleColor };
const Icon = (
<IconButton>{d.missing || d.error ? <WarningTriangle style={style} /> : <Tick style={style} />}</IconButton>
);
return (
<Grid item container alignItems="center" wrap="nowrap">
<Grid item>{Icon}</Grid>
<Grid container item zeroMinWidth wrap="nowrap">
<Typography noWrap component="p">
<Typography component="span" variant="subtitle2" color={color}>
{d.description}
</Typography>
<Typography component="span"> </Typography>
<Typography
component="span"
variant="subtitle2"
color={d.error ? 'error' : 'inherit'}
style={{ fontWeight: 400 }}
>
{d.label}
</Typography>
</Typography>
</Grid>
</Grid>
);
};
const Descriptions = ({ data }) => {
const theme = useTheme();
return (
<Grid item style={{ maxWidth: '80%', overflow: 'hidden' }}>
{data.map((e, ix) => {
const Rows = e.descriptions.map((d, dix) => <DescriptionRow d={d} key={dix} />);
return (
<Grid
container
item
key={ix}
direction="column"
style={{
paddingBottom: `${theme.spacing(2)}px`,
}}
>
<Typography noWrap key={ix} variant="subtitle1" align="left" color="textSecondary">
{e.title}
</Typography>
{Rows}
</Grid>
);
})}
</Grid>
);
};
export default function Error({ title = 'Error', message = '', data = [] }) {
const { contentError } = useStyles();
return (
<Grid
container
direction="column"
alignItems="center"
justify="center"
className={contentError}
style={{ position: 'relative', height: '100%' }}
style={{ position: 'relative', height: '100%', width: '100%' }}
spacing={1}
>
<Grid item>
@@ -44,14 +92,9 @@ export default function Error({ title = 'Error', message = '', data = [] }) {
{message}
</Typography>
</Grid>
<Grid item>
{data.map((d, ix) => (
// eslint-disable-next-line react/no-array-index-key
<Typography key={ix} variant="subtitle2" align="center">
{d.path} {d.error && '-'} {d.error && d.error.qErrorCode}
</Typography>
))}
</Grid>
<Descriptions data={data} />
</Grid>
);
}
export { Descriptions, DescriptionRow };

View File

@@ -224,20 +224,26 @@ describe('<Cell />', () => {
});
it('should render requirements', async () => {
const localLayout = { visualization: 'sn', foo: { qDimensionInfo: [], qMeasureInfo: [] } };
const sn = {
generator: {
qae: {
data: {
targets: [
{
resolveLayout: () => '/foo',
resolveLayout: () => localLayout.foo,
dimensions: {
min: () => 1,
max: () => 1,
description: (_properties, ix) =>
ix === 0
? 'Column'
: 'Cells - dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd ',
},
measures: {
min: () => 1,
max: () => 1,
description: () => 'Size',
},
},
],
@@ -251,11 +257,14 @@ describe('<Cell />', () => {
}),
getSupportedVersion: sandbox.stub().returns('1.0.0'),
};
await render({ types });
const model = {
getProperties: async () => {},
};
await render({ types, model });
const ftypes = renderer.root.findAllByType(CError);
expect(ftypes).to.have.length(1);
expect(ftypes[0].props.title).to.equal('Supernova.Incomplete');
expect(ftypes[0].props.title).to.equal('Visualization.Incomplete');
});
it('should render hypercube error', async () => {
@@ -271,10 +280,15 @@ describe('<Cell />', () => {
dimensions: {
min: () => 0,
max: () => 0,
description: (_properties, ix) =>
ix === 0
? 'Column'
: 'Cells - dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd dkslfjd ',
},
measures: {
min: () => 0,
max: () => 0,
description: () => 'Size',
},
},
],
@@ -292,9 +306,8 @@ describe('<Cell />', () => {
const ftypes = renderer.root.findAllByType(CError);
expect(ftypes).to.have.length(1);
expect(ftypes[0].props.title).to.equal('Error');
expect(ftypes[0].props.data[0].path).to.equal('/foo');
expect(ftypes[0].props.data[0].error).to.deep.equal({ qErrorCode: 1337 });
expect(ftypes[0].props.data[0].title).to.equal('/foo');
expect(ftypes[0].props.data[0].descriptions[0].message).to.deep.equal({ qErrorCode: 1337 });
});
it('should go modal (selections)', async () => {

View File

@@ -1,7 +1,33 @@
import React from 'react';
import { create, act } from 'react-test-renderer';
const [{ default: Error }] = aw.mock([], ['../Error']);
import WarningTriangle from '@nebula.js/ui/icons/warning-triangle-2';
import Tick from '@nebula.js/ui/icons/tick';
const [{ default: Error, Descriptions, DescriptionRow }] = aw.mock(
[
[
require.resolve('@nebula.js/ui/theme'),
() => ({
useTheme: () => ({
spacing: () => 0,
palette: {
success: {
main: 'success',
},
warning: {
maing: 'warning',
},
error: {
main: 'error',
},
},
}),
}),
],
],
['../Error']
);
describe('<Error />', () => {
let sandbox;
@@ -32,18 +58,40 @@ describe('<Error />', () => {
});
it('should render error', async () => {
await render('foo', 'bar', [{ path: 'baz', error: { qErrorCode: 1337 } }]);
await render('foo', 'bar', [{ title: 'foo', descriptions: [] }]);
const title = renderer.root.find((el) => {
return el.props['data-tid'] === 'error-title';
});
expect(title.props.children).to.equal('foo');
const message = renderer.root.find((el) => {
const msg = renderer.root.find((el) => {
return el.props['data-tid'] === 'error-message';
});
expect(message.props.children).to.equal('bar');
const data = renderer.root.find((el) => {
return el.props.children && Array.isArray(el.props.children) ? el.props.children[0] === 'baz' : false;
});
expect(data.props).to.deep.equal({ variant: 'subtitle2', align: 'center', children: ['baz', ' ', '-', ' ', 1337] });
expect(msg.props.children).to.equal('bar');
});
it('should render error with descriptions', async () => {
const d = [1, 2, 3, 4, 5, 6].map((n) => ({
description: `d-${n}`,
label: `l-${n}`,
missing: n % 3 === 0,
error: n % 5 === 0,
}));
const dims = {
title: 'Dimensions',
descriptions: d.slice(0, 3),
};
const meas = {
title: 'Measures',
descriptions: d.slice(3),
};
const data = [dims, meas];
await render('foo', 'bar', data);
const list = renderer.root.findByType(Descriptions);
const rows = list.findAllByType(DescriptionRow);
expect(rows).to.have.length(6);
const w = list.findAllByType(WarningTriangle);
const t = list.findAllByType(Tick);
expect(w).to.have.length(3);
expect(t).to.have.length(3);
});
});

View File

@@ -44,7 +44,7 @@ describe('qae', () => {
data: {
targets: [
{
path: 'qhc',
path: '/qHyperCubeDef',
dimensions: {
min: () => 3,
max: () => 7,
@@ -64,7 +64,7 @@ describe('qae', () => {
],
},
}).data.targets[0];
expect(t.propertyPath).to.eql('qhc');
expect(t.propertyPath).to.eql('/qHyperCubeDef');
expect(t.dimensions.min()).to.eql(3);
expect(t.dimensions.max()).to.eql(7);
expect(t.dimensions.added()).to.equal('a');
@@ -79,6 +79,34 @@ describe('qae', () => {
expect(t.dimensions.isDefined()).to.equal(true);
expect(t.measures.isDefined()).to.equal(true);
});
it('should throw with incorrect hypercube def', () => {
expect(() =>
qae({
data: {
targets: [
{
path: 'qhc',
dimensions: {
min: () => 3,
max: () => 7,
added: () => 'a',
description: () => 'Slice',
moved: () => 'c',
replaced: () => 'd',
},
measures: {
min: 2,
max: 4,
added: () => 'b',
description: () => 'Angle',
removed: () => 'e',
},
},
],
},
})
).to.throw('Incorrect definition for qHyperCubeDef at qhc');
});
it('should resolve layout', () => {
const t = qae({
data: {

View File

@@ -66,6 +66,9 @@ const resolveValue = (data, reference, defaultValue) => {
function target(def) {
const propertyPath = def.path || '/qHyperCubeDef';
const layoutPath = propertyPath.slice(0, -3);
if (/\/qHyperCube$/.test(layoutPath) === false) {
throw new Error(`Incorrect definition for qHyperCubeDef at ${propertyPath}`);
}
return {
propertyPath,
layoutPath,

View File

@@ -146,6 +146,7 @@ const renderFixture = async () => {
};
return mockedLayout;
},
getProperties: () => null,
on() {},
once() {},
});