diff --git a/client/app/directives/resize-event.js b/client/app/directives/resize-event.js
index a0c4d5bed0..396cf55b1a 100644
--- a/client/app/directives/resize-event.js
+++ b/client/app/directives/resize-event.js
@@ -1,48 +1,13 @@
-import { findIndex } from 'lodash';
-
-const items = new Map();
-
-function checkItems() {
- items.forEach((item, node) => {
- const bounds = node.getBoundingClientRect();
- // convert to int (because these numbers needed for comparisons), but preserve 1 decimal point
- const width = Math.round(bounds.width * 10);
- const height = Math.round(bounds.height * 10);
-
- if (
- (item.width !== width) ||
- (item.height !== height)
- ) {
- item.width = width;
- item.height = height;
- item.callback(node);
- }
- });
-
- setTimeout(checkItems, 100);
-}
-
-checkItems(); // ensure it was called only once!
+import resizeObserver from '@/services/resizeObserver';
function resizeEvent() {
return {
restrict: 'A',
link($scope, $element, attrs) {
- const node = $element[0];
- if (!items.has(node)) {
- items.set(node, {
- callback: () => {
- $scope.$evalAsync(attrs.resizeEvent);
- },
- });
-
- $scope.$on('$destroy', () => {
- const index = findIndex(items, item => item.node === node);
- if (index >= 0) {
- items.splice(index, 1); // remove item
- }
- });
- }
+ const unwatch = resizeObserver($element[0], () => {
+ $scope.$evalAsync(attrs.resizeEvent);
+ });
+ $scope.$on('$destroy', unwatch);
},
};
}
diff --git a/client/app/lib/hooks/useQueryResult.js b/client/app/lib/hooks/useQueryResult.js
index 6a7179b930..3918dd2fb9 100644
--- a/client/app/lib/hooks/useQueryResult.js
+++ b/client/app/lib/hooks/useQueryResult.js
@@ -2,9 +2,9 @@ import { useState, useEffect } from 'react';
function getQueryResultData(queryResult) {
return {
- columns: queryResult ? queryResult.getColumns() : [],
- rows: queryResult ? queryResult.getData() : [],
- filters: queryResult ? queryResult.getFilters() : [],
+ columns: (queryResult && queryResult.getColumns()) || [],
+ rows: (queryResult && queryResult.getData()) || [],
+ filters: (queryResult && queryResult.getFilters()) || [],
};
}
diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less
index 905bccfe4f..21f2cf2d60 100644
--- a/client/app/pages/dashboards/dashboard.less
+++ b/client/app/pages/dashboards/dashboard.less
@@ -83,6 +83,7 @@
.sunburst-visualization-container,
.sankey-visualization-container,
.map-visualization-container,
+ .word-cloud-visualization-container,
.plotly-chart-container {
position: absolute;
left: 0;
diff --git a/client/app/services/resizeObserver.js b/client/app/services/resizeObserver.js
new file mode 100644
index 0000000000..65c1c8f890
--- /dev/null
+++ b/client/app/services/resizeObserver.js
@@ -0,0 +1,50 @@
+/* global ResizeObserver */
+
+function observeNative(node, callback) {
+ if ((typeof ResizeObserver === 'function') && node) {
+ const observer = new ResizeObserver(() => callback()); // eslint-disable-line compat/compat
+ observer.observe(node);
+ return () => observer.disconnect();
+ }
+ return null;
+}
+
+const items = new Map();
+
+function checkItems() {
+ if (items.size > 0) {
+ items.forEach((item, node) => {
+ const bounds = node.getBoundingClientRect();
+ // convert to int (because these numbers needed for comparisons), but preserve 1 decimal point
+ const width = Math.round(bounds.width * 10);
+ const height = Math.round(bounds.height * 10);
+
+ if (
+ (item.width !== width) ||
+ (item.height !== height)
+ ) {
+ item.width = width;
+ item.height = height;
+ item.callback(node);
+ }
+ });
+
+ setTimeout(checkItems, 100);
+ }
+}
+
+function observeFallback(node, callback) {
+ if (node && !items.has(node)) {
+ const shouldTrigger = items.size === 0;
+ items.set(node, { callback });
+ if (shouldTrigger) {
+ checkItems();
+ }
+ return () => items.delete(node);
+ }
+ return null;
+}
+
+export default function observe(node, callback) {
+ return observeNative(node, callback) || observeFallback(node, callback) || (() => {});
+}
diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx
index 10c370a03e..db2a94384c 100644
--- a/client/app/visualizations/EditVisualizationDialog.jsx
+++ b/client/app/visualizations/EditVisualizationDialog.jsx
@@ -1,4 +1,4 @@
-import { extend, map, findIndex, isEqual } from 'lodash';
+import { extend, map, sortBy, findIndex, isEqual } from 'lodash';
import React, { useState, useMemo } from 'react';
import PropTypes from 'prop-types';
import Modal from 'antd/lib/modal';
@@ -158,7 +158,7 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
onChange={onTypeChanged}
>
{map(
- registeredVisualizations,
+ sortBy(registeredVisualizations, ['type']),
vis => {vis.name},
)}
diff --git a/client/app/visualizations/word-cloud/Editor.jsx b/client/app/visualizations/word-cloud/Editor.jsx
index 3499ca603a..37cee6ab84 100644
--- a/client/app/visualizations/word-cloud/Editor.jsx
+++ b/client/app/visualizations/word-cloud/Editor.jsx
@@ -1,30 +1,101 @@
-import { map } from 'lodash';
+import { map, merge } from 'lodash';
import React from 'react';
import Select from 'antd/lib/select';
+import InputNumber from 'antd/lib/input-number';
+import * as Grid from 'antd/lib/grid';
import { EditorPropTypes } from '@/visualizations';
-const { Option } = Select;
-
export default function Editor({ options, data, onOptionsChange }) {
- const onColumnChanged = (column) => {
- const newOptions = { ...options, column };
- onOptionsChange(newOptions);
+ const optionsChanged = (newOptions) => {
+ onOptionsChange(merge({}, options, newOptions));
};
return (
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ optionsChanged({ wordLengthLimit: { min: value > 0 ? value : null } })}
+ />
+
+
+ optionsChanged({ wordLengthLimit: { max: value > 0 ? value : null } })}
+ />
+
+
+
+
+
+
+
+ optionsChanged({ wordCountLimit: { min: value > 0 ? value : null } })}
+ />
+
+
+ optionsChanged({ wordCountLimit: { max: value > 0 ? value : null } })}
+ />
+
+
+
+
);
}
diff --git a/client/app/visualizations/word-cloud/Renderer.jsx b/client/app/visualizations/word-cloud/Renderer.jsx
new file mode 100644
index 0000000000..cbf6a50cdd
--- /dev/null
+++ b/client/app/visualizations/word-cloud/Renderer.jsx
@@ -0,0 +1,172 @@
+import d3 from 'd3';
+import cloud from 'd3-cloud';
+import { each, filter, map, min, max, sortBy, toString } from 'lodash';
+import React, { useMemo, useState, useEffect } from 'react';
+import resizeObserver from '@/services/resizeObserver';
+import { RendererPropTypes } from '@/visualizations';
+
+import './renderer.less';
+
+function computeWordFrequencies(rows, column) {
+ const result = {};
+
+ each(rows, (row) => {
+ const wordsList = toString(row[column]).split(/\s/g);
+ each(wordsList, (d) => {
+ result[d] = (result[d] || 0) + 1;
+ });
+ });
+
+ return result;
+}
+
+function getWordsWithFrequencies(rows, wordColumn, frequencyColumn) {
+ const result = {};
+
+ each(rows, (row) => {
+ const count = parseFloat(row[frequencyColumn]);
+ if (Number.isFinite(count) && (count > 0)) {
+ const word = toString(row[wordColumn]);
+ result[word] = count;
+ }
+ });
+
+ return result;
+}
+
+function applyLimitsToWords(words, { wordLength, wordCount }) {
+ wordLength.min = Number.isFinite(wordLength.min) ? wordLength.min : null;
+ wordLength.max = Number.isFinite(wordLength.max) ? wordLength.max : null;
+
+ wordCount.min = Number.isFinite(wordCount.min) ? wordCount.min : null;
+ wordCount.max = Number.isFinite(wordCount.max) ? wordCount.max : null;
+
+ return filter(words, ({ text, count }) => {
+ const wordLengthFits = (
+ (!wordLength.min || (text.length >= wordLength.min)) &&
+ (!wordLength.max || (text.length <= wordLength.max))
+ );
+ const wordCountFits = (
+ (!wordCount.min || (count >= wordCount.min)) &&
+ (!wordCount.max || (count <= wordCount.max))
+ );
+ return wordLengthFits && wordCountFits;
+ });
+}
+
+function prepareWords(rows, options) {
+ let result = [];
+
+ if (options.column) {
+ if (options.frequenciesColumn) {
+ result = getWordsWithFrequencies(rows, options.column, options.frequenciesColumn);
+ } else {
+ result = computeWordFrequencies(rows, options.column);
+ }
+ result = sortBy(
+ map(result, (count, text) => ({ text, count })),
+ [({ count }) => -count, ({ text }) => -text.length], // "count" desc, length("text") desc
+ );
+ }
+
+ // Add additional attributes
+ const counts = map(result, item => item.count);
+ const wordSize = d3.scale.linear()
+ .domain([min(counts), max(counts)])
+ .range([10, 100]); // min/max word size
+ const color = d3.scale.category20();
+
+ each(result, (item, index) => {
+ item.size = wordSize(item.count);
+ item.color = color(index);
+ item.angle = index % 2 * 90; // make it stable between renderings
+ });
+
+ return applyLimitsToWords(result, {
+ wordLength: options.wordLengthLimit,
+ wordCount: options.wordCountLimit,
+ });
+}
+
+function scaleElement(node, container) {
+ node.style.transform = null;
+ const { width: nodeWidth, height: nodeHeight } = node.getBoundingClientRect();
+ const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect();
+
+ const scaleX = containerWidth / nodeWidth;
+ const scaleY = containerHeight / nodeHeight;
+
+ node.style.transform = `scale(${Math.min(scaleX, scaleY)})`;
+}
+
+function createLayout() {
+ const fontFamily = window.getComputedStyle(document.body).fontFamily;
+
+ return cloud()
+ // make the area large enough to contain even very long words; word cloud will be placed in the center of the area
+ // TODO: dimensions probably should be larger, but `d3-cloud` has some performance issues related to these values
+ .size([5000, 5000])
+ .padding(3)
+ .font(fontFamily)
+ .rotate(d => d.angle)
+ .fontSize(d => d.size)
+ .random(() => 0.5); // do not place words randomly - use compact layout
+}
+
+function render(container, words) {
+ container = d3.select(container);
+ container.selectAll('*').remove();
+
+ const svg = container.append('svg');
+ const g = svg.append('g');
+ g.selectAll('text')
+ .data(words)
+ .enter()
+ .append('text')
+ .style('font-size', d => `${d.size}px`)
+ .style('font-family', d => d.font)
+ .style('fill', d => d.color)
+ .attr('text-anchor', 'middle')
+ .attr('transform', d => `translate(${[d.x, d.y]}) rotate(${d.rotate})`)
+ .text(d => d.text);
+
+ const svgBounds = svg.node().getBoundingClientRect();
+ const gBounds = g.node().getBoundingClientRect();
+
+ svg.attr('width', Math.ceil(gBounds.width)).attr('height', Math.ceil(gBounds.height));
+ g.attr('transform', `translate(${svgBounds.left - gBounds.left},${svgBounds.top - gBounds.top})`);
+
+ scaleElement(svg.node(), container.node());
+}
+
+export default function Renderer({ data, options }) {
+ const [container, setContainer] = useState(null);
+ const [words, setWords] = useState([]);
+ const layout = useMemo(createLayout, []);
+
+ useEffect(() => {
+ layout.words(prepareWords(data.rows, options)).on('end', w => setWords(w)).start();
+ return () => layout.on('end', null).stop();
+ }, [layout, data, options, setWords]);
+
+ useEffect(() => {
+ if (container) {
+ render(container, words);
+ }
+ }, [container, words]);
+
+ useEffect(() => {
+ if (container) {
+ return resizeObserver(container, () => {
+ const svg = container.querySelector('svg');
+ if (svg) {
+ scaleElement(svg, container);
+ }
+ });
+ }
+ }, [container]);
+
+ return ();
+}
+
+Renderer.propTypes = RendererPropTypes;
diff --git a/client/app/visualizations/word-cloud/index.js b/client/app/visualizations/word-cloud/index.js
index 1f57689bcc..db5765adef 100644
--- a/client/app/visualizations/word-cloud/index.js
+++ b/client/app/visualizations/word-cloud/index.js
@@ -1,145 +1,25 @@
-import d3 from 'd3';
-import cloud from 'd3-cloud';
-import { map, min, max, values } from 'lodash';
-import { angular2react } from 'angular2react';
+import { merge } from 'lodash';
import { registerVisualization } from '@/visualizations';
+import Renderer from './Renderer';
import Editor from './Editor';
-function findWordFrequencies(data, columnName) {
- const wordsHash = {};
-
- data.forEach((row) => {
- const wordsList = row[columnName].toString().split(' ');
- wordsList.forEach((d) => {
- if (d in wordsHash) {
- wordsHash[d] += 1;
- } else {
- wordsHash[d] = 1;
- }
- });
- });
-
- return wordsHash;
-}
-
-// target domain: [t1, t2]
-const MIN_WORD_SIZE = 10;
-const MAX_WORD_SIZE = 100;
-
-function createScale(wordCounts) {
- wordCounts = values(wordCounts);
-
- // source domain: [s1, s2]
- const minCount = min(wordCounts);
- const maxCount = max(wordCounts);
-
- // Edge case - if all words have the same count; just set middle size for all
- if (minCount === maxCount) {
- return () => (MAX_WORD_SIZE + MIN_WORD_SIZE) / 2;
- }
-
- // v is value from source domain:
- // s1 <= v <= s2.
- // We need to fit it target domain:
- // t1 <= v" <= t2
- // 1. offset source value to zero point:
- // v' = v - s1
- // 2. offset source and target domains to zero point:
- // s' = s2 - s1
- // t' = t2 - t1
- // 3. compute fraction:
- // f = v' / s';
- // 0 <= f <= 1
- // 4. map f to target domain:
- // v" = f * t' + t1;
- // t1 <= v" <= t' + t1;
- // t1 <= v" <= t2 (because t' = t2 - t1, so t2 = t' + t1)
-
- const sourceScale = maxCount - minCount;
- const targetScale = MAX_WORD_SIZE - MIN_WORD_SIZE;
-
- return value => ((value - minCount) / sourceScale) * targetScale + MIN_WORD_SIZE;
-}
-
-const WordCloudRenderer = {
- restrict: 'E',
- bindings: {
- data: '<',
- options: '<',
- },
- controller($scope, $element) {
- $element[0].style.display = 'block';
-
- const update = () => {
- const data = this.data.rows;
- const options = this.options;
-
- let wordsHash = {};
- if (options.column) {
- wordsHash = findWordFrequencies(data, options.column);
- }
-
- const scaleValue = createScale(wordsHash);
-
- const wordList = map(wordsHash, (count, key) => ({
- text: key,
- size: scaleValue(count),
- }));
-
- const fill = d3.scale.category20();
- const layout = cloud()
- .size([500, 500])
- .words(wordList)
- .padding(5)
- .rotate(() => Math.floor(Math.random() * 2) * 90)
- .font('Impact')
- .fontSize(d => d.size);
-
- function draw(words) {
- d3.select($element[0]).selectAll('*').remove();
-
- d3.select($element[0])
- .append('svg')
- .attr('width', layout.size()[0])
- .attr('height', layout.size()[1])
- .append('g')
- .attr('transform', `translate(${layout.size()[0] / 2},${layout.size()[1] / 2})`)
- .selectAll('text')
- .data(words)
- .enter()
- .append('text')
- .style('font-size', d => `${d.size}px`)
- .style('font-family', 'Impact')
- .style('fill', (d, i) => fill(i))
- .attr('text-anchor', 'middle')
- .attr('transform', d => `translate(${[d.x, d.y]})rotate(${d.rotate})`)
- .text(d => d.text);
- }
-
- layout.on('end', draw);
-
- layout.start();
- };
-
- $scope.$watch('$ctrl.data', update);
- $scope.$watch('$ctrl.options', update, true);
- },
+const DEFAULT_OPTIONS = {
+ column: '',
+ frequenciesColumn: '',
+ wordLengthLimit: { min: null, max: null },
+ wordCountLimit: { min: null, max: null },
};
-export default function init(ngModule) {
- ngModule.component('wordCloudRenderer', WordCloudRenderer);
-
- ngModule.run(($injector) => {
- registerVisualization({
- type: 'WORD_CLOUD',
- name: 'Word Cloud',
- getOptions: options => ({ ...options }),
- Renderer: angular2react('wordCloudRenderer', WordCloudRenderer, $injector),
- Editor,
+export default function init() {
+ registerVisualization({
+ type: 'WORD_CLOUD',
+ name: 'Word Cloud',
+ getOptions: options => merge({}, DEFAULT_OPTIONS, options),
+ Renderer,
+ Editor,
- defaultRows: 8,
- });
+ defaultRows: 8,
});
}
diff --git a/client/app/visualizations/word-cloud/renderer.less b/client/app/visualizations/word-cloud/renderer.less
new file mode 100644
index 0000000000..aee6afcced
--- /dev/null
+++ b/client/app/visualizations/word-cloud/renderer.less
@@ -0,0 +1,12 @@
+.word-cloud-visualization-container {
+ overflow: hidden;
+ height: 400px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ svg {
+ transform-origin: center center;
+ flex: 0 0 auto;
+ }
+}
diff --git a/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js b/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js
index aa8b61d486..4ce3bea175 100644
--- a/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js
+++ b/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js
@@ -22,8 +22,23 @@ describe('Edit visualization dialog', () => {
it('opens Edit Visualization dialog', () => {
cy.getByTestId('EditVisualization').click();
cy.getByTestId('EditVisualizationDialog').should('exist');
- // Default visualization should be selected
+ // Default `Table` visualization should be selected
cy.getByTestId('VisualizationType').should('exist').should('contain', 'Table');
cy.getByTestId('VisualizationName').should('exist').should('have.value', 'Table');
});
+
+ it('creates visualization with custom name', () => {
+ const visualizationName = 'Custom name';
+
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.TABLE
+ `);
+
+ cy.getByTestId('VisualizationName').clear().type(visualizationName);
+
+ cy.getByTestId('EditVisualizationDialog').contains('button', 'Save').click();
+ cy.getByTestId('QueryPageVisualizationTabs').contains('li', visualizationName).should('exist');
+ });
});
diff --git a/client/cypress/integration/visualizations/word_cloud_spec.js b/client/cypress/integration/visualizations/word_cloud_spec.js
new file mode 100644
index 0000000000..ff687b31e2
--- /dev/null
+++ b/client/cypress/integration/visualizations/word_cloud_spec.js
@@ -0,0 +1,131 @@
+/* global cy, Cypress */
+
+import { createQuery } from '../../support/redash-api';
+
+const { map } = Cypress._;
+
+const SQL = `
+ SELECT 'Lorem ipsum dolor' AS a, 'ipsum' AS b, 2 AS c UNION ALL
+ SELECT 'Lorem sit amet' AS a, 'amet' AS b, 2 AS c UNION ALL
+ SELECT 'dolor adipiscing elit' AS a, 'elit' AS b, 4 AS c UNION ALL
+ SELECT 'sed do sed' AS a, 'sed' AS b, 5 AS c UNION ALL
+ SELECT 'sed eiusmod tempor' AS a, 'tempor' AS b, 7 AS c
+`;
+
+// Hack to fix Cypress -> Percy communication
+// Word Cloud uses `font-family` defined in CSS with a lot of fallbacks, so
+// it's almost impossible to know which font will be used on particular machine/browser.
+// In Cypress browser it could be one font, in Percy - another.
+// The issue is in how Percy takes screenshots: it takes a DOM/CSS/assets snapshot in Cypress,
+// copies it to own servers and restores in own browsers. Word Cloud computes its layout
+// using Cypress font, sets absolute positions for elements (in pixels), and when it is restored
+// on Percy machines (with another font) - visualization gets messed up.
+// Solution: explicitly provide some font that will be 100% the same on all CI machines. In this
+// case, it's "Roboto" just because it's in the list of fallback fonts and we already have this
+// webfont in assets folder (so browser can load it).
+function injectFont(document) {
+ const style = document.createElement('style');
+ style.setAttribute('id', 'percy-fix');
+ style.setAttribute('type', 'text/css');
+
+ const fonts = [
+ ['Roboto', 'Roboto-Light-webfont', 300],
+ ['Roboto', 'Roboto-Regular-webfont', 400],
+ ['Roboto', 'Roboto-Medium-webfont', 500],
+ ['Roboto', 'Roboto-Bold-webfont', 700],
+ ];
+
+ const basePath = '/static/fonts/roboto/';
+
+ // `insertRule` does not load font for some reason. Using text content works ¯\_(ツ)_/¯
+ style.appendChild(document.createTextNode(map(fonts, ([fontFamily, fileName, fontWeight]) => (`
+ @font-face {
+ font-family: "${fontFamily}";
+ font-weight: ${fontWeight};
+ src: url("${basePath}${fileName}.eot");
+ src: url("${basePath}${fileName}.eot?#iefix") format("embedded-opentype"),
+ url("${basePath}${fileName}.woff") format("woff"),
+ url("${basePath}${fileName}.ttf") format("truetype"),
+ url("${basePath}${fileName}.svg") format("svg");
+ }
+ `)).join('\n\n')));
+ document.getElementsByTagName('head')[0].appendChild(style);
+}
+
+describe('Word Cloud', () => {
+ beforeEach(() => {
+ cy.login();
+ createQuery({ query: SQL }).then(({ id }) => {
+ cy.visit(`queries/${id}/source`);
+ cy.getByTestId('ExecuteButton').click();
+ });
+ cy.document().then(injectFont);
+ });
+
+ it('creates visualization with automatic word frequencies', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.WORD_CLOUD
+
+ WordCloud.WordsColumn
+ WordCloud.WordsColumn.a
+ `);
+
+ // Wait for proper initialization of visualization
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+
+ cy.getByTestId('VisualizationPreview').find('svg text').should('have.length', 11);
+
+ cy.percySnapshot('Visualizations - Word Cloud (Automatic word frequencies)');
+ });
+
+ it('creates visualization with word frequencies from another column', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.WORD_CLOUD
+
+ WordCloud.WordsColumn
+ WordCloud.WordsColumn.b
+
+ WordCloud.FrequenciesColumn
+ WordCloud.FrequenciesColumn.c
+ `);
+
+ // Wait for proper initialization of visualization
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+
+ cy.getByTestId('VisualizationPreview').find('svg text').should('have.length', 5);
+
+ cy.percySnapshot('Visualizations - Word Cloud (Frequencies from another column)');
+ });
+
+ it('creates visualization with word length and frequencies limits', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.WORD_CLOUD
+
+ WordCloud.WordsColumn
+ WordCloud.WordsColumn.b
+
+ WordCloud.FrequenciesColumn
+ WordCloud.FrequenciesColumn.c
+ `);
+
+ cy.fillInputs({
+ 'WordCloud.WordLengthLimit.Min': '4',
+ 'WordCloud.WordLengthLimit.Max': '5',
+ 'WordCloud.WordCountLimit.Min': '1',
+ 'WordCloud.WordCountLimit.Max': '3',
+ });
+
+ // Wait for proper initialization of visualization
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+
+ cy.getByTestId('VisualizationPreview').find('svg text').should('have.length', 2);
+
+ cy.percySnapshot('Visualizations - Word Cloud (With filters)');
+ });
+});
diff --git a/client/cypress/support/commands.js b/client/cypress/support/commands.js
index 83c2681ee5..97efba27f2 100644
--- a/client/cypress/support/commands.js
+++ b/client/cypress/support/commands.js
@@ -1,5 +1,9 @@
+/* global Cypress */
+
import '@percy/cypress'; // eslint-disable-line import/no-extraneous-dependencies, import/no-unresolved
+const { each } = Cypress._;
+
Cypress.Commands.add('login', (email = 'admin@redash.io', password = 'password') => cy.request({
url: '/login',
method: 'POST',
@@ -20,3 +24,9 @@ Cypress.Commands.add('clickThrough', (elements) => {
.forEach(element => cy.getByTestId(element).click());
return undefined;
});
+
+Cypress.Commands.add('fillInputs', (elements) => {
+ each(elements, (value, testId) => {
+ cy.getByTestId(testId).clear().type(value);
+ });
+});
diff --git a/webpack.config.js b/webpack.config.js
index f0bcf90221..a413079ea7 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -75,7 +75,8 @@ const config = {
{ from: "client/app/unsupported.html" },
{ from: "client/app/unsupportedRedirect.js" },
{ from: "client/app/assets/css/*.css", to: "styles/", flatten: true },
- { from: "node_modules/jquery/dist/jquery.min.js", to: "js/jquery.min.js" }
+ { from: "node_modules/jquery/dist/jquery.min.js", to: "js/jquery.min.js" },
+ { from: "client/app/assets/fonts", to: "fonts/" },
])
],
optimization: {