From c8a47a2b0d8f78104613d374cacbaa4bd321950d Mon Sep 17 00:00:00 2001 From: Tim Zallmann <tzallmann@gitlab.com> Date: Mon, 26 Jun 2023 09:30:34 +0000 Subject: [PATCH] Save visualizations to snowplow path - Update the visualization save path - Improve the designer UI consistency - Improve the designer save error messages - Move strings to constants - Fix todo issue link - Add ability to open dashboard in editing mode - Change visualization "title" to "name" --- .../api/dashboards_api.js | 32 ++- .../components/analytics_dashboard.vue | 2 +- .../analytics_visualization_designer.vue | 143 ++++++++-- .../analytics_visualization_preview.vue | 16 +- .../analytics_dashboards/constants.js | 19 ++ .../customizable_dashboard/constants.js | 2 + .../customizable_dashboard.vue | 12 +- .../analytics_visualization_designer_spec.js | 270 +++++++++++++++++- .../analytics_visualization_preview_spec.js | 16 +- .../analytics/analytics_dashboards/stubs.js | 35 +++ .../utils/dashboards_api_spec.js | 50 +++- .../customizable_dashboard_spec.js | 44 ++- .../customizable_dashboard/mock_data.js | 1 + locale/gitlab.pot | 28 +- 14 files changed, 626 insertions(+), 44 deletions(-) create mode 100644 ee/spec/frontend/analytics/analytics_dashboards/stubs.js diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/api/dashboards_api.js b/ee/app/assets/javascripts/analytics/analytics_dashboards/api/dashboards_api.js index feed39ea32ff1..18b749a6ad735 100644 --- a/ee/app/assets/javascripts/analytics/analytics_dashboards/api/dashboards_api.js +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/api/dashboards_api.js @@ -3,11 +3,11 @@ import axios from '~/lib/utils/axios_utils'; import service from '~/ide/services/'; import { s__, sprintf } from '~/locale'; -const DASHBOARD_BRANCH = 'main'; +export const DASHBOARD_BRANCH = 'main'; export const CUSTOM_DASHBOARDS_PATH = '.gitlab/dashboards/'; -export const PRODUCT_ANALYTICS_VISUALIZATIONS_PATH = - '.gitlab/dashboards/product_analytics/visualizations/'; +export const PRODUCT_ANALYTICS_VISUALIZATIONS_PATH = '.gitlab/analytics/dashboards/visualizations/'; +export const CONFIGURATION_FILE_TYPE = '.yaml'; export const CREATE_FILE_ACTION = 'create'; export const UPDATE_FILE_ACTION = 'update'; @@ -28,7 +28,7 @@ const getFileFromCustomDashboardProject = async (directory, fileId, projectInfo) `${gon.relative_url_root}/${ projectInfo.fullPath }/-/raw/${DASHBOARD_BRANCH}/${encodeURIComponent( - `${directory}${fileId}.yml`.replace(/^\//, ''), + `${directory}${fileId}${CONFIGURATION_FILE_TYPE}`.replace(/^\//, ''), )}`, { params: { cb: Math.random() } }, ); @@ -47,6 +47,28 @@ export async function getProductAnalyticsVisualization(visualizationId, projectI ); } +export async function saveProductAnalyticsVisualization( + visualizationName, + visualizationCode, + projectInfo, +) { + const payload = { + branch: DASHBOARD_BRANCH, + commit_message: sprintf(s__('Analytics|Updating visualization %{visualizationName}'), { + visualizationName, + }), + actions: [ + { + action: CREATE_FILE_ACTION, + file_path: `${PRODUCT_ANALYTICS_VISUALIZATIONS_PATH}${visualizationName}${CONFIGURATION_FILE_TYPE}`, + content: stringify(visualizationCode, null), + encoding: 'text', + }, + ], + }; + return service.commit(projectInfo.fullPath, payload); +} + export async function getCustomDashboards(projectInfo) { return getFileListFromCustomDashboardProject(CUSTOM_DASHBOARDS_PATH, projectInfo); } @@ -71,7 +93,7 @@ export async function saveCustomDashboard({ actions: [ { action, - file_path: `${CUSTOM_DASHBOARDS_PATH}${dashboardId}.yml`, + file_path: `${CUSTOM_DASHBOARDS_PATH}${dashboardId}${CONFIGURATION_FILE_TYPE}`, content: stringify(dashboardObject, null), encoding: 'text', }, diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/analytics_dashboard.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/analytics_dashboard.vue index 02b6602d68a6f..32e7bac170418 100644 --- a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/analytics_dashboard.vue +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/analytics_dashboard.vue @@ -110,7 +110,7 @@ export default { }, apollo: { // TODO: Add retrieval of visualizations for Snowplow - // https://gitlab.com/gitlab-org/gitlab/-/issues/411597 + // https://gitlab.com/gitlab-org/gitlab/-/issues/414281 dashboard: { query: getProductAnalyticsDashboardQuery, variables() { diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/analytics_visualization_designer.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/analytics_visualization_designer.vue index 0fea214a1d66e..6c3000954cdc3 100644 --- a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/analytics_visualization_designer.vue +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/analytics_visualization_designer.vue @@ -2,13 +2,24 @@ import { QueryBuilder } from '@cubejs-client/vue'; import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; import { createAlert } from '~/alert'; +import { slugify } from '~/lib/utils/text_utility'; +import { HTTP_STATUS_CREATED } from '~/lib/utils/http_status'; import { createCubeJsApi } from 'ee/analytics/analytics_dashboards/data_sources/cube_analytics'; import { getPanelOptions } from 'ee/analytics/analytics_dashboards/utils/visualization_panel_options'; +import { saveProductAnalyticsVisualization } from 'ee/analytics/analytics_dashboards/api/dashboards_api'; +import { NEW_DASHBOARD_SLUG } from 'ee/vue_shared/components/customizable_dashboard/constants'; import { PANEL_DISPLAY_TYPES, I18N_DASHBOARD_LIST_VISUALIZATION_DESIGNER_CUBEJS_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_NAME_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_MEASURE_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_TYPE_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_ALREADY_EXISTS_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_SUCCESS, } from '../constants'; import MeasureSelector from './visualization_designer/selectors/product_analytics/measure_selector.vue'; @@ -26,6 +37,12 @@ export default { VisualizationInspector, VisualizationPreview, }, + inject: { + customDashboardsProject: { + type: Object, + default: null, + }, + }, data() { const query = { limit: 100, @@ -38,11 +55,12 @@ export default { measureType: '', measureSubType: '', }, - cubeJsErrorAlert: null, - defaultTitle: '', + visualizationName: '', selectedDisplayType: PANEL_DISPLAY_TYPES.DATA, selectedVisualizationType: '', hasTimeDimension: false, + isSaving: false, + alert: null, }; }, computed: { @@ -56,7 +74,6 @@ export default { return { version: 1, - title: this.defaultTitle, type: this.selectedVisualizationType, data: { type: 'cube_analytics', @@ -68,6 +85,14 @@ export default { panelOptions() { return getPanelOptions(this.selectedVisualizationType, this.hasTimeDimension); }, + saveButtonText() { + return this.$route?.params.dashboardid + ? s__('Analytics|Save and add to Dashboard') + : s__('Analytics|Save new visualization'); + }, + }, + beforeDestroy() { + this.alert?.dismiss(); }, mounted() { // Needs to be dynamic as it can't be changed on the cube component @@ -83,15 +108,11 @@ export default { methods: { onQueryStatusChange({ error }) { if (!error) { - this.cubeJsErrorAlert?.dismiss(); + this.alert?.dismiss(); return; } - this.cubeJsErrorAlert = createAlert({ - message: I18N_DASHBOARD_LIST_VISUALIZATION_DESIGNER_CUBEJS_ERROR, - captureError: true, - error, - }); + this.showAlert(I18N_DASHBOARD_LIST_VISUALIZATION_DESIGNER_CUBEJS_ERROR, error, true); }, onVizStateChange(state) { this.hasTimeDimension = Boolean(state.query.timeDimensions?.length); @@ -107,8 +128,90 @@ export default { this.selectDisplayType(PANEL_DISPLAY_TYPES.VISUALIZATION); this.selectedVisualizationType = newType; }, - addToDashboard() { - this.selectDisplayType(PANEL_DISPLAY_TYPES.CODE); + getSaveVisualizationValidationError() { + if (!this.visualizationName) { + return I18N_DASHBOARD_VISUALIZATION_DESIGNER_NAME_ERROR; + } + if (!this.selectedVisualizationType) { + return I18N_DASHBOARD_VISUALIZATION_DESIGNER_TYPE_ERROR; + } + if (!this.queryState.measureSubType) { + return I18N_DASHBOARD_VISUALIZATION_DESIGNER_MEASURE_ERROR; + } + return null; + }, + async saveVisualization() { + const validationError = this.getSaveVisualizationValidationError(); + + if (validationError) { + this.showAlert(validationError); + return; + } + + this.isSaving = true; + + try { + const filename = slugify(this.visualizationName, '_'); + + const saveResult = await saveProductAnalyticsVisualization( + filename, + this.resultVisualization, + this.customDashboardsProject, + ); + + if (saveResult.status === HTTP_STATUS_CREATED) { + this.alert?.dismiss(); + + this.$toast.show(I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_SUCCESS); + + if (this.$route?.params.dashboard) { + this.routeToDashboard(this.$route?.params.dashboard); + } + } else { + this.showAlert( + I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_ERROR, + new Error( + `Recieved an unexpected HTTP status while saving visualization: ${saveResult.status}`, + ), + true, + ); + } + } catch (error) { + const { message = '' } = error?.response?.data || {}; + + // eslint-disable-next-line @gitlab/require-i18n-strings + if (message === 'A file with this name already exists') { + this.showAlert(I18N_DASHBOARD_VISUALIZATION_DESIGNER_ALREADY_EXISTS_ERROR); + } else { + this.showAlert( + `${I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_ERROR} ${message}`.trimEnd(), + error, + true, + ); + } + } finally { + this.isSaving = false; + } + }, + routeToDashboard(dashboard) { + if (dashboard === NEW_DASHBOARD_SLUG) { + this.$router.push('/new'); + } else { + this.$router.push({ + name: 'dashboard-detail', + params: { + slug: dashboard, + editing: true, + }, + }); + } + }, + showAlert(message, error = null, captureError = false) { + this.alert = createAlert({ + message, + error, + captureError, + }); }, }, I18N_DASHBOARD_LIST_VISUALIZATION_DESIGNER_CUBEJS_ERROR, @@ -120,19 +223,24 @@ export default { <div class="gl-display-flex gl-mb-4 gl-mt-4"> <div class="gl-flex-direction-column gl-flex-grow-1"> <input - v-model="defaultTitle" + v-model="visualizationName" dir="auto" type="text" - :placeholder="s__('Analytics|New Analytics Visualization Title')" - :aria-label="__('Title')" + :placeholder="s__('Analytics|New analytics visualization name')" + :aria-label="__('Name')" class="form-control gl-border-gray-200" data-testid="panel-title-tba" /> </div> <div class="gl-ml-2"> - <gl-button category="primary" @click="addToDashboard">{{ - s__('Analytics|Add to Dashboard') - }}</gl-button> + <gl-button + :loading="isSaving" + category="primary" + variant="confirm" + data-testid="visualization-save-btn" + @click="saveVisualization" + >{{ saveButtonText }}</gl-button + > </div> </div> <div id="js-query-builder-wrapper" class="gl-border-t"> @@ -198,6 +306,7 @@ export default { :loading="loading" :result-set="resultSet ? resultSet : null" :result-visualization="resultSet && isQueryPresent ? resultVisualization : null" + :title="visualizationName" @selectedDisplayType="selectDisplayType" /> </div> diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualization_designer/analytics_visualization_preview.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualization_designer/analytics_visualization_preview.vue index dd59dd4b06eba..ab84345a1a20d 100644 --- a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualization_designer/analytics_visualization_preview.vue +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualization_designer/analytics_visualization_preview.vue @@ -51,6 +51,11 @@ export default { required: false, default: null, }, + title: { + type: String, + required: false, + default: '', + }, }, computed: { dataTableResults() { @@ -114,7 +119,7 @@ export default { data-testid="grid-stack-panel" > <div - class="grid-stack-item-content gl-shadow gl-rounded-base gl-p-4 gl-display-flex gl-flex-direction-column gl-bg-white" + class="grid-stack-item-content gl-shadow-sm gl-rounded-base gl-p-4 gl-display-flex gl-flex-direction-column gl-bg-white" > <strong class="gl-mb-2">{{ s__('Analytics|Resulting Data') }}</strong> <div class="gl-overflow-y-auto gl-h-full"> @@ -135,7 +140,7 @@ export default { > <panels-base v-if="selectedVisualizationType" - :title="resultVisualization.title" + :title="title" :visualization="resultVisualization" :style="{ height: $options.PANEL_VISUALIZATION_HEIGHT }" data-testid="preview-visualization" @@ -150,9 +155,12 @@ export default { </div> </div> - <div v-if="displayType === $options.PANEL_DISPLAY_TYPES.CODE" class="gl-m-4"> + <div + v-if="displayType === $options.PANEL_DISPLAY_TYPES.CODE" + class="gl-bg-white gl-m-5 gl-p-4 gl-shadow-sm gl-rounded-base" + > <pre - class="code highlight gl-display-flex gl-bg-white" + class="code highlight gl-display-flex gl-bg-transparent gl-border-none" data-testid="preview-code" ><code>{{ resultVisualization }}</code></pre> </div> diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/constants.js b/ee/app/assets/javascripts/analytics/analytics_dashboards/constants.js index a5631395b031d..aad7dd17fddc1 100644 --- a/ee/app/assets/javascripts/analytics/analytics_dashboards/constants.js +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/constants.js @@ -17,6 +17,25 @@ export const I18N_DASHBOARD_LIST_VISUALIZATION_DESIGNER_CUBEJS_ERROR = s__( 'Analytics|An error occurred while loading data', ); +export const I18N_DASHBOARD_VISUALIZATION_DESIGNER_NAME_ERROR = s__( + 'Analytics|Enter a visualization name', +); +export const I18N_DASHBOARD_VISUALIZATION_DESIGNER_MEASURE_ERROR = s__( + 'Analytics|Select a measurement', +); +export const I18N_DASHBOARD_VISUALIZATION_DESIGNER_TYPE_ERROR = s__( + 'Analytics|Select a visualization type', +); +export const I18N_DASHBOARD_VISUALIZATION_DESIGNER_ALREADY_EXISTS_ERROR = s__( + 'Analytics|A visualization with that name already exists.', +); +export const I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_ERROR = s__( + 'Analytics|Error while saving visualization.', +); +export const I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_SUCCESS = s__( + 'Analytics|Visualization was saved successfully', +); + export const I18N_ALERT_NO_POINTER_TITLE = s__('Analytics|Custom dashboards'); export const I18N_ALERT_NO_POINTER_BUTTON = s__('Analytics|Configure Dashboard Project'); export const I18N_ALERT_NO_POINTER_DESCRIPTION = s__( diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js index 62f5299494d46..1eb212bc47bf2 100644 --- a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js @@ -14,3 +14,5 @@ export const CURSOR_GRABBING_CLASS = 'gl-cursor-grabbing!'; export const I18N_VISUALIZATION_SELECTOR_NEW = s__('ProductAnalytics|Create a visualization'); export const I18N_PRODUCT_ANALYTICS_TITLE = __('Product analytics'); + +export const NEW_DASHBOARD_SLUG = 'new'; diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue index ae474e5cc09cb..1caae40abefdf 100644 --- a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue @@ -15,6 +15,7 @@ import { GRIDSTACK_CELL_HEIGHT, GRIDSTACK_MIN_ROW, CURSOR_GRABBING_CLASS, + NEW_DASHBOARD_SLUG, } from './constants'; import VisualizationSelector from './dashboard_editor/visualization_selector.vue'; import { filtersToQueryParams } from './utils'; @@ -114,6 +115,14 @@ export default { isNewDashboard(isNew) { this.editing = isNew; }, + '$route.params.editing': { + handler(editing) { + if (editing !== undefined) { + this.editing = editing; + } + }, + immediate: true, + }, }, async created() { try { @@ -203,7 +212,8 @@ export default { } }, routeToVisualizationDesigner() { - this.$router.push({ name: 'visualization-designer' }); + const dashboard = this.isNewDashboard ? NEW_DASHBOARD_SLUG : this.dashboard.slug; + this.$router.push({ name: 'visualization-designer', params: { dashboard } }); }, async saveEdit(submitEvent) { submitEvent.preventDefault(); diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/analytics_visualization_designer_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/analytics_visualization_designer_spec.js index bfceb7aa28cfc..d683f9a26e5c5 100644 --- a/ee/spec/frontend/analytics/analytics_dashboards/components/analytics_visualization_designer_spec.js +++ b/ee/spec/frontend/analytics/analytics_dashboards/components/analytics_visualization_designer_spec.js @@ -1,18 +1,103 @@ +import { nextTick } from 'vue'; import { __setMockMetadata } from '@cubejs-client/core'; - import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import { HTTP_STATUS_CREATED, HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status'; +import { createAlert } from '~/alert'; + +import { saveProductAnalyticsVisualization } from 'ee/analytics/analytics_dashboards/api/dashboards_api'; import AnalyticsVisualizationDesigner from 'ee/analytics/analytics_dashboards/components/analytics_visualization_designer.vue'; -import { mockMetaData } from '../mock_data'; +import VisualizationInspector from 'ee/analytics/analytics_dashboards/components/visualization_designer/analytics_visualization_inspector.vue'; +import { + I18N_DASHBOARD_VISUALIZATION_DESIGNER_NAME_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_MEASURE_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_TYPE_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_ALREADY_EXISTS_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_ERROR, + I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_SUCCESS, + I18N_DASHBOARD_LIST_VISUALIZATION_DESIGNER_CUBEJS_ERROR, +} from 'ee/analytics/analytics_dashboards/constants'; + +import { NEW_DASHBOARD_SLUG } from 'ee/vue_shared/components/customizable_dashboard/constants'; + +import { mockMetaData, TEST_CUSTOM_DASHBOARDS_PROJECT } from '../mock_data'; +import { BuilderComponent, QueryBuilder } from '../stubs'; + +const mockAlertDismiss = jest.fn(); +jest.mock('~/alert', () => ({ + createAlert: jest.fn().mockImplementation(() => ({ + dismiss: mockAlertDismiss, + })), +})); +jest.mock('ee/analytics/analytics_dashboards/api/dashboards_api'); + +const showToast = jest.fn(); +const routerPush = jest.fn(); describe('AnalyticsVisualizationDesigner', () => { let wrapper; const findTitleInput = () => wrapper.findByTestId('panel-title-tba'); + const findMeasureSelector = () => wrapper.findByTestId('panel-measure-selector'); const findDimensionSelector = () => wrapper.findByTestId('panel-dimension-selector'); + const findSaveButton = () => wrapper.findByTestId('visualization-save-btn'); + const findQueryBuilder = () => wrapper.findByTestId('query-builder'); + const findVisualizationInspector = () => wrapper.findComponent(VisualizationInspector); + + const setVisualizationTitle = (newTitle = '') => { + const textinput = findTitleInput(); + textinput.setValue(newTitle); + textinput.trigger('input'); + }; + + const setMeasurement = (type = '', subType = '') => { + findMeasureSelector().vm.$emit('measureSelected', type, subType); + }; + + const setVisualizationType = (type = '') => { + findVisualizationInspector().vm.$emit('selectVisualizationType', type); + }; + + const setAllRequiredFields = () => { + setVisualizationTitle('New Title'); + setMeasurement('pageViews', 'all'); + setVisualizationType('SingleStat'); + }; - const createWrapper = () => { - wrapper = shallowMountExtended(AnalyticsVisualizationDesigner); + const mockSaveVisualizationImplementation = async (responseCallback) => { + saveProductAnalyticsVisualization.mockImplementation(responseCallback); + + await waitForPromises(); + }; + + const createWrapper = (sourceDashboardSlug) => { + const mocks = { + $toast: { + show: showToast, + }, + $route: { + params: { + dashboard: sourceDashboardSlug || '', + }, + }, + $router: { + push: routerPush, + }, + }; + + wrapper = shallowMountExtended(AnalyticsVisualizationDesigner, { + stubs: { + RouterView: true, + BuilderComponent, + QueryBuilder, + }, + mocks, + provide: { + customDashboardsProject: TEST_CUSTOM_DASHBOARDS_PROJECT, + }, + }); }; describe('when mounted', () => { @@ -29,4 +114,181 @@ describe('AnalyticsVisualizationDesigner', () => { expect(findDimensionSelector().exists()).toBe(false); }); }); + + describe('query builder', () => { + beforeEach(() => { + __setMockMetadata(jest.fn().mockImplementation(() => mockMetaData)); + createWrapper(); + }); + + it('shows an alert when a query error occurs', () => { + const error = new Error(); + findQueryBuilder().vm.$emit('queryStatus', { error }); + + expect(createAlert).toHaveBeenCalledWith({ + message: I18N_DASHBOARD_LIST_VISUALIZATION_DESIGNER_CUBEJS_ERROR, + captureError: true, + error, + }); + }); + }); + + describe('when saving', () => { + beforeEach(() => { + __setMockMetadata(jest.fn().mockImplementation(() => mockMetaData)); + createWrapper(); + setAllRequiredFields(); + }); + + it.each` + field | setter | errorMessage + ${'title'} | ${setVisualizationTitle} | ${I18N_DASHBOARD_VISUALIZATION_DESIGNER_NAME_ERROR} + ${'measurement'} | ${setMeasurement} | ${I18N_DASHBOARD_VISUALIZATION_DESIGNER_MEASURE_ERROR} + ${'type'} | ${setVisualizationType} | ${I18N_DASHBOARD_VISUALIZATION_DESIGNER_TYPE_ERROR} + `( + 'creates an alert when the $field is empty or not selected', + async ({ setter, errorMessage }) => { + setter(); + await findSaveButton().vm.$emit('click'); + expect(createAlert).toHaveBeenCalledWith({ + message: errorMessage, + captureError: false, + error: null, + }); + }, + ); + + it('successfully', async () => { + await mockSaveVisualizationImplementation(() => ({ status: HTTP_STATUS_CREATED })); + + await findSaveButton().vm.$emit('click'); + + expect(saveProductAnalyticsVisualization).toHaveBeenCalledWith( + 'new_title', + { + data: { + query: { foo: 'bar' }, + type: 'cube_analytics', + }, + options: {}, + type: 'SingleStat', + version: 1, + }, + TEST_CUSTOM_DASHBOARDS_PROJECT, + ); + + await waitForPromises(); + + expect(showToast).toHaveBeenCalledWith(I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_SUCCESS); + }); + + it('dismisses the existing alert after successfully saving', async () => { + await setVisualizationTitle(''); + await findSaveButton().vm.$emit('click'); + + await mockSaveVisualizationImplementation(() => ({ status: HTTP_STATUS_CREATED })); + + await setAllRequiredFields(); + await findSaveButton().vm.$emit('click'); + await waitForPromises(); + + expect(mockAlertDismiss).toHaveBeenCalled(); + }); + + it('and a error happens', async () => { + await mockSaveVisualizationImplementation(() => ({ status: HTTP_STATUS_FORBIDDEN })); + + await findSaveButton().vm.$emit('click'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_ERROR, + error: new Error( + `Recieved an unexpected HTTP status while saving visualization: ${HTTP_STATUS_FORBIDDEN}`, + ), + captureError: true, + }); + }); + + it('and the server responds with "A file with this name already exists"', async () => { + const responseError = new Error(); + responseError.response = { + data: { message: 'A file with this name already exists' }, + }; + + mockSaveVisualizationImplementation(() => { + throw responseError; + }); + + await findSaveButton().vm.$emit('click'); + await waitForPromises(); + expect(createAlert).toHaveBeenCalledWith({ + message: I18N_DASHBOARD_VISUALIZATION_DESIGNER_ALREADY_EXISTS_ERROR, + error: null, + captureError: false, + }); + }); + + it('and an error is thrown', async () => { + const newError = new Error(); + mockSaveVisualizationImplementation(() => { + throw newError; + }); + await findSaveButton().vm.$emit('click'); + await waitForPromises(); + expect(createAlert).toHaveBeenCalledWith({ + error: newError, + message: I18N_DASHBOARD_VISUALIZATION_DESIGNER_SAVE_ERROR, + captureError: true, + }); + }); + }); + + describe('beforeDestroy', () => { + beforeEach(() => { + __setMockMetadata(jest.fn().mockImplementation(() => mockMetaData)); + createWrapper(); + }); + + it('should dismiss the alert', async () => { + await findSaveButton().vm.$emit('click'); + + wrapper.destroy(); + + await nextTick(); + + expect(mockAlertDismiss).toHaveBeenCalled(); + }); + }); + + describe('when editing for dashboard', () => { + const setupSaveDashbboard = async (dashboard) => { + __setMockMetadata(jest.fn().mockImplementation(() => mockMetaData)); + createWrapper(dashboard); + setAllRequiredFields(); + + await mockSaveVisualizationImplementation(() => ({ status: HTTP_STATUS_CREATED })); + + await findSaveButton().vm.$emit('click'); + await waitForPromises(); + }; + + it('after save it will redirect for new dashboards', async () => { + await setupSaveDashbboard(NEW_DASHBOARD_SLUG); + + expect(routerPush).toHaveBeenCalledWith('/new'); + }); + + it('after save it will redirect for existing dashboards', async () => { + await setupSaveDashbboard('test-source-dashboard'); + + expect(routerPush).toHaveBeenCalledWith({ + name: 'dashboard-detail', + params: { + slug: 'test-source-dashboard', + editing: true, + }, + }); + }); + }); }); diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/visualization_designer/analytics_visualization_preview_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/visualization_designer/analytics_visualization_preview_spec.js index df9a7699e18f9..0fc57c0d268a2 100644 --- a/ee/spec/frontend/analytics/analytics_dashboards/components/visualization_designer/analytics_visualization_preview_spec.js +++ b/ee/spec/frontend/analytics/analytics_dashboards/components/visualization_designer/analytics_visualization_preview_spec.js @@ -5,6 +5,7 @@ import { PANEL_VISUALIZATION_HEIGHT, } from 'ee/analytics/analytics_dashboards/constants'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { TEST_VISUALIZATION } from '../../mock_data'; describe('AnalyticsVisualizationPreview', () => { let wrapper; @@ -15,6 +16,8 @@ describe('AnalyticsVisualizationPreview', () => { const selectDisplayType = jest.fn(); + const resultVisualization = TEST_VISUALIZATION(); + const createWrapper = (props = {}) => { wrapper = shallowMountExtended(AnalyticsVisualizationPreview, { propsData: { @@ -24,7 +27,7 @@ describe('AnalyticsVisualizationPreview', () => { isQueryPresent: false, loading: false, resultSet: { tableColumns: () => [], tablePivot: () => [] }, - resultVisualization: {}, + resultVisualization, ...props, }, }); @@ -95,6 +98,7 @@ describe('AnalyticsVisualizationPreview', () => { describe('resultSet and visualization is selected', () => { beforeEach(() => { createWrapper({ + title: 'Hello world', isQueryPresent: true, displayType: PANEL_DISPLAY_TYPES.VISUALIZATION, selectedVisualizationType: 'LineChart', @@ -102,9 +106,13 @@ describe('AnalyticsVisualizationPreview', () => { }); it('should render visualization', () => { - expect(wrapper.findByTestId('preview-visualization').attributes('style')).toBe( - `height: ${PANEL_VISUALIZATION_HEIGHT};`, - ); + const preview = wrapper.findByTestId('preview-visualization'); + + expect(preview.attributes('style')).toBe(`height: ${PANEL_VISUALIZATION_HEIGHT};`); + expect(preview.props()).toMatchObject({ + title: 'Hello world', + visualization: resultVisualization, + }); }); }); diff --git a/ee/spec/frontend/analytics/analytics_dashboards/stubs.js b/ee/spec/frontend/analytics/analytics_dashboards/stubs.js new file mode 100644 index 0000000000000..18dfa768fbc38 --- /dev/null +++ b/ee/spec/frontend/analytics/analytics_dashboards/stubs.js @@ -0,0 +1,35 @@ +export const BuilderComponent = { + data() { + return { + resultSet: { + query: () => ({ foo: 'bar' }), + }, + }; + }, + template: '<div><slot></slot></div>', +}; + +export const QueryBuilder = { + data() { + return { + loading: false, + filters: [], + measures: [], + dimensions: [], + timeDimensions: [], + setMeasures: () => {}, + setFilters: () => {}, + addFilters: () => {}, + addDimensions: () => {}, + removeDimensions: () => {}, + setTimeDimensions: () => {}, + removeTimeDimensions: () => {}, + }; + }, + template: ` + <builder-component> + <slot name="builder" v-bind="{measures, dimensions, timeDimensions, setTimeDimensions, removeTimeDimensions, removeDimensions, addDimensions, filters, setMeasures, setFilters, addFilters}"></slot> + <slot v-bind="{loading}"></slot> + </builder-component> + `, +}; diff --git a/ee/spec/frontend/analytics/analytics_dashboards/utils/dashboards_api_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/utils/dashboards_api_spec.js index f616eae9cdeb0..232683ee1903f 100644 --- a/ee/spec/frontend/analytics/analytics_dashboards/utils/dashboards_api_spec.js +++ b/ee/spec/frontend/analytics/analytics_dashboards/utils/dashboards_api_spec.js @@ -10,10 +10,13 @@ import { saveCustomDashboard, getProductAnalyticsVisualizationList, getProductAnalyticsVisualization, + saveProductAnalyticsVisualization, CUSTOM_DASHBOARDS_PATH, PRODUCT_ANALYTICS_VISUALIZATIONS_PATH, CREATE_FILE_ACTION, UPDATE_FILE_ACTION, + CONFIGURATION_FILE_TYPE, + DASHBOARD_BRANCH, } from 'ee/analytics/analytics_dashboards/api/dashboards_api'; import { TEST_CUSTOM_DASHBOARDS_PROJECT, @@ -64,7 +67,9 @@ describe('AnalyticsDashboard', () => { it('get a single dashboard', async () => { const expectedUrl = `${dummyUrlRoot}/${ TEST_CUSTOM_DASHBOARDS_PROJECT.fullPath - }/-/raw/main/${encodeURIComponent(CUSTOM_DASHBOARDS_PATH + 'abc.yml'.replace(/^\//, ''))}`; + }/-/raw/main/${encodeURIComponent( + CUSTOM_DASHBOARDS_PATH + `abc${CONFIGURATION_FILE_TYPE}`.replace(/^\//, ''), + )}`; mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, TEST_CUSTOM_DASHBOARD()); jest.spyOn(axios, 'get'); @@ -95,12 +100,12 @@ describe('AnalyticsDashboard', () => { }); const callPayload = { - branch: 'main', + branch: DASHBOARD_BRANCH, commit_message: isNewFile ? 'Create dashboard abc' : 'Updating dashboard abc', actions: [ { action, - file_path: `${CUSTOM_DASHBOARDS_PATH}${dashboardId}.yml`, + file_path: `${CUSTOM_DASHBOARDS_PATH}${dashboardId}${CONFIGURATION_FILE_TYPE}`, previous_path: undefined, content: 'id: test\n', encoding: 'text', @@ -144,7 +149,7 @@ describe('AnalyticsDashboard', () => { const expectedUrl = `${dummyUrlRoot}/${ TEST_CUSTOM_DASHBOARDS_PROJECT.fullPath }/-/raw/main/${encodeURIComponent( - PRODUCT_ANALYTICS_VISUALIZATIONS_PATH + 'abc.yml'.replace(/^\//, ''), + PRODUCT_ANALYTICS_VISUALIZATIONS_PATH + `abc${CONFIGURATION_FILE_TYPE}`.replace(/^\//, ''), )}`; mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, TEST_CUSTOM_DASHBOARD()); @@ -155,4 +160,41 @@ describe('AnalyticsDashboard', () => { }); }); }); + + describe('visualization save functions', () => { + beforeEach(() => { + jest.spyOn(service, 'commit').mockResolvedValue({ data: {} }); + }); + + it('save a new visualization', async () => { + const visualizationName = 'abc'; + + const result = await saveProductAnalyticsVisualization( + visualizationName, + { id: 'test' }, + TEST_CUSTOM_DASHBOARDS_PROJECT, + ); + + const callPayload = { + branch: DASHBOARD_BRANCH, + commit_message: 'Updating visualization abc', + actions: [ + { + action: 'create', + file_path: `${PRODUCT_ANALYTICS_VISUALIZATIONS_PATH}${visualizationName}${CONFIGURATION_FILE_TYPE}`, + content: 'id: test\n', + encoding: 'text', + }, + ], + start_sha: undefined, + }; + + expect(service.commit).toHaveBeenCalledWith( + TEST_CUSTOM_DASHBOARDS_PROJECT.fullPath, + callPayload, + ); + + expect(result).toEqual({ data: {} }); + }); + }); }); diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js index 29a566e461ec1..d46edb50dd544 100644 --- a/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js +++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js @@ -11,6 +11,7 @@ import { GRIDSTACK_CSS_HANDLE, GRIDSTACK_CELL_HEIGHT, GRIDSTACK_MIN_ROW, + NEW_DASHBOARD_SLUG, } from 'ee/vue_shared/components/customizable_dashboard/constants'; import { loadCSSFile } from '~/lib/utils/css_utils'; import { createAlert } from '~/alert'; @@ -55,7 +56,12 @@ describe('CustomizableDashboard', () => { push: jest.fn(), }; - const createWrapper = (props = {}, loadedDashboard = dashboard, provide = {}) => { + const createWrapper = ( + props = {}, + loadedDashboard = dashboard, + provide = {}, + routeParams = {}, + ) => { const loadDashboard = { ...loadedDashboard }; loadDashboard.default = { ...loadDashboard }; @@ -70,6 +76,9 @@ describe('CustomizableDashboard', () => { }, mocks: { $router, + $route: { + params: routeParams, + }, }, provide, }); @@ -236,6 +245,21 @@ describe('CustomizableDashboard', () => { expect(findEditButton().exists()).toBe(true); }); + describe('when mounted with the $route.editing param', () => { + beforeEach(() => { + createWrapper( + {}, + dashboard, + { glFeatures: { combinedAnalyticsDashboardsEditor: true } }, + { editing: true }, + ); + }); + + it('opens the dashboard in edit mode', () => { + expect(findVisualizationSelector().exists()).toBe(true); + }); + }); + describe('when editing', () => { beforeEach(() => { findEditButton().vm.$emit('click'); @@ -321,7 +345,12 @@ describe('CustomizableDashboard', () => { it('routes to the designer when a "create" event is recieved', async () => { await findVisualizationSelector().vm.$emit('create'); - expect($router.push).toHaveBeenCalledWith({ name: 'visualization-designer' }); + expect($router.push).toHaveBeenCalledWith({ + name: 'visualization-designer', + params: { + dashboard: dashboard.slug, + }, + }); }); }); }); @@ -407,6 +436,17 @@ describe('CustomizableDashboard', () => { expect(findCodeView().exists()).toBe(false); }); + + it('routes to the designer with `dashboard: "new"` when a "create" event is recieved', async () => { + await findVisualizationSelector().vm.$emit('create'); + + expect($router.push).toHaveBeenCalledWith({ + name: 'visualization-designer', + params: { + dashboard: NEW_DASHBOARD_SLUG, + }, + }); + }); }); describe('when saving while editing and the editor is enabled', () => { diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js index c67609493bbe3..2416d29b9a58b 100644 --- a/ee/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js +++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js @@ -24,6 +24,7 @@ const cubeLineChart = { export const dashboard = { id: 'analytics_overview', + slug: 'analytics_overview', title: 'Analytics Overview', panels: [ { diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6e84e9d2e8608..4ca31e9c379d1 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5239,7 +5239,7 @@ msgstr "" msgid "Analytics" msgstr "" -msgid "Analytics|Add to Dashboard" +msgid "Analytics|A visualization with that name already exists." msgstr "" msgid "Analytics|Add visualizations" @@ -5311,9 +5311,15 @@ msgstr "" msgid "Analytics|Edit" msgstr "" +msgid "Analytics|Enter a visualization name" +msgstr "" + msgid "Analytics|Error while saving dashboard" msgstr "" +msgid "Analytics|Error while saving visualization." +msgstr "" + msgid "Analytics|Host" msgstr "" @@ -5323,7 +5329,7 @@ msgstr "" msgid "Analytics|Line Chart" msgstr "" -msgid "Analytics|New Analytics Visualization Title" +msgid "Analytics|New analytics visualization name" msgstr "" msgid "Analytics|New dashboard" @@ -5362,6 +5368,18 @@ msgstr "" msgid "Analytics|Save" msgstr "" +msgid "Analytics|Save and add to Dashboard" +msgstr "" + +msgid "Analytics|Save new visualization" +msgstr "" + +msgid "Analytics|Select a measurement" +msgstr "" + +msgid "Analytics|Select a visualization type" +msgstr "" + msgid "Analytics|Single Statistic" msgstr "" @@ -5374,6 +5392,9 @@ msgstr "" msgid "Analytics|Updating dashboard %{dashboardId}" msgstr "" +msgid "Analytics|Updating visualization %{visualizationName}" +msgstr "" + msgid "Analytics|Users" msgstr "" @@ -5392,6 +5413,9 @@ msgstr "" msgid "Analytics|Visualization Type" msgstr "" +msgid "Analytics|Visualization was saved successfully" +msgstr "" + msgid "Analyze your dependencies for known vulnerabilities." msgstr "" -- GitLab