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