From 13e550071895483c277401cc7261c50d1b9ed4ef Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Fri, 21 Jul 2023 21:36:53 +0200 Subject: [PATCH] Parameter feedback - #2 Client errors in query page (#4319) * Parameter feedback - #2 Client errors in query page * Added cypress test * Fixed percy screenshot * Safer touched change * Parameter feedback - #3 Added in Widgets (#4320) * Parameter feedback - #3 Added in Widgets * Added cypress tests * Making sure widget-level param is selected * Parameter feedback - #4 Added in Dashboard params (#4321) * Parameter feedback - #4 Added in Dashboard params * Added cypress test * Moved to service * Parameter feedback - #5 Unsaved indication (#4322) * Parameter feedback - #5 Unsaved indication * Added ANGULAR_REMOVE_ME * Added cypress test * Fixed percy screenshot * Some code improvements * Parameter input feedback - #6 Better value normalization (#4327) --- .../EditParameterSettingsDialog.jsx | 1 + client/app/components/ParameterValueInput.jsx | 10 +- client/app/components/Parameters.jsx | 83 +++++-- client/app/components/Parameters.less | 25 +- .../dashboard-widget/VisualizationWidget.jsx | 6 +- .../queries/visualization-embed.html | 2 +- .../components/queries/visualization-embed.js | 2 + client/app/pages/dashboards/dashboard.html | 4 +- .../dashboards/public-dashboard-page.html | 4 +- client/app/pages/queries/query.html | 4 +- client/app/pages/queries/source-view.js | 18 +- client/app/services/dashboard.js | 33 +++ .../services/parameters/NumberParameter.js | 6 +- .../app/services/parameters/TextParameter.js | 9 +- .../parameters/tests/NumberParameter.test.js | 5 - client/app/services/query-result.js | 6 +- client/app/services/query.js | 22 +- .../integration/query/parameter_spec.js | 225 +++++++++++++++++- 18 files changed, 412 insertions(+), 53 deletions(-) diff --git a/client/app/components/EditParameterSettingsDialog.jsx b/client/app/components/EditParameterSettingsDialog.jsx index fbae2eb71d..3d370ee1b9 100644 --- a/client/app/components/EditParameterSettingsDialog.jsx +++ b/client/app/components/EditParameterSettingsDialog.jsx @@ -179,6 +179,7 @@ function EditParameterSettingsDialog(props) { {param.type === 'enum' && ( setParam({ ...param, enumOptions: e.target.value })} diff --git a/client/app/components/ParameterValueInput.jsx b/client/app/components/ParameterValueInput.jsx index 7f28ad33a8..88aef8c41e 100644 --- a/client/app/components/ParameterValueInput.jsx +++ b/client/app/components/ParameterValueInput.jsx @@ -5,7 +5,7 @@ import Input from 'antd/lib/input'; import InputNumber from 'antd/lib/input-number'; import DateParameter from '@/components/dynamic-parameters/DateParameter'; import DateRangeParameter from '@/components/dynamic-parameters/DateRangeParameter'; -import { isEqual } from 'lodash'; +import { isEqual, trim } from 'lodash'; import { QueryBasedParameterInput } from './QueryBasedParameterInput'; import './ParameterValueInput.less'; @@ -59,7 +59,7 @@ class ParameterValueInput extends React.Component { } onSelect = (value) => { - const isDirty = !isEqual(value, this.props.value); + const isDirty = !isEqual(trim(value), trim(this.props.value)); this.setState({ value, isDirty }); this.props.onSelect(value, isDirty); } @@ -140,13 +140,11 @@ class ParameterValueInput extends React.Component { const { className } = this.props; const { value } = this.state; - const normalize = val => (isNaN(val) ? undefined : val); - return ( this.onSelect(normalize(val))} + value={value} + onChange={val => this.onSelect(val)} /> ); } diff --git a/client/app/components/Parameters.jsx b/client/app/components/Parameters.jsx index b77c47401c..d9378d58e7 100644 --- a/client/app/components/Parameters.jsx +++ b/client/app/components/Parameters.jsx @@ -1,12 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { size, filter, forEach, extend } from 'lodash'; +import { size, filter, forEach, extend, get, includes } from 'lodash'; import { react2angular } from 'react2angular'; import { SortableContainer, SortableElement, DragHandle } from '@/components/sortable'; import { $location } from '@/services/ng'; import { Parameter } from '@/services/parameters'; import ParameterApplyButton from '@/components/ParameterApplyButton'; import ParameterValueInput from '@/components/ParameterValueInput'; +import Form from 'antd/lib/form'; +import Tooltip from 'antd/lib/tooltip'; import EditParameterSettingsDialog from './EditParameterSettingsDialog'; import { toHuman } from '@/filters'; @@ -29,6 +31,10 @@ export class Parameters extends React.Component { onValuesChange: PropTypes.func, onPendingValuesChange: PropTypes.func, onParametersEdit: PropTypes.func, + queryResultErrorData: PropTypes.shape({ + parameters: PropTypes.objectOf(PropTypes.string), + }), + unsavedParameters: PropTypes.arrayOf(PropTypes.string), }; static defaultProps = { @@ -38,25 +44,36 @@ export class Parameters extends React.Component { onValuesChange: () => {}, onPendingValuesChange: () => {}, onParametersEdit: () => {}, + queryResultErrorData: {}, + unsavedParameters: null, }; constructor(props) { super(props); const { parameters } = props; - this.state = { parameters }; + this.state = { + parameters, + touched: {}, + }; + if (!props.disableUrlUpdate) { updateUrl(parameters); } } componentDidUpdate = (prevProps) => { - const { parameters, disableUrlUpdate } = this.props; + const { parameters, disableUrlUpdate, queryResultErrorData } = this.props; if (prevProps.parameters !== parameters) { this.setState({ parameters }); if (!disableUrlUpdate) { updateUrl(parameters); } } + + // reset touched flags on new error data + if (prevProps.queryResultErrorData !== queryResultErrorData) { + this.setState({ touched: {} }); + } }; handleKeyDown = (e) => { @@ -69,14 +86,15 @@ export class Parameters extends React.Component { setPendingValue = (param, value, isDirty) => { const { onPendingValuesChange } = this.props; - this.setState(({ parameters }) => { + this.setState(({ parameters, touched }) => { if (isDirty) { param.setPendingValue(value); + touched = { ...touched, [param.name]: true }; } else { param.clearPendingValue(); } onPendingValuesChange(); - return { parameters }; + return { parameters, touched }; }); }; @@ -109,17 +127,47 @@ export class Parameters extends React.Component { EditParameterSettingsDialog .showModal({ parameter }) .result.then((updated) => { - this.setState(({ parameters }) => { + this.setState(({ parameters, touched }) => { + touched = { ...touched, [parameter.name]: true }; const updatedParameter = extend(parameter, updated); parameters[index] = Parameter.create(updatedParameter, updatedParameter.parentQueryId); onParametersEdit(); - return { parameters }; + return { parameters, touched }; }); }); }; + getParameterFeedback = (param) => { + // error msg + const { queryResultErrorData } = this.props; + const error = get(queryResultErrorData, ['parameters', param.name], false); + if (error) { + const feedback = {error}; + return [feedback, 'error']; + } + + // unsaved + const { unsavedParameters } = this.props; + if (includes(unsavedParameters, param.name)) { + const feedback = ( + <> + Unsaved{' '} + + + + + ); + return [feedback, 'warning']; + } + + return []; + }; + renderParameter(param, index) { const { editable } = this.props; + const touched = this.state.touched[param.name]; + const [feedback, status] = this.getParameterFeedback(param); + return (
)}
- this.setPendingValue(param, value, isDirty)} - /> + + this.setPendingValue(param, value, isDirty)} + /> + ); } diff --git a/client/app/components/Parameters.less b/client/app/components/Parameters.less index 338912fe80..22f968d637 100644 --- a/client/app/components/Parameters.less +++ b/client/app/components/Parameters.less @@ -3,7 +3,7 @@ .parameter-block { display: inline-block; background: white; - padding: 0 12px 6px 0; + padding: 0 12px 17px 0; vertical-align: top; z-index: 1; @@ -15,12 +15,31 @@ .parameter-container.sortable-container & { margin: 4px 0 0 4px; - padding: 3px 6px 6px; + padding: 3px 6px 19px; } &.parameter-dragged { box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15); } + + .ant-form-item { + margin-bottom: 0 !important; + } + + .ant-form-explain { + position: absolute; + left: 0; + right: 0; + bottom: -20px; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ant-form-item-control { + line-height: normal; + } } .parameter-heading { @@ -107,4 +126,4 @@ box-shadow: 0 0 0 1px white, -1px 1px 0 1px #5d6f7d85; } } -} +} \ No newline at end of file diff --git a/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx b/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx index 3a4e31245b..44df51eea5 100644 --- a/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx +++ b/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx @@ -75,6 +75,8 @@ RefreshIndicator.defaultProps = { refreshStartedAt: null }; function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onParametersUpdate }) { const canViewQuery = currentUser.hasPermission('view_query'); + const queryResult = widget.getQueryResult(); + const errorData = queryResult && queryResult.getErrorData(); return ( <> @@ -90,8 +92,8 @@ function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onPar {!isEmpty(parameters) && ( -
- +
+
)} diff --git a/client/app/components/queries/visualization-embed.html b/client/app/components/queries/visualization-embed.html index 563e4021e5..44bb137a29 100644 --- a/client/app/components/queries/visualization-embed.html +++ b/client/app/components/queries/visualization-embed.html @@ -13,7 +13,7 @@

- +
diff --git a/client/app/components/queries/visualization-embed.js b/client/app/components/queries/visualization-embed.js index fd3fa1c7f0..24b37837d6 100644 --- a/client/app/components/queries/visualization-embed.js +++ b/client/app/components/queries/visualization-embed.js @@ -14,6 +14,7 @@ const VisualizationEmbed = { this.refreshQueryResults = () => { this.loading = true; this.error = null; + this.errorData = {}; this.refreshStartedAt = moment(); this.query .getQueryResultPromise() @@ -24,6 +25,7 @@ const VisualizationEmbed = { .catch((error) => { this.loading = false; this.error = error.getError(); + this.errorData = error.getErrorData(); }); }; diff --git a/client/app/pages/dashboards/dashboard.html b/client/app/pages/dashboards/dashboard.html index 114a07fd63..3c8bd896e4 100644 --- a/client/app/pages/dashboards/dashboard.html +++ b/client/app/pages/dashboards/dashboard.html @@ -93,8 +93,8 @@

-
- +
+
diff --git a/client/app/pages/dashboards/public-dashboard-page.html b/client/app/pages/dashboards/public-dashboard-page.html index 2c1d251d3f..7246e8d86f 100644 --- a/client/app/pages/dashboards/public-dashboard-page.html +++ b/client/app/pages/dashboards/public-dashboard-page.html @@ -1,8 +1,8 @@
-
- +
+
diff --git a/client/app/pages/queries/query.html b/client/app/pages/queries/query.html index 15ecb22e33..34855c792f 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -199,8 +199,8 @@

- +
diff --git a/client/app/pages/queries/source-view.js b/client/app/pages/queries/source-view.js index f5d72898b1..1d6257c000 100644 --- a/client/app/pages/queries/source-view.js +++ b/client/app/pages/queries/source-view.js @@ -1,4 +1,4 @@ -import { map, debounce } from 'lodash'; +import { map, debounce, isEmpty, isEqual } from 'lodash'; import template from './query.html'; import EditParameterSettingsDialog from '@/components/EditParameterSettingsDialog'; @@ -109,6 +109,22 @@ function QuerySourceCtrl( $scope.$watch('query.query', (newQueryText) => { $scope.isDirty = newQueryText !== queryText; }); + + $scope.unsavedParameters = null; + $scope.getUnsavedParameters = () => { + if (!$scope.isDirty || !queryText) { + return null; + } + const unsavedParameters = $scope.query.$parameters.getUnsavedParameters(queryText); + if (isEmpty(unsavedParameters)) { + return null; + } + // avoiding Angular infdig (ANGULAR_REMOVE_ME) + if (!isEqual(unsavedParameters, $scope.unsavedParameters)) { + $scope.unsavedParameters = unsavedParameters; + } + return $scope.unsavedParameters; + }; } export default function init(ngModule) { diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js index 509ab387cf..00dc483566 100644 --- a/client/app/services/dashboard.js +++ b/client/app/services/dashboard.js @@ -249,6 +249,39 @@ function DashboardService($resource, $http, $location, currentUser) { }); }; + let currentQueryResultsErrorData; // swap for useMemo ANGULAR_REMOVE_ME + resource.prototype.getQueryResultsErrorData = function getQueryResultsErrorData() { + const dashboardErrors = _.map(this.widgets, (widget) => { + // get result + const result = widget.getQueryResult(); + if (!result) { + return null; + } + + // get error data + const errorData = result.getErrorData(); + if (_.isEmpty(errorData)) { + return null; + } + + // dashboard params only + const localParamNames = _.map(widget.getLocalParameters(), p => p.name); + const filtered = _.omit(errorData.parameters, localParamNames); + + return filtered; + }); + + const merged = _.assign({}, ...dashboardErrors); + const errorData = _.isEmpty(merged) ? null : { parameters: merged }; + + // avoiding Angular infdig (ANGULAR_REMOVE_ME) + if (!_.isEqual(currentQueryResultsErrorData, errorData)) { + currentQueryResultsErrorData = errorData; + } + + return currentQueryResultsErrorData; + }; + return resource; } diff --git a/client/app/services/parameters/NumberParameter.js b/client/app/services/parameters/NumberParameter.js index 997cdb4b29..17abe0cc65 100644 --- a/client/app/services/parameters/NumberParameter.js +++ b/client/app/services/parameters/NumberParameter.js @@ -1,4 +1,4 @@ -import { toNumber, isNull } from 'lodash'; +import { toNumber, trim } from 'lodash'; import { Parameter } from '.'; class NumberParameter extends Parameter { @@ -9,11 +9,11 @@ class NumberParameter extends Parameter { // eslint-disable-next-line class-methods-use-this normalizeValue(value) { - if (isNull(value)) { + if (!trim(value)) { return null; } const normalizedValue = toNumber(value); - return !isNaN(normalizedValue) ? normalizedValue : null; + return !isNaN(normalizedValue) ? normalizedValue : value; } } diff --git a/client/app/services/parameters/TextParameter.js b/client/app/services/parameters/TextParameter.js index 645adbc8e3..0488392e88 100644 --- a/client/app/services/parameters/TextParameter.js +++ b/client/app/services/parameters/TextParameter.js @@ -1,4 +1,4 @@ -import { toString, isEmpty } from 'lodash'; +import { toString, isEmpty, trim } from 'lodash'; import { Parameter } from '.'; class TextParameter extends Parameter { @@ -15,6 +15,13 @@ class TextParameter extends Parameter { } return normalizedValue; } + + getExecutionValue() { + if (!trim(this.value)) { + return null; + } + return this.value; + } } export default TextParameter; diff --git a/client/app/services/parameters/tests/NumberParameter.test.js b/client/app/services/parameters/tests/NumberParameter.test.js index 2a292ea880..9cce92ef28 100644 --- a/client/app/services/parameters/tests/NumberParameter.test.js +++ b/client/app/services/parameters/tests/NumberParameter.test.js @@ -17,10 +17,5 @@ describe('NumberParameter', () => { const normalizedValue = param.normalizeValue(42); expect(normalizedValue).toBe(42); }); - - test('returns null when not possible to convert to number', () => { - const normalizedValue = param.normalizeValue('notanumber'); - expect(normalizedValue).toBeNull(); - }); }); }); diff --git a/client/app/services/query-result.js b/client/app/services/query-result.js index a267cee050..3fdf0cd34c 100644 --- a/client/app/services/query-result.js +++ b/client/app/services/query-result.js @@ -132,7 +132,7 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) { this.status = 'processing'; } else if (this.job.status === 4) { this.status = statuses[this.job.status]; - this.deferred.reject(new QueryResultError(this.job.error)); + this.deferred.reject(new QueryResultError(this.job.error, this.job.error_data)); } else { this.status = undefined; } @@ -166,6 +166,10 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) { return this.job.error; } + getErrorData() { + return this.job.error_data || undefined; + } + getLog() { if (!this.query_result.data || !this.query_result.data.log || this.query_result.data.log.length === 0) { return null; diff --git a/client/app/services/query.js b/client/app/services/query.js index 561af14526..a43dfc7fa3 100644 --- a/client/app/services/query.js +++ b/client/app/services/query.js @@ -2,8 +2,8 @@ import moment from 'moment'; import debug from 'debug'; import Mustache from 'mustache'; import { - zipObject, isEmpty, map, includes, union, - uniq, has, identity, extend, each, some, + zipObject, isEmpty, map, includes, union, isNil, + uniq, has, identity, extend, each, some, reject, } from 'lodash'; import { Parameter } from './parameters'; @@ -35,13 +35,13 @@ class Parameters { this.initFromQueryString(queryString); } - parseQuery() { + parseQuery(queryText = this.query.query) { const fallback = () => map(this.query.options.parameters, i => i.name); let parameters = []; - if (this.query.query !== undefined) { + if (!isNil(queryText)) { try { - const parts = Mustache.parse(this.query.query); + const parts = Mustache.parse(queryText); parameters = uniq(collectParams(parts)); } catch (e) { logger('Failed parsing parameters: ', e); @@ -124,6 +124,11 @@ class Parameters { each(this.get(), p => p.applyPendingValue()); } + getUnsavedParameters(queryText) { + const savedParameters = this.parseQuery(queryText); + return reject(this.get(), p => includes(savedParameters, p.name)).map(p => p.name); + } + toUrlParams() { if (this.get().length === 0) { return ''; @@ -140,8 +145,9 @@ class Parameters { function QueryResultErrorFactory($q) { class QueryResultError { - constructor(errorMessage) { + constructor(errorMessage, errorData = {}) { this.errorMessage = errorMessage; + this.errorData = errorData; this.updatedAt = moment.utc(); } @@ -153,6 +159,10 @@ function QueryResultErrorFactory($q) { return this.errorMessage; } + getErrorData() { + return this.errorData || undefined; + } + toPromise() { return $q.reject(this); } diff --git a/client/cypress/integration/query/parameter_spec.js b/client/cypress/integration/query/parameter_spec.js index 35fb9afd7c..5979c4c3a0 100644 --- a/client/cypress/integration/query/parameter_spec.js +++ b/client/cypress/integration/query/parameter_spec.js @@ -1,4 +1,6 @@ -import { createQuery } from '../../support/redash-api'; +import { createQuery, createDashboard, addWidget } from '../../support/redash-api'; + +const { get } = Cypress._; describe('Parameter', () => { const expectDirtyStateChange = (edit) => { @@ -17,6 +19,15 @@ describe('Parameter', () => { }); }; + const expectValueValidationError = (edit, expectedInvalidString = 'Required parameter') => { + cy.getByTestId('ParameterName-test-parameter') + .find('.ant-form-item-control') + .should('have.class', 'has-error') + .find('.ant-form-explain') + .should('contain.text', expectedInvalidString) + .should('not.have.class', 'show-help-enter'); // assures ant animation ended for screenshot + }; + beforeEach(() => { cy.login(); }); @@ -28,7 +39,7 @@ describe('Parameter', () => { query: "SELECT '{{test-parameter}}' AS parameter", options: { parameters: [ - { name: 'test-parameter', title: 'Test Parameter', type: 'text' }, + { name: 'test-parameter', title: 'Test Parameter', type: 'text', value: 'text' }, ], }, }; @@ -56,6 +67,16 @@ describe('Parameter', () => { .type('Redash'); }); }); + + it('shows validation error when value is empty', () => { + cy.getByTestId('ParameterName-test-parameter') + .find('input') + .clear(); + + cy.getByTestId('ParameterApplyButton').click(); + + expectValueValidationError(); + }); }); describe('Number Parameter', () => { @@ -65,7 +86,7 @@ describe('Parameter', () => { query: "SELECT '{{test-parameter}}' AS parameter", options: { parameters: [ - { name: 'test-parameter', title: 'Test Parameter', type: 'number' }, + { name: 'test-parameter', title: 'Test Parameter', type: 'number', value: 1 }, ], }, }; @@ -103,6 +124,16 @@ describe('Parameter', () => { .type('{selectall}42'); }); }); + + it('shows validation error when value is empty', () => { + cy.getByTestId('ParameterName-test-parameter') + .find('input') + .clear(); + + cy.getByTestId('ParameterApplyButton').click(); + + expectValueValidationError(); + }); }); describe('Dropdown Parameter', () => { @@ -178,6 +209,36 @@ describe('Parameter', () => { .click(); }); }); + + it('shows validation error when empty', () => { + cy.getByTestId('ParameterSettings-test-parameter').click(); + cy.getByTestId('EnumTextArea').clear(); + cy.clickThrough(` + SaveParameterSettings + ExecuteButton + `); + + expectValueValidationError(); + }); + + it('shows validation error when multi-selection is empty', () => { + cy.clickThrough(` + ParameterSettings-test-parameter + AllowMultipleValuesCheckbox + QuotationSelect + DoubleQuotationMarkOption + SaveParameterSettings + `); + + cy.getByTestId('ParameterName-test-parameter') + .find('.ant-select-remove-icon') + .click(); + + cy.getByTestId('ParameterApplyButton') + .click(); + + expectValueValidationError(); + }); }); describe('Query Based Dropdown Parameter', () => { @@ -306,6 +367,22 @@ describe('Parameter', () => { it('sets dirty state when edited', () => { expectDirtyStateChange(() => selectCalendarDate('15')); }); + + it('shows validation error when value is empty', () => { + selectCalendarDate('15'); + + cy.getByTestId('ParameterApplyButton') + .click(); + + cy.getByTestId('ParameterName-test-parameter') + .find('.ant-calendar-picker-clear') + .click({ force: true }); + + cy.getByTestId('ParameterApplyButton') + .click(); + + expectValueValidationError(); + }); }); describe('Date and Time Parameter', () => { @@ -396,6 +473,32 @@ describe('Parameter', () => { .click(); }); }); + + it('shows validation error when value is empty', () => { + cy.getByTestId('ParameterName-test-parameter') + .find('input') + .as('Input') + .click({ force: true }); + + cy.get('.ant-calendar-date-panel') + .contains('.ant-calendar-date', '15') + .click(); + + cy.get('.ant-calendar-ok-btn') + .click(); + + cy.getByTestId('ParameterApplyButton') + .click(); + + cy.getByTestId('ParameterName-test-parameter') + .find('.ant-calendar-picker-clear') + .click({ force: true }); + + cy.getByTestId('ParameterApplyButton') + .click(); + + expectValueValidationError(); + }); }); describe('Date Range Parameter', () => { @@ -469,6 +572,122 @@ describe('Parameter', () => { it('sets dirty state when edited', () => { expectDirtyStateChange(() => selectCalendarDateRange('15', '20')); }); + + it('shows validation error when value is empty', () => { + selectCalendarDateRange('15', '20'); + + cy.getByTestId('ParameterApplyButton') + .click(); + + cy.getByTestId('ParameterName-test-parameter') + .find('.ant-calendar-picker-clear') + .click({ force: true }); + + cy.getByTestId('ParameterApplyButton') + .click(); + + expectValueValidationError(); + }); + }); + + describe('Inline feedback', () => { + beforeEach(function () { + const queryData = { + query: 'SELECT {{ test-parameter }}', + options: { + parameters: [ + { name: 'test-parameter', title: 'Param', type: 'number', value: null }, + ], + }, + }; + + createQuery(queryData, false) + .then((query) => { + this.query = query; + this.vizId = get(query, 'visualizations.0.id'); + }); + }); + + it('shows validation error in query page', function () { + cy.visit(`/queries/${this.query.id}`); + expectValueValidationError(); + cy.percySnapshot('Validation error in query page'); + }); + + it('shows unsaved feedback in query page', function () { + cy.visit(`/queries/${this.query.id}/source`); + + cy.getByTestId('QueryEditor') + .get('.ace_text-input') + .type(' {{ newparam }}', { force: true, parseSpecialCharSequences: false }); + + cy.getByTestId('ParameterName-newparam') + .find('.ant-form-item-control') + .should('have.class', 'has-warning') + .find('.ant-form-explain') + .as('Feedback'); + + cy.get('@Feedback') + .should('contain.text', 'Unsaved') + .should('not.have.class', 'show-help-appear'); // assures ant animation ended for screenshot + + cy.percySnapshot('Unsaved feedback in query page'); + + cy.getByTestId('SaveButton').click(); + cy.get('@Feedback').should('not.exist'); + }); + + it('shows validation error in visualization embed', function () { + cy.visit(`/embed/query/${this.query.id}/visualization/${this.vizId}?api_key=${this.query.api_key}`); + expectValueValidationError(); + cy.percySnapshot('Validation error in visualization embed'); + }); + + it('shows validation error in widget-level parameter', function () { + createDashboard('Foo') + .then(({ slug, id }) => { + this.dashboardUrl = `/dashboard/${slug}`; + return addWidget(id, this.vizId, { + parameterMappings: { + 'test-parameter': { + type: 'widget-level', + title: '', + name: 'test-parameter', + mapTo: 'test-parameter', + value: null, + }, + }, + }); + }) + .then(() => { + cy.visit(this.dashboardUrl); + }); + expectValueValidationError(); + cy.percySnapshot('Validation error in widget-level parameter'); + }); + + it('shows validation error in dashboard-level parameter', function () { + createDashboard('Foo') + .then(({ slug, id }) => { + this.dashboardUrl = `/dashboard/${slug}`; + return addWidget(id, this.vizId, { + parameterMappings: { + 'test-parameter': { + type: 'dashboard-level', + title: '', + name: 'test-parameter', + mapTo: 'test-parameter', + value: null, + }, + }, + }); + }) + .then(() => { + cy.visit(this.dashboardUrl); + }); + expectValueValidationError(); + cy.percySnapshot('Validation error in dashboard-level parameter'); + }); }); describe('Apply Changes', () => {