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