From c8a0473780910cf86873f5a29d9b6a8aed9b4400 Mon Sep 17 00:00:00 2001
From: Tim Zallmann <tzallmann@gitlab.com>
Date: Wed, 11 Jan 2023 15:10:58 +0000
Subject: [PATCH] First version for editing Dashboards

---
 .../components/analytics_dashboard.vue        |  16 +-
 .../customizable_dashboard.vue                | 180 +++++++++++++++---
 .../components/analytics_dashboard_spec.js    |   5 +-
 .../customizable_dashboard_spec.js            |  88 +++++++--
 .../customizable_dashboard/mock_data.js       |   2 +
 locale/gitlab.pot                             |   9 +
 package.json                                  |   2 +-
 yarn.lock                                     |   8 +-
 8 files changed, 251 insertions(+), 59 deletions(-)

diff --git a/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue b/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue
index ec46a660b6397..8b38203d50712 100644
--- a/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue
+++ b/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue
@@ -44,6 +44,8 @@ export default {
       const dashboard = await DASHBOARD_JSONS[this.$route.params.id]();
       this.dashboard = await this.importDashboardDependencies(dashboard);
     }
+
+    this.availableVisualizations = [];
   },
   methods: {
     // TODO: Remove in https://gitlab.com/gitlab-org/gitlab/-/issues/382551
@@ -70,15 +72,11 @@ export default {
 <template>
   <div>
     <template v-if="dashboard">
-      <section
-        class="gl-display-flex gl-align-items-center gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
-      >
-        <h3 class="gl-my-0 flex-fill">{{ dashboard.title }}</h3>
-        <router-link to="/" class="gl-button btn btn-default btn-md">
-          {{ __('Go back') }}
-        </router-link>
-      </section>
-      <customizable-dashboard :widgets="dashboard.widgets" />
+      <customizable-dashboard
+        :initial-dashboard="dashboard"
+        :get-visualization="importVisualization"
+        :available-visualizations="availableVisualizations"
+      />
     </template>
     <gl-loading-icon v-else size="lg" class="gl-my-7" />
   </div>
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 87e27ac816849..187f494761822 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
@@ -1,6 +1,7 @@
 <script>
 import { GridStack } from 'gridstack';
 import * as Sentry from '@sentry/browser';
+import { GlButton } from '@gitlab/ui';
 import { loadCSSFile } from '~/lib/utils/css_utils';
 import { createAlert } from '~/flash';
 import { s__, sprintf } from '~/locale';
@@ -10,30 +11,42 @@ import { GRIDSTACK_MARGIN, GRIDSTACK_CSS_HANDLE } from './constants';
 export default {
   name: 'CustomizableDashboard',
   components: {
+    GlButton,
     WidgetsBase,
   },
   props: {
-    editable: {
-      type: Boolean,
+    initialDashboard: {
+      type: Object,
+      required: true,
+      default: () => {},
+    },
+    getVisualization: {
+      type: Function,
       required: false,
-      default: false,
+      default: () => {},
     },
-    widgets: {
+    availableVisualizations: {
       type: Array,
-      required: false,
-      default: () => [],
+      required: true,
     },
   },
   data() {
     return {
+      dashboard: { ...this.initialDashboard },
+      grid: undefined,
       cssLoaded: false,
       mounted: true,
+      editing: false,
+      showCode: false,
     };
   },
   computed: {
     loaded() {
       return this.cssLoaded && this.mounted;
     },
+    showCodeVariant() {
+      return this.showCode ? 'confirm' : 'default';
+    },
   },
   watch: {
     cssLoaded() {
@@ -53,6 +66,12 @@ export default {
   },
   mounted() {
     this.mounted = true;
+
+    const wrappers = document.querySelectorAll('.container-fluid.container-limited');
+
+    wrappers.forEach((el) => {
+      el.classList.remove('container-limited');
+    });
   },
   unmounted() {
     this.mounted = false;
@@ -60,10 +79,22 @@ export default {
   methods: {
     initGridStack() {
       if (this.loaded) {
-        GridStack.init({
-          staticGrid: !this.editable,
+        this.grid = GridStack.init({
+          staticGrid: !this.editing,
           margin: GRIDSTACK_MARGIN,
           handle: GRIDSTACK_CSS_HANDLE,
+          minRow: 1,
+        });
+
+        this.grid.on('change', (event, items) => {
+          items.forEach((item) => {
+            this.updateWidgetWithGridStackItem(item);
+          });
+        });
+        this.grid.on('added', (event, items) => {
+          items.forEach((item) => {
+            this.updateWidgetWithGridStackItem(item);
+          });
         });
       }
     },
@@ -72,6 +103,52 @@ export default {
 
       return gridAttributes[attribute];
     },
+    convertToGridAttributes(gridStackProperties) {
+      return {
+        yPos: gridStackProperties.y,
+        xPos: gridStackProperties.x,
+        width: gridStackProperties.w,
+        height: gridStackProperties.h,
+      };
+    },
+    startEdit() {
+      if (!this.editing) {
+        this.editing = true;
+        if (this.grid) this.grid.setStatic(false);
+      }
+    },
+    async saveEdit() {
+      // Only showing code until we can actually save
+      this.toggleCodeDisplay();
+    },
+    cancelEdit() {
+      this.editing = false;
+      if (this.grid) this.grid.setStatic(true);
+    },
+    async toggleCodeDisplay() {
+      this.showCode = !this.showCode;
+      if (!this.showCode) {
+        setTimeout(() => {
+          this.initGridStack();
+        }, 200);
+      } else {
+        this.grid.destroy();
+      }
+    },
+    updateWidgetWithGridStackItem(item) {
+      const updatedWidget = this.dashboard.widgets.find(
+        (element) => element.id === Number(item.id),
+      );
+      if (updatedWidget) {
+        updatedWidget.gridAttributes = this.convertToGridAttributes(item);
+      }
+      const selectedDefaultWidget = this.dashboard.default.widgets.find(
+        (element) => element.id === Number(item.id),
+      );
+      if (selectedDefaultWidget) {
+        selectedDefaultWidget.gridAttributes = this.convertToGridAttributes(item);
+      }
+    },
     handleWidgetError(widgetTitle, error) {
       createAlert({
         message: sprintf(
@@ -87,29 +164,72 @@ export default {
 </script>
 
 <template>
-  <div class="grid-stack-container gl-py-6">
-    <div class="grid-stack">
-      <div
-        v-for="(widget, index) in widgets"
-        :key="index"
-        :gs-id="index"
-        :gs-x="getGridAttribute(widget, 'xPos')"
-        :gs-y="getGridAttribute(widget, 'yPos')"
-        :gs-h="getGridAttribute(widget, 'height')"
-        :gs-w="getGridAttribute(widget, 'width')"
-        :gs-min-h="getGridAttribute(widget, 'minHeight')"
-        :gs-min-w="getGridAttribute(widget, 'minWidth')"
-        :gs-max-h="getGridAttribute(widget, 'maxHeight')"
-        :gs-max-w="getGridAttribute(widget, 'maxWidth')"
-        class="grid-stack-item"
-        data-testid="grid-stack-widget"
+  <div>
+    <section
+      class="gl-display-flex gl-align-items-center gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+    >
+      <h3 class="gl-my-0 flex-fill">{{ dashboard.title }}</h3>
+      <gl-button
+        v-if="!editing"
+        icon="pencil"
+        class="gl-mr-2"
+        data-testid="dashboard-edit-btn"
+        @click="startEdit"
+        >{{ s__('ProductAnalytics|Edit') }}</gl-button
+      >
+      <gl-button
+        v-if="editing"
+        :variant="showCodeVariant"
+        icon="code"
+        class="gl-mr-2"
+        data-testid="dashboard-code-btn"
+        @click="toggleCodeDisplay"
+        >{{ s__('ProductAnalytics|Code') }}</gl-button
+      >
+      <gl-button
+        v-if="editing"
+        class="gl-mr-2"
+        category="secondary"
+        data-testid="dashboard-cancel-edit-btn"
+        @click="cancelEdit"
+        >{{ s__('ProductAnalytics|Cancel Edit') }}</gl-button
       >
-        <widgets-base
-          :title="widget.title"
-          :visualization="widget.visualization"
-          :query-overrides="widget.queryOverrides"
-          @error="handleWidgetError(widget.title, $event)"
-        />
+      <router-link v-if="!editing" to="/" class="gl-button btn btn-default btn-md">
+        {{ s__('ProductAnalytics|Go back') }}
+      </router-link>
+    </section>
+    <div class="grid-stack-container gl-display-flex gl-bg-gray-10">
+      <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-py-6">
+        <div v-if="!showCode" class="grid-stack">
+          <div
+            v-for="(widget, index) in dashboard.widgets"
+            :id="'widget-' + widget.id"
+            :key="index"
+            :gs-id="widget.id"
+            :gs-x="getGridAttribute(widget, 'xPos')"
+            :gs-y="getGridAttribute(widget, 'yPos')"
+            :gs-h="getGridAttribute(widget, 'height')"
+            :gs-w="getGridAttribute(widget, 'width')"
+            :gs-min-h="getGridAttribute(widget, 'minHeight')"
+            :gs-min-w="getGridAttribute(widget, 'minWidth')"
+            :gs-max-h="getGridAttribute(widget, 'maxHeight')"
+            :gs-max-w="getGridAttribute(widget, 'maxWidth')"
+            class="grid-stack-item"
+            data-testid="grid-stack-widget"
+          >
+            <widgets-base
+              :title="widget.title"
+              :visualization="widget.visualization"
+              :query-overrides="widget.queryOverrides"
+              @error="handleWidgetError(widget.title, $event)"
+            />
+          </div>
+        </div>
+        <div v-if="showCode" class="gl-m-4">
+          <pre
+            class="code highlight gl-display-flex"
+          ><code data-testid="dashboard-code">{{ dashboard.default }}</code></pre>
+        </div>
       </div>
     </div>
   </div>
diff --git a/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js b/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js
index a3c47e92531ce..43bfcad3a0799 100644
--- a/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js
+++ b/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js
@@ -42,10 +42,7 @@ describe('AnalyticsDashboard', () => {
         dashboard,
       });
 
-      expect(findDashboard().props()).toStrictEqual({
-        widgets: dashboard.widgets,
-        editable: false,
-      });
+      expect(findDashboard().props().initialDashboard).toStrictEqual(dashboard);
     });
 
     it('should render the loading icon while fetching data', async () => {
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 57e58a55818c0..2ee275ca941de 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
@@ -1,5 +1,6 @@
 import { GridStack } from 'gridstack';
 import * as Sentry from '@sentry/browser';
+import { RouterLinkStub } from '@vue/test-utils';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue';
 import WidgetsBase from 'ee/vue_shared/components/customizable_dashboard/widgets_base.vue';
@@ -15,7 +16,12 @@ import { dashboard } from './mock_data';
 jest.mock('~/flash');
 jest.mock('gridstack', () => ({
   GridStack: {
-    init: jest.fn(),
+    init: jest.fn(() => {
+      return {
+        on: jest.fn(),
+        destroy: jest.fn(),
+      };
+    }),
   },
 }));
 
@@ -29,26 +35,32 @@ describe('CustomizableDashboard', () => {
   const sentryError = new Error('Network error');
 
   const createWrapper = (props = {}) => {
+    dashboard.default = { ...dashboard };
+
     wrapper = shallowMountExtended(CustomizableDashboard, {
       propsData: {
-        editable: false,
-        widgets: [],
+        initialDashboard: dashboard,
+        availableVisualizations: [],
         ...props,
       },
+      stubs: {
+        RouterLink: RouterLinkStub,
+      },
     });
   };
 
   const findGridStackWidgets = () => wrapper.findAllByTestId('grid-stack-widget');
   const findWidgets = () => wrapper.findAllComponents(WidgetsBase);
+  const findEditButton = () => wrapper.findByTestId('dashboard-edit-btn');
+  const findCancelEditButton = () => wrapper.findByTestId('dashboard-cancel-edit-btn');
+  const findCodeButton = () => wrapper.findByTestId('dashboard-code-btn');
 
   describe('when being created an error occurs while loading the CSS', () => {
     beforeEach(() => {
       jest.spyOn(Sentry, 'captureException');
       loadCSSFile.mockRejectedValue(sentryError);
 
-      createWrapper({
-        widgets: dashboard.widgets,
-      });
+      createWrapper();
     });
 
     it('reports the error to sentry', async () => {
@@ -61,21 +73,21 @@ describe('CustomizableDashboard', () => {
     beforeEach(() => {
       loadCSSFile.mockResolvedValue();
 
-      createWrapper({
-        widgets: dashboard.widgets,
-      });
+      createWrapper();
     });
 
     it('sets up GridStack', () => {
       expect(GridStack.init).toHaveBeenCalledWith({
         staticGrid: true,
         margin: GRIDSTACK_MARGIN,
+        minRow: 1,
         handle: GRIDSTACK_CSS_HANDLE,
       });
     });
 
     it.each(
       dashboard.widgets.map((widget, index) => [
+        widget.id,
         widget.title,
         widget.visualization,
         widget.gridAttributes,
@@ -84,7 +96,7 @@ describe('CustomizableDashboard', () => {
       ]),
     )(
       'should render the widget for %s',
-      (title, visualization, gridAttributes, queryOverrides, index) => {
+      (id, title, visualization, gridAttributes, queryOverrides, index) => {
         expect(findWidgets().at(index).props()).toMatchObject({
           title,
           visualization,
@@ -92,7 +104,7 @@ describe('CustomizableDashboard', () => {
         });
 
         expect(findGridStackWidgets().at(index).attributes()).toMatchObject({
-          'gs-id': `${index}`,
+          'gs-id': `${id}`,
           'gs-h': `${gridAttributes.height}`,
           'gs-w': `${gridAttributes.width}`,
         });
@@ -110,5 +122,59 @@ describe('CustomizableDashboard', () => {
         error,
       });
     });
+
+    it('shows Edit Button', () => {
+      expect(findEditButton().exists()).toBe(true);
+    });
+  });
+
+  describe('when editing', () => {
+    beforeEach(() => {
+      loadCSSFile.mockResolvedValue();
+
+      createWrapper();
+
+      findEditButton().vm.$emit('click');
+    });
+
+    it('shows Code Button', () => {
+      expect(wrapper.vm.editing).toBe(true);
+      expect(findCodeButton().exists()).toBe(true);
+    });
+
+    it('updates widgets when their values change', async () => {
+      await wrapper.vm.updateWidgetWithGridStackItem({ id: 1, x: 10, y: 20, w: 30, h: 40 });
+
+      expect(findGridStackWidgets().at(0).attributes()).toMatchObject({
+        id: 'widget-1',
+        'gs-h': '40',
+        'gs-w': '30',
+        'gs-x': '10',
+        'gs-y': '20',
+      });
+    });
+
+    it('clicking Code Button will show code', async () => {
+      await findCodeButton().vm.$emit('click');
+
+      expect(wrapper.vm.showCode).toBe(true);
+      expect(wrapper.findByTestId('dashboard-code').exists()).toBe(true);
+    });
+
+    it('clicking twice on Code Button will show dashboard', async () => {
+      await findCodeButton().vm.$emit('click');
+      await findCodeButton().vm.$emit('click');
+
+      expect(wrapper.vm.showCode).toBe(false);
+      expect(wrapper.findByTestId('dashboard-code').exists()).toBe(false);
+    });
+
+    it('shows Cancel Edit Button', () => {
+      expect(findCancelEditButton().exists()).toBe(true);
+    });
+
+    it('shows no Edit Button', () => {
+      expect(findEditButton().exists()).toBe(false);
+    });
   });
 });
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 b1d1d7476559b..c2560c6c86804 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
@@ -26,12 +26,14 @@ export const dashboard = {
   title: 'Analytics Overview',
   widgets: [
     {
+      id: 1,
       title: __('Test A'),
       gridAttributes: { width: 3, height: 3 },
       visualization: cubeLineChart,
       queryOverrides: {},
     },
     {
+      id: 2,
       title: __('Test B'),
       gridAttributes: { width: 2, height: 4 },
       visualization: cubeLineChart,
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 013ba979c3ba1..9ec660da16190 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -31815,6 +31815,9 @@ msgstr ""
 msgid "ProductAnalytics|Browser Family"
 msgstr ""
 
+msgid "ProductAnalytics|Cancel Edit"
+msgstr ""
+
 msgid "ProductAnalytics|Choose a chart type on the right"
 msgstr ""
 
@@ -31851,6 +31854,9 @@ msgstr ""
 msgid "ProductAnalytics|Dimensions"
 msgstr ""
 
+msgid "ProductAnalytics|Edit"
+msgstr ""
+
 msgid "ProductAnalytics|Event Type"
 msgstr ""
 
@@ -31869,6 +31875,9 @@ msgstr ""
 msgid "ProductAnalytics|Feature usage"
 msgstr ""
 
+msgid "ProductAnalytics|Go back"
+msgstr ""
+
 msgid "ProductAnalytics|Host"
 msgstr ""
 
diff --git a/package.json b/package.json
index 900f7ec276e16..f911bd022ce56 100644
--- a/package.json
+++ b/package.json
@@ -133,7 +133,7 @@
     "fuzzaldrin-plus": "^0.6.0",
     "graphql": "^15.7.2",
     "graphql-tag": "^2.11.0",
-    "gridstack": "^7.0.0",
+    "gridstack": "^7.1.2",
     "highlight.js": "^11.5.1",
     "immer": "^9.0.15",
     "ipaddr.js": "^1.9.1",
diff --git a/yarn.lock b/yarn.lock
index 3c28081dd1474..547d7da1c44ac 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6507,10 +6507,10 @@ graphql@^15.7.2:
   resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.7.2.tgz#85ab0eeb83722977151b3feb4d631b5f2ab287ef"
   integrity sha512-AnnKk7hFQFmU/2I9YSQf3xw44ctnSFCfp3zE0N6W174gqe9fWG/2rKaKxROK7CcI3XtERpjEKFqts8o319Kf7A==
 
-gridstack@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-7.0.0.tgz#2d00b28efa8d22a8b9ad2640c8ab64b494bbfdc9"
-  integrity sha512-iBts/PRuqg6OQvdpv7A84p3RROxzXVSKjM3SJHrdl2pdDZKmIpGo2oxjdCHv6l+SzU2EuptcHd1Rqouocwl1Cg==
+gridstack@^7.1.2:
+  version "7.1.2"
+  resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-7.1.2.tgz#288ccccf786e0440094a48d5f8d654064fb18566"
+  integrity sha512-vc0sHheyOCKe2VE9JgNlNjHroaYJAbOsl/R4sF6TY5WqKTa6owJz4pj3D+W3QQZ43zS18yttLw6m+R9UNuoC3A==
 
 gzip-size@^6.0.0:
   version "6.0.0"
-- 
GitLab