@eeacms/volto-tableau 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,57 +1,89 @@
1
- import React, { useState, useRef } from 'react';
1
+ import React, {
2
+ useEffect,
3
+ useImperativeHandle,
4
+ useState,
5
+ useRef,
6
+ useMemo,
7
+ useCallback,
8
+ forwardRef,
9
+ } from 'react';
2
10
  import { connect } from 'react-redux';
3
11
  import { toast } from 'react-toastify';
4
12
  import isEqual from 'lodash/isEqual';
5
- import { Toast } from '@plone/volto/components';
13
+ import isUndefined from 'lodash/isUndefined';
6
14
  import cx from 'classnames';
15
+ import { Button } from 'semantic-ui-react';
16
+ import { Toast, Icon } from '@plone/volto/components';
7
17
  import { useTableau } from '@eeacms/volto-tableau/hooks';
18
+ import JsonCodeSnippet from '@eeacms/volto-tableau/Utils/JsonCodeSnippet/JsonCodeSnippet';
8
19
  import Sources from '@eeacms/volto-tableau/Utils/Sources/Sources';
9
20
  import Download from '@eeacms/volto-tableau/Utils/Download/Download';
10
21
  import Share from '@eeacms/volto-tableau/Utils/Share/Share';
22
+ import { getSheetnames, getActiveSheetname, getDevice } from './helpers';
11
23
 
12
- const TableauDebug = ({ mode, vizState, url, version, sheetSize }) => {
24
+ import resetSVG from '@plone/volto/icons/reset.svg';
25
+
26
+ const TableauDebug = ({ mode, data, vizState, url, version, clearData }) => {
13
27
  const { loaded, error } = vizState;
28
+ const { filters = {}, parameters = {} } = data;
14
29
 
15
30
  const showTableauInfo = mode === 'edit' && (!url || (loaded && url) || error);
16
31
 
17
- return showTableauInfo ? (
18
- <div
19
- className="tableau-debug"
20
- style={{ ...(sheetSize.width ? { width: sheetSize.width } : {}) }}
21
- >
22
- {!url ? <p className="tableau-error">URL required</p> : ''}
23
- {vizState.loaded && url ? (
32
+ if (!showTableauInfo) return null;
33
+
34
+ return (
35
+ <div className="tableau-debug">
36
+ {!url && !vizState.error && <p className="tableau-error">URL required</p>}
37
+ {vizState.error && <p className="tableau-error">{vizState.error}</p>}
38
+ {vizState.loaded && url && (
24
39
  <h3 className="tableau-version">
25
40
  Tableau <span className="version">{version}</span>
26
41
  </h3>
27
- ) : null}
28
- {vizState.error ? <p className="tableau-error">{vizState.error}</p> : ''}
42
+ )}
43
+
44
+ {vizState.loaded && url && (
45
+ <>
46
+ <p>
47
+ Apply filters / parameters inside of tableau to set default static
48
+ filters / parameters.
49
+ </p>
50
+ <p className="important-note">
51
+ Click{' '}
52
+ <button className="clear-filter-button" onClick={clearData}>
53
+ here
54
+ </button>{' '}
55
+ to reset applied filters / parameters.
56
+ </p>
57
+ <code className="json-snippet">
58
+ <JsonCodeSnippet obj={{ filters, parameters }} />
59
+ </code>
60
+ </>
61
+ )}
29
62
  </div>
30
- ) : null;
63
+ );
31
64
  };
32
65
 
33
- const Tableau = (props) => {
34
- const filters = useRef(props.data.filters || {});
66
+ const Tableau = forwardRef((props, ref) => {
35
67
  const vizEl = useRef(null);
36
68
  const viz = useRef();
37
69
  const vizState = useRef({});
70
+ const dataRef = useRef(props.data || {});
71
+ const [initiateViz, setInitiateViz] = useState(false);
38
72
  const [loaded, setLoaded] = useState(false);
39
73
  const [loading, setLoading] = useState(false);
40
74
  const [error, setError] = useState(null);
41
- const [sheetSize, setSheetSize] = useState({});
42
75
  const {
43
- canUpdateUrl = true,
76
+ block,
44
77
  data = {},
78
+ breakpoints = {},
45
79
  extraFilters = {},
46
80
  extraOptions = {},
47
81
  mode = 'view',
48
82
  screen = {},
49
- version = '2.9.1',
50
- with_sources,
51
- with_download,
52
- with_share,
83
+ version = '2.8.0',
53
84
  sources,
54
- // noSizeUpdate = false,
85
+ setVizState,
86
+ onChangeBlock,
55
87
  } = props;
56
88
  const {
57
89
  autoScale = false,
@@ -59,39 +91,121 @@ const Tableau = (props) => {
59
91
  hideToolbar = false,
60
92
  sheetname = '',
61
93
  toolbarPosition = 'Top',
94
+ breakpointUrls = [],
95
+ with_sources,
96
+ with_download,
97
+ with_share,
62
98
  } = data;
63
- const defaultUrl = data.url;
64
- const url = props.url || defaultUrl;
99
+
100
+ const device = useMemo(
101
+ () => getDevice(breakpoints, screen.page?.width || Infinity),
102
+ [breakpoints, screen],
103
+ );
104
+
105
+ const breakpointUrl = useMemo(
106
+ () =>
107
+ breakpointUrls.filter((breakpoint) => breakpoint.device === device)[0]
108
+ ?.url,
109
+ [breakpointUrls, device],
110
+ );
111
+
112
+ const url = breakpointUrl || data.url;
65
113
 
66
114
  // Load tableau from script tag
67
115
  const tableau = useTableau(version);
68
116
 
69
117
  const onFilterChange = (filter) => {
70
- const newFilters = { ...filters.current };
118
+ let value;
119
+ const filters = dataRef.current.filters;
120
+ const newFilters = { ...filters };
71
121
  const fieldName = filter.getFieldName();
72
- const values = filter
73
- .getAppliedValues()
74
- .map((appliedValue) => appliedValue.value);
75
- newFilters[fieldName] = values;
122
+ const filterType = filter.getFilterType();
123
+ switch (filterType) {
124
+ // https://community.tableau.com/s/idea/0874T000000HA8hQAG/detail
125
+ // Only categorical filters are allowed
126
+ case tableau.FilterType.CATEGORICAL:
127
+ const isAllSelected = filter.getIsAllSelected();
128
+ if (!isAllSelected) {
129
+ value = filter
130
+ .getAppliedValues()
131
+ .map((appliedValue) => appliedValue.value);
132
+ }
133
+ break;
134
+ case tableau.FilterType.QUANTITATIVE:
135
+ break;
136
+ case tableau.FilterType.HIERARCHICAL:
137
+ break;
138
+ case tableau.FilterType.RELATIVE_DATE:
139
+ break;
140
+ default:
141
+ break;
142
+ }
143
+ if (isUndefined(value) && !isUndefined(newFilters[fieldName])) {
144
+ delete newFilters[fieldName];
145
+ } else if (!isUndefined(value)) {
146
+ newFilters[fieldName] = value;
147
+ }
76
148
  if (!isEqual(newFilters, filters)) {
77
- props.onChangeBlock(props.block, {
78
- ...data,
149
+ onChangeBlock(block, {
150
+ ...dataRef.current,
79
151
  filters: {
80
152
  ...newFilters,
81
153
  },
82
154
  });
83
- filters.current = { ...newFilters };
84
155
  }
85
156
  };
86
157
 
87
- const onVizStateUpdate = (loaded, loading, error) => {
158
+ const onParameterChange = (parameter) => {
159
+ const parameters = dataRef.current.parameters;
160
+ const newParameters = { ...parameters };
161
+ const fieldName = parameter.getName();
162
+ const value = parameter.getCurrentValue()?.value;
163
+ if (isUndefined(value) && !isUndefined(newParameters[fieldName])) {
164
+ delete newParameters[fieldName];
165
+ } else {
166
+ newParameters[fieldName] = [value];
167
+ }
168
+ if (!isEqual(newParameters, parameters)) {
169
+ onChangeBlock(block, {
170
+ ...dataRef.current,
171
+ parameters: {
172
+ ...newParameters,
173
+ },
174
+ });
175
+ }
176
+ };
177
+
178
+ const onVizStateUpdate = useCallback((loaded, loading, error) => {
88
179
  vizState.current = { ...vizState.current, loaded, loading, error };
89
180
  setLoaded(loaded);
90
181
  setLoading(loading);
91
182
  setError(error);
92
- if (props.setVizState) {
93
- props.setVizState({ loaded, loading, error });
183
+ if (setVizState) {
184
+ setVizState({ loaded, loading, error });
185
+ }
186
+ /* eslint-disable-next-line */
187
+ }, []);
188
+
189
+ const activateDefaultSheet = useCallback(() => {
190
+ if (!vizState.current.loaded || !viz.current) return;
191
+ const workbook = viz.current.getWorkbook();
192
+ const sheetnames = getSheetnames(viz.current);
193
+ const activeSheetName = getActiveSheetname(viz.current);
194
+ if (sheetnames.includes(sheetname) && sheetname !== activeSheetName) {
195
+ workbook.activateSheetAsync(sheetname).then(() => {
196
+ onVizStateUpdate(true, false, null);
197
+ });
198
+ } else {
199
+ onVizStateUpdate(true, false, null);
94
200
  }
201
+ }, [onVizStateUpdate, sheetname]);
202
+
203
+ const clearData = () => {
204
+ onChangeBlock(block, {
205
+ ...data,
206
+ filters: {},
207
+ parameters: {},
208
+ });
95
209
  };
96
210
 
97
211
  const disposeViz = () => {
@@ -103,28 +217,34 @@ const Tableau = (props) => {
103
217
  };
104
218
 
105
219
  const initViz = () => {
106
- disposeViz();
107
220
  try {
108
221
  onVizStateUpdate(false, true, vizState.current.error);
109
- setSheetSize({});
110
- viz.current = new tableau.Viz(vizEl.current, url || defaultUrl, {
222
+ viz.current = new tableau.Viz(vizEl.current, url, {
111
223
  hideTabs,
112
224
  hideToolbar,
113
- sheetname,
114
225
  toolbarPosition,
226
+ device: !!breakpointUrl ? device : 'desktop',
115
227
  ...data.filters,
228
+ ...data.parameters,
116
229
  ...extraFilters,
117
230
  ...extraOptions,
118
231
  onFirstInteractive: () => {
119
- onVizStateUpdate(true, false, null);
120
- if (viz.current && mode === 'edit') {
121
- const workbook = viz.current.getWorkbook();
232
+ onVizStateUpdate(true, true, null);
233
+ setInitiateViz(false);
234
+ activateDefaultSheet();
235
+ if (viz.current && mode === 'edit' && !breakpointUrl) {
236
+ const sheetnames = getSheetnames(viz.current);
237
+ const activeSheetname = getActiveSheetname(viz.current);
238
+ const vizUrl = viz.current.getUrl();
122
239
  const newData = {
123
- url: canUpdateUrl ? viz.current.getUrl() : defaultUrl,
124
- sheetname: workbook.getActiveSheet().getName(),
240
+ ...data,
125
241
  };
242
+ newData.url = vizUrl;
243
+ if (!sheetname || !sheetnames.includes(sheetname)) {
244
+ newData.sheetname = activeSheetname;
245
+ }
126
246
  if (newData.url !== url || newData.sheetname !== sheetname) {
127
- props.onChangeBlock(props.block, {
247
+ onChangeBlock(block, {
128
248
  ...data,
129
249
  ...newData,
130
250
  });
@@ -132,6 +252,8 @@ const Tableau = (props) => {
132
252
  <Toast success title={'Tableau data updated'} content={null} />,
133
253
  );
134
254
  }
255
+ }
256
+ if (viz.current && mode === 'edit') {
135
257
  // Filter change event
136
258
  viz.current.addEventListener(
137
259
  tableau.TableauEventName.FILTER_CHANGE,
@@ -141,17 +263,21 @@ const Tableau = (props) => {
141
263
  });
142
264
  },
143
265
  );
266
+ // Parameter change event
267
+ viz.current.addEventListener(
268
+ tableau.TableauEventName.PARAMETER_VALUE_CHANGE,
269
+ (event) => {
270
+ event.getParameterAsync().then((parameter) => {
271
+ onParameterChange(parameter);
272
+ });
273
+ },
274
+ );
144
275
  }
145
276
  },
146
- // onFirstVizSizeKnown: (e) => {
147
- // if (!noSizeUpdate) {
148
- // setSheetSize(e.$2.sheetSize.maxSize);
149
- // }
150
- // },
151
277
  });
152
278
  } catch (e) {
153
- onVizStateUpdate(false, false, e._message);
154
- setSheetSize({});
279
+ onVizStateUpdate(false, false, e.get_message());
280
+ setInitiateViz(false);
155
281
  }
156
282
  };
157
283
 
@@ -177,87 +303,128 @@ const Tableau = (props) => {
177
303
  };
178
304
 
179
305
  const updateScale = () => {
180
- const tableauEl = vizEl.current;
181
- const tableau = tableauEl.querySelector('iframe');
306
+ const tableauEl = vizEl.current.querySelector('iframe');
182
307
  const { sheetSize = {} } = viz.current.getVizSize() || {};
183
308
  const vizWidth = sheetSize?.minSize?.width || 1;
184
- const vizHeight = sheetSize?.minSize?.height || 0;
185
- const scale = Math.min(tableauEl.clientWidth / vizWidth, 1);
186
- tableau.style.transform = `scale(${scale})`;
187
- tableau.style.width = `${100 / scale}%`;
188
- tableauEl.style.height = `${scale * vizHeight}px`;
309
+ const scale = Math.min(vizEl.current.clientWidth / vizWidth, 1);
310
+ tableauEl.style.transform = `scale(${scale})`;
311
+ window.requestAnimationFrame(() => {
312
+ if (vizEl.current) {
313
+ vizEl.current.style.height = `${scale * tableauEl.clientHeight}px`;
314
+ }
315
+ });
189
316
  };
190
317
 
191
- React.useEffect(() => {
192
- if (tableau && url) {
193
- initViz();
318
+ // Update refs
319
+ useEffect(() => {
320
+ dataRef.current = data;
321
+ }, [data]);
322
+
323
+ useEffect(() => {
324
+ if (!tableau) return;
325
+ if (url) {
326
+ disposeViz();
327
+ setInitiateViz(true);
194
328
  } else {
195
329
  disposeViz();
196
330
  }
331
+ /* eslint-disable-next-line */
332
+ }, [tableau, url, hideTabs, hideToolbar, toolbarPosition]);
197
333
 
198
- return () => {
199
- disposeViz();
200
- };
334
+ useEffect(() => {
335
+ if (initiateViz && !loaded && !loading) {
336
+ initViz();
337
+ }
201
338
  /* eslint-disable-next-line */
202
- }, [
203
- hideTabs,
204
- hideToolbar,
205
- autoScale,
206
- sheetname,
207
- tableau,
208
- toolbarPosition,
209
- url,
210
- ]);
339
+ }, [loaded, loading, initiateViz]);
211
340
 
212
- React.useEffect(() => {
341
+ useEffect(() => {
213
342
  if (vizState.current.loaded && viz.current) {
214
343
  addExtraFilters(extraFilters);
215
344
  }
216
345
  /* eslint-disable-next-line */
217
346
  }, [JSON.stringify(extraFilters)]);
218
347
 
219
- React.useEffect(() => {
348
+ useEffect(() => {
220
349
  if (vizState.current.loaded && viz.current && autoScale) {
221
350
  updateScale();
222
351
  }
223
352
  /* eslint-disable-next-line */
224
353
  }, [loaded, screen?.page?.width]);
225
354
 
355
+ useEffect(() => {
356
+ if (vizState.current.loaded && viz.current) {
357
+ onVizStateUpdate(true, true, null);
358
+ activateDefaultSheet();
359
+ }
360
+ /* eslint-disable-next-line */
361
+ }, [sheetname]);
362
+
363
+ useImperativeHandle(
364
+ ref,
365
+ () => {
366
+ if (loaded) {
367
+ return viz.current;
368
+ }
369
+ if (loading) {
370
+ return null;
371
+ }
372
+ return;
373
+ },
374
+ [loaded, loading],
375
+ );
376
+
226
377
  return (
227
378
  <div className="tableau-wrapper">
228
379
  {loading && (
229
- <div
230
- className="tableau-loader"
231
- style={{ ...(sheetSize.width ? { width: sheetSize.width } : {}) }}
232
- >
380
+ <div className="tableau-loader">
233
381
  <span>Loading...</span>
234
382
  </div>
235
383
  )}
384
+ {!loading && mode === 'edit' && (
385
+ <Button
386
+ className="reload-tableau"
387
+ icon
388
+ secondary
389
+ compact
390
+ onClick={() => {
391
+ if (tableau && url) {
392
+ disposeViz();
393
+ setInitiateViz(true);
394
+ }
395
+ }}
396
+ >
397
+ <Icon name={resetSVG} size="24px" />
398
+ </Button>
399
+ )}
236
400
  <TableauDebug
237
401
  mode={props.mode}
402
+ data={data}
238
403
  vizState={{ loaded, loading, error }}
239
404
  url={url}
240
405
  version={version}
241
- sheetSize={sheetSize}
406
+ clearData={clearData}
242
407
  />
243
408
  <div
244
409
  className={cx('tableau', `tableau-${version}`, {
245
- 'tableau-scale': autoScale,
410
+ 'tableau-autoscale': autoScale,
246
411
  })}
247
412
  ref={vizEl}
248
413
  />
249
- <div
250
- className="tableau-info"
251
- style={{ ...(sheetSize.width ? { width: sheetSize.width } : {}) }}
252
- >
414
+ <div className="tableau-info">
253
415
  {with_sources && loaded && <Sources sources={sources} />}
254
416
  {with_download && loaded && <Download viz={viz.current} />}
255
417
  {with_share && loaded && <Share viz={viz.current} />}
256
418
  </div>
257
419
  </div>
258
420
  );
259
- };
421
+ });
260
422
 
261
- export default connect((state) => ({
262
- screen: state.screen,
263
- }))(Tableau);
423
+ export default connect(
424
+ (state) => ({
425
+ screen: state.screen,
426
+ }),
427
+ null,
428
+ null,
429
+ { forwardRef: true },
430
+ )(Tableau);
@@ -0,0 +1,41 @@
1
+ import isUndefined from 'lodash/isUndefined';
2
+
3
+ export const getSheetnames = (viz) => {
4
+ if (!viz) return [];
5
+ let sheetnames = [];
6
+ const workbook = viz.getWorkbook();
7
+ workbook.getPublishedSheetsInfo().forEach((sheet) => {
8
+ const sheetName = sheet.getName();
9
+ sheetnames.push(sheetName);
10
+ });
11
+ return sheetnames;
12
+ };
13
+
14
+ export const getSheetnamesChoices = (viz) => {
15
+ return getSheetnames(viz).map((sheet) => [sheet, sheet]);
16
+ };
17
+
18
+ export const getActiveSheetname = (viz) => {
19
+ const workbook = viz.getWorkbook();
20
+ return workbook.getActiveSheet().getName();
21
+ };
22
+
23
+ export const canChangeVizData = (viz, vizState) => {
24
+ // If viz is null it means that the viz is loading
25
+ // If viz is undefined it means that there is no viz nor it is loading
26
+ if (vizState?.loading) return false;
27
+ return !!viz || isUndefined(viz);
28
+ };
29
+
30
+ export const getDevice = (breakpoints, width) => {
31
+ let device = 'desktop';
32
+ Object.keys(breakpoints).forEach((breakpoint) => {
33
+ if (
34
+ width <= breakpoints[breakpoint][0] &&
35
+ width >= breakpoints[breakpoint][1]
36
+ ) {
37
+ device = breakpoint;
38
+ }
39
+ });
40
+ return device;
41
+ };
@@ -30,7 +30,7 @@ const Download = ({ viz }) => {
30
30
  ref={popupRef}
31
31
  >
32
32
  <Button
33
- color="primary"
33
+ className="primary"
34
34
  onClick={() => {
35
35
  viz.showExportImageDialog();
36
36
  popupRef.current.triggerRef.current.click();
@@ -39,7 +39,7 @@ const Download = ({ viz }) => {
39
39
  Image
40
40
  </Button>
41
41
  <Button
42
- color="primary"
42
+ className="primary"
43
43
  onClick={() => {
44
44
  viz.showExportPDFDialog();
45
45
  popupRef.current.triggerRef.current.click();
@@ -48,7 +48,7 @@ const Download = ({ viz }) => {
48
48
  PDF
49
49
  </Button>
50
50
  <Button
51
- color="primary"
51
+ className="primary"
52
52
  onClick={() => {
53
53
  viz.showExportCrossTabDialog();
54
54
  popupRef.current.triggerRef.current.click();
@@ -57,7 +57,7 @@ const Download = ({ viz }) => {
57
57
  CSV
58
58
  </Button>
59
59
  <Button
60
- color="primary"
60
+ className="primary"
61
61
  onClick={() => {
62
62
  viz.exportCrossTabToExcel();
63
63
  popupRef.current.triggerRef.current.click();
@@ -0,0 +1,48 @@
1
+ import React from 'react';
2
+ import isObject from 'lodash/isObject';
3
+ import isArray from 'lodash/isArray';
4
+ import isString from 'lodash/isString';
5
+ import isBoolean from 'lodash/isBoolean';
6
+ import isNull from 'lodash/isNull';
7
+ import isUndefined from 'lodash/isUndefined';
8
+
9
+ const JsonCodeSnippet = ({ obj, depth = 1 }) => {
10
+ const keys = Object.keys(obj);
11
+ return (
12
+ <>
13
+ <span className="left-curly-brace">&#123;</span>
14
+ {!!keys.length && <br />}
15
+ {keys.map((key) => {
16
+ const value = obj[key];
17
+
18
+ return (
19
+ <React.Fragment key={`${key}-depth`}>
20
+ <span className="key" style={{ marginLeft: `${depth}rem` }}>
21
+ "{key}":
22
+ </span>
23
+ <span className="value">
24
+ &nbsp;
25
+ {isObject(value) && !isArray(value) && (
26
+ <JsonCodeSnippet obj={value} depth={depth + 1} />
27
+ )}
28
+ {isArray(value) && JSON.stringify(value)}
29
+ {isString(value) && <>"{value}"</>}
30
+ {isBoolean(value) && <>{value}</>}
31
+ {isNull(value) && <>null</>}
32
+ {isUndefined(value) && <>undefined</>}
33
+ </span>
34
+ <br />
35
+ </React.Fragment>
36
+ );
37
+ })}
38
+ <span
39
+ className="right-curly-brace"
40
+ style={{ marginLeft: !!keys.length ? `${depth - 1}rem` : 0 }}
41
+ >
42
+ &#125;
43
+ </span>
44
+ </>
45
+ );
46
+ };
47
+
48
+ export default JsonCodeSnippet;
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Container } from 'semantic-ui-react';
3
+ import config from '@plone/volto/registry';
3
4
  import Tableau from '@eeacms/volto-tableau/Tableau/Tableau';
4
5
 
5
6
  const VisualizationView = (props) => {
@@ -9,11 +10,16 @@ const VisualizationView = (props) => {
9
10
  return (
10
11
  <Container id="page-document">
11
12
  <Tableau
12
- data={tableau_visualization}
13
- with_sources={true}
14
- with_download={true}
15
- with_share={true}
13
+ data={{
14
+ ...tableau_visualization,
15
+ with_sources: true,
16
+ with_download: true,
17
+ with_share: true,
18
+ }}
16
19
  sources={data_provenance.data || []}
20
+ breakpoints={
21
+ config.blocks.blocksConfig.embed_tableau_visualization.breakpoints
22
+ }
17
23
  />
18
24
  </Container>
19
25
  );