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 8994f1921e876344beac669fdad3e320784c9741..c91511e0131ae413f2ef7367b11b3dd9d4100ffe 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 @@ -13,6 +13,7 @@ import { buildDefaultDashboardFilters, getDashboardConfig, updateApolloCache, + getUniquePanelId, } from 'ee/vue_shared/components/customizable_dashboard/utils'; import { saveCustomDashboard } from 'ee/analytics/analytics_dashboards/api/dashboards_api'; import { @@ -101,6 +102,7 @@ export default { changesSaved: false, alert: null, hasDashboardError: false, + savedPanels: null, }; }, computed: { @@ -170,7 +172,16 @@ export default { return { ...dashboard, - panels: dashboard.panels?.nodes || [], + panels: + // Panel ids need to remain consistent and they are unique to the + // frontend. Thus they don't get saved with GraphQL and we need to + // reference the saved panels array to persist the ids. + this.savedPanels || + dashboard.panels?.nodes?.map((panel) => ({ + ...panel, + id: getUniquePanelId(), + })) || + [], }; }, result() { @@ -269,6 +280,8 @@ export default { isGroup: this.isGroup, }); + this.savedPanels = dashboard.panels; + if (this.isNewDashboard) { // We redirect now to the new route this.$router.push({ 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 a8824d8549a3ae39ec45cac31b82e6c45b0a3f54..f50bb0ebc3868e08960fc9485f43bfa49971ed72 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 @@ -106,7 +106,6 @@ export default { filters: this.defaultFilters, alert: null, visualizationDrawerOpen: false, - dashboardRenderKey: 0, }; }, computed: { @@ -215,8 +214,6 @@ export default { }, resetToInitialDashboard() { this.dashboard = this.createDraftDashboard(this.initialDashboard); - // Update the element key to force re-render - this.dashboardRenderKey += 1; }, onTitleInput(submitting) { this.$emit('title-input', this.dashboard.title, submitting); @@ -318,10 +315,15 @@ export default { panelTestId({ visualization: { slug = '' } }) { return `panel-${slug.replaceAll('_', '-')}`; }, - createVisualizationPanels(visualizations) { + deletePanel(panel) { + const removeIndex = this.dashboard.panels.findIndex((p) => p.id === panel.id); + this.dashboard.panels.splice(removeIndex, 1); + }, + addPanels(visualizations) { this.closeVisualizationDrawer(); - return visualizations.map((viz) => createNewVisualizationPanel(viz)); + const panels = visualizations.map((viz) => createNewVisualizationPanel(viz)); + this.dashboard.panels.push(...panels); }, }, HISTORY_REPLACE_UPDATE_METHOD, @@ -458,8 +460,8 @@ export default { </div> </button> - <gridstack-wrapper :key="dashboardRenderKey" v-model="dashboard" :editing="editing"> - <template #panel="{ panel, deletePanel }"> + <gridstack-wrapper v-model="dashboard" :editing="editing"> + <template #panel="{ panel }"> <panels-base :title="panel.title" :visualization="panel.visualization" @@ -470,17 +472,16 @@ export default { @delete="deletePanel(panel)" /> </template> - <template #drawer="{ addPanels }"> - <available-visualizations-drawer - :visualizations="availableVisualizations.visualizations" - :loading="availableVisualizations.loading" - :has-error="availableVisualizations.hasError" - :open="visualizationDrawerOpen" - @select="(visualizations) => addPanels(createVisualizationPanels(visualizations))" - @close="closeVisualizationDrawer" - /> - </template> </gridstack-wrapper> + + <available-visualizations-drawer + :visualizations="availableVisualizations.visualizations" + :loading="availableVisualizations.loading" + :has-error="availableVisualizations.hasError" + :open="visualizationDrawerOpen" + @select="addPanels" + @close="closeVisualizationDrawer" + /> </div> </div> </div> diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/gridstack_wrapper.vue b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/gridstack_wrapper.vue index 28165c9f7e9d10625270228c2f89bf1c4b2e4de9..c69ac3d463067961a6abf753cf7c34096e62475d 100644 --- a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/gridstack_wrapper.vue +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/gridstack_wrapper.vue @@ -11,7 +11,7 @@ import { GRIDSTACK_MIN_ROW, CURSOR_GRABBING_CLASS, } from './constants'; -import { getUniquePanelId } from './utils'; +import { parsePanelToGridItem } from './utils'; export default { name: 'GridstackWrapper', @@ -28,16 +28,19 @@ export default { }, data() { return { - dashboard: this.createDraftDashboard(this.value), grid: undefined, cssLoaded: false, mounted: false, + gridPanels: [], }; }, computed: { mountedWithCss() { return this.cssLoaded && this.mounted; }, + gridConfig() { + return this.value.panels.map(parsePanelToGridItem); + }, }, watch: { mountedWithCss(mountedWithCss) { @@ -48,6 +51,12 @@ export default { editing(value) { this.grid?.setStatic(!value); }, + gridConfig: { + handler(config) { + this.grid?.load(config); + }, + deep: true, + }, }, mounted() { this.mounted = true; @@ -65,16 +74,34 @@ export default { } }, methods: { - createDraftDashboard(dashboard) { - const draft = cloneWithoutReferences(dashboard); - return { - ...draft, - // Gridstack requires unique panel IDs for mutations - panels: draft.panels.map((panel) => ({ - ...panel, - id: getUniquePanelId(), - })), - }; + async mountGridComponents(panels, options = { scollIntoView: false }) { + // Ensure new panels are always rendered first + await this.$nextTick(); + + panels.forEach((panel) => { + const wrapper = this.$refs.panelWrappers.find((w) => w.id === panel.id); + const widgetContentEl = panel.el.querySelector('.grid-stack-item-content'); + + widgetContentEl.appendChild(wrapper); + }); + + if (options.scrollIntoView) { + const mostRecent = panels[panels.length - 1]; + mostRecent.el.scrollIntoView({ behavior: 'smooth' }); + } + }, + getGridItemForElement(el) { + return this.gridConfig.find((item) => item.id === el.getAttribute('gs-id')); + }, + initGridPanelSlots(gridElements) { + if (!gridElements) return; + + this.gridPanels = gridElements.map((el) => ({ + ...this.getGridItemForElement(el), + el, + })); + + this.mountGridComponents(this.gridPanels); }, initGridStack() { this.grid = GridStack.init({ @@ -86,7 +113,10 @@ export default { columnOpts: { breakpoints: [{ w: breakpoints.md, c: 1 }] }, alwaysShowResizeHandle: true, animate: false, - }); + }).load(this.gridConfig); + + // Sync Vue components array with gridstack items + this.initGridPanelSlots(this.grid.getGridItems()); this.grid.on('dragstart', () => { this.$el.classList.add(CURSOR_GRABBING_CLASS); @@ -95,91 +125,66 @@ export default { this.$el.classList.remove(CURSOR_GRABBING_CLASS); }); this.grid.on('change', (_, items) => { - items.forEach((item) => { - this.updatePanelWithGridStackItem(item); - }); + if (!items) return; + + this.emitLayoutChanges(items); }); this.grid.on('added', (_, items) => { - items.forEach((item) => { - this.updatePanelWithGridStackItem(item); - }); + this.addGridPanels(items); + }); + this.grid.on('removed', (_, items) => { + this.removeGridPanels(items); }); }, - registerNewGridPanelElement(panelId) { - this.grid.makeWidget(`#${panelId}`); - - document.getElementById(panelId)?.scrollIntoView({ behavior: 'smooth' }); - }, - getGridAttribute(panel, attribute) { - const { gridAttributes = {} } = panel; - - return gridAttributes[attribute]; + convertToGridAttributes(gridStackItem) { + return { + yPos: gridStackItem.y, + xPos: gridStackItem.x, + width: gridStackItem.w, + height: gridStackItem.h, + }; }, - deletePanel(panel) { - const panelIndex = this.dashboard.panels.indexOf(panel); - this.dashboard.panels.splice(panelIndex, 1); - - this.grid.removeWidget(document.getElementById(panel.id), false); - - this.emitChange(); + removeGridPanels(items) { + items.forEach((item) => { + const index = this.gridPanels.findIndex((c) => c.id === item.id); + this.gridPanels.splice(index, 1); + // Finally remove the gridstack element + item.el.remove(); + }); }, - async addPanels(panels) { - const panelIds = panels.map(({ id }) => id); - - this.dashboard.panels.push(...panels); + addGridPanels(items) { + const newPanels = items.map(({ grid, ...rest }) => ({ ...rest })); + this.gridPanels.push(...newPanels); - // Wait for the panel elements to render - await this.$nextTick(); - - panelIds.forEach((id) => this.registerNewGridPanelElement(id)); - - this.emitChange(); - }, - updatePanelWithGridStackItem(item) { - const updatedPanel = this.dashboard.panels.find((panel) => panel.id === item.id); - if (updatedPanel) { - updatedPanel.gridAttributes = this.convertToGridAttributes(item); - this.emitChange(); - } + this.mountGridComponents(newPanels, { scollIntoView: true }); }, - emitChange() { - this.$emit('input', this.dashboard); - }, - convertToGridAttributes(gridStackProperties) { - return { - yPos: gridStackProperties.y, - xPos: gridStackProperties.x, - width: gridStackProperties.w, - height: gridStackProperties.h, - }; + emitLayoutChanges(items) { + const newValue = cloneWithoutReferences(this.value); + items.forEach((item) => { + const panel = newValue.panels.find((p) => p.id === item.id); + panel.gridAttributes = { + ...panel.gridAttributes, + ...this.convertToGridAttributes(item), + }; + }); + this.$emit('input', newValue); }, }, }; </script> <template> - <div> - <div class="grid-stack" data-testid="gridstack-grid"> - <div - v-for="panel in dashboard.panels" - :id="panel.id" - :key="panel.id" - :gs-id="panel.id" - :gs-x="getGridAttribute(panel, 'xPos')" - :gs-y="getGridAttribute(panel, 'yPos')" - :gs-h="getGridAttribute(panel, 'height')" - :gs-w="getGridAttribute(panel, 'width')" - :gs-min-h="getGridAttribute(panel, 'minHeight')" - :gs-min-w="getGridAttribute(panel, 'minWidth')" - :gs-max-h="getGridAttribute(panel, 'maxHeight')" - :gs-max-w="getGridAttribute(panel, 'maxWidth')" - class="grid-stack-item" - :class="{ 'gl-cursor-grab': editing }" - data-testid="grid-stack-panel" - > - <slot name="panel" v-bind="{ panel, editing, deletePanel }"></slot> - </div> + <div class="grid-stack" data-testid="gridstack-grid"> + <div + v-for="panel in gridPanels" + :id="panel.id" + ref="panelWrappers" + :key="panel.id" + class="gl-h-full" + :class="{ 'gl-cursor-grab': editing }" + data-testid="grid-stack-panel" + > + <slot name="panel" v-bind="{ panel: panel.props }"></slot> </div> - <slot name="drawer" v-bind="{ addPanels }"></slot> </div> </template> diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue index b3c7c4162981ef4efe948d4c4ffb47824db66a58..0d011d2c181b39da17ab2734760e94c0371f4331 100644 --- a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue @@ -212,7 +212,7 @@ export default { <template> <div :id="popoverId" - class="grid-stack-item-content gl-border gl-rounded-base gl-p-4 gl-bg-white gl-overflow-visible!" + class="grid-stack-item-content gl-border gl-rounded-base gl-p-4 gl-bg-white gl-overflow-visible! gl-h-full" :class="{ 'gl-border-t-2 gl-border-t-solid gl-border-t-red-500': showErrorState, }" @@ -254,6 +254,7 @@ export default { fluid-width toggle-class="gl-ml-1" category="tertiary" + positioning-strategy="fixed" > <template #list-item="{ item }"> <span :data-testId="`panel-action-${item.testId}`"> @@ -296,6 +297,7 @@ export default { :css-classes="['gl-max-w-50p']" :target="popoverId" :delay="$options.PANEL_POPOVER_DELAY" + boundary="viewport" > <gl-sprintf :message="errorPopoverMessage"> <template #link="{ content }"> diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/utils.js b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/utils.js index 68f81d20ffe6a1ce0c537f8af9d682f600b0d574..28136f66e1fc6084006b8717696da5e94cd7073b 100644 --- a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/utils.js +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/utils.js @@ -242,3 +242,33 @@ export const updateApolloCache = ({ }); updateDashboardsListApolloCache({ apolloClient, slug, dashboard, fullPath, isProject, isGroup }); }; + +const filterUndefinedValues = (obj) => { + // eslint-disable-next-line no-unused-vars + return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined)); +}; + +/** + * Parses a dashboard panel config into a GridStack item. + */ +export const parsePanelToGridItem = ({ + gridAttributes: { xPos, yPos, width, height, minHeight, minWidth, maxHeight, maxWidth }, + id, + ...rest +}) => + // GridStack renders undefined layout values so we need to filter them out. + filterUndefinedValues({ + x: xPos, + y: yPos, + w: width, + h: height, + minH: minHeight, + minW: minWidth, + maxH: maxHeight, + maxW: maxWidth, + id, + props: { + id, + ...rest, + }, + }); diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/analytics_dashboard_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/analytics_dashboard_spec.js index f85924e935a4cdacf672988dc3f44debec9c2e02..73dfbc099f64e18b0b30d92fe6302f62c499e31c 100644 --- a/ee/spec/frontend/analytics/analytics_dashboards/components/analytics_dashboard_spec.js +++ b/ee/spec/frontend/analytics/analytics_dashboards/components/analytics_dashboard_spec.js @@ -244,6 +244,20 @@ describe('AnalyticsDashboard', () => { expect(findDashboard().exists()).toBe(true); }); + + it('should add unique panel ids to each panel', async () => { + createWrapper(); + + await waitForPromises(); + + expect(findDashboard().props().initialDashboard.panels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.stringContaining('panel-'), + }), + ]), + ); + }); }); describe('when dashboard fails to load', () => { @@ -422,7 +436,15 @@ describe('AnalyticsDashboard', () => { }); describe('with a valid dashboard', () => { - beforeEach(() => mockSaveDashboardImplementation(() => ({ status: HTTP_STATUS_CREATED }))); + let originalPanels; + + beforeEach(async () => { + await waitForPromises(); + + originalPanels = findDashboard().props().initialDashboard.panels; + + await mockSaveDashboardImplementation(() => ({ status: HTTP_STATUS_CREATED })); + }); it('saves the dashboard and shows a success toast', () => { expect(saveCustomDashboard).toHaveBeenCalledWith({ @@ -449,6 +471,10 @@ describe('AnalyticsDashboard', () => { expect.any(Object), ); }); + + it('persists the original panels array after saving', () => { + expect(findDashboard().props().initialDashboard.panels).toStrictEqual(originalPanels); + }); }); describe('with an invalid dashboard', () => { @@ -590,8 +616,14 @@ describe('AnalyticsDashboard', () => { }); describe('when saving', () => { - beforeEach(() => { - return mockSaveDashboardImplementation(() => ({ status: HTTP_STATUS_CREATED })); + let originalPanels; + + beforeEach(async () => { + await waitForPromises(); + + originalPanels = findDashboard().props().initialDashboard.panels; + + await mockSaveDashboardImplementation(() => ({ status: HTTP_STATUS_CREATED })); }); it('saves the dashboard as a new file', () => { @@ -613,6 +645,10 @@ describe('AnalyticsDashboard', () => { expect.any(Object), ); }); + + it('persists the original panels array after saving', () => { + expect(findDashboard().props().initialDashboard.panels).toStrictEqual(originalPanels); + }); }); }); }); 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 d329cdc467f109ce8be647e06d4dc41636de1a9e..ca0ac3b8c335f5e4bd9c5a63ad07881ee7c1bf00 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 @@ -415,24 +415,6 @@ describe('CustomizableDashboard', () => { }, ); }); - - it.each` - action | confirmed | expectedKey - ${'confirm discard'} | ${true} | ${1} - ${'continue editing'} | ${false} | ${0} - `( - 'sets the gridstack-wrapper render key to "$expectedKey" on $action', - async ({ confirmed, expectedKey }) => { - confirmAction.mockResolvedValue(confirmed); - - expect(findGridstackWrapper().vm.$vnode.key).toBe(0); - - await findCancelButton().vm.$emit('click'); - await waitForPromises(); - - expect(findGridstackWrapper().vm.$vnode.key).toBe(expectedKey); - }, - ); }); }); @@ -485,26 +467,31 @@ describe('CustomizableDashboard', () => { expect(findVisualizationDrawer().props('open')).toBe(false); }); - it('calls the wrapper method to add new panels', () => { - expect(addPanelsMock).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - ...createNewVisualizationPanel(TEST_VISUALIZATION()), - id: expect.stringContaining('panel-'), - }), - ]), - ); + it('adds new panels to the dashboard', () => { + const { panels } = findGridstackWrapper().props().value; + + expect(panels).toHaveLength(3); + expect(panels[2]).toMatchObject({ + ...createNewVisualizationPanel(TEST_VISUALIZATION()), + id: expect.stringContaining('panel-'), + }); }); }); }); describe('add a panel is deleted', () => { + const removePanel = dashboard.panels[0]; + beforeEach(async () => { - await findPanels().at(0).vm.$emit('delete', dashboard.panels[0]); + await findPanels().at(0).vm.$emit('delete', removePanel); }); - it('calls the wrapper method to delete the panel', () => { - expect(deletePanelMock).toHaveBeenCalledWith(dashboard.panels[0]); + it('removes the chosen panel from the dashboard', () => { + const { panels } = findGridstackWrapper().props().value; + const panelIds = panels.map(({ id }) => id); + + expect(panels).toHaveLength(1); + expect(panelIds).not.toContain(removePanel.id); }); }); }); diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/gridstack_wrapper_spec.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/gridstack_wrapper_spec.js index 44b192705c2ef1405a2ed8fc83a480ef17e9e696..fa0387a64ecd41e64e423b03961652f55f1ea8ab 100644 --- a/ee/spec/frontend/vue_shared/components/customizable_dashboard/gridstack_wrapper_spec.js +++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/gridstack_wrapper_spec.js @@ -12,24 +12,29 @@ import { } from 'ee/vue_shared/components/customizable_dashboard/constants'; import { loadCSSFile } from '~/lib/utils/css_utils'; import waitForPromises from 'helpers/wait_for_promises'; +import { parsePanelToGridItem } from 'ee/vue_shared/components/customizable_dashboard/utils'; import { createNewVisualizationPanel } from 'ee/analytics/analytics_dashboards/utils'; import { dashboard, builtinDashboard } from './mock_data'; const mockGridSetStatic = jest.fn(); const mockGridDestroy = jest.fn(); -jest.mock('gridstack', () => ({ - GridStack: { - init: jest.fn(() => { - return { - on: jest.fn(), - destroy: mockGridDestroy, - makeWidget: jest.fn(), - setStatic: mockGridSetStatic, - removeWidget: jest.fn(), - }; - }), - }, -})); +const mockGridLoad = jest.fn(); + +jest.mock('gridstack', () => { + const actualModule = jest.requireActual('gridstack'); + + return { + GridStack: { + init: jest.fn().mockImplementation((config) => { + const instance = actualModule.GridStack.init(config); + instance.load = mockGridLoad.mockImplementation(instance.load); + instance.setStatic = mockGridSetStatic; + instance.destroy = mockGridDestroy; + return instance; + }), + }, + }; +}); jest.mock('~/lib/utils/css_utils', () => ({ loadCSSFile: jest.fn(), @@ -39,7 +44,6 @@ describe('GridstackWrapper', () => { /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */ let wrapper; let panelSlots = []; - let drawerSlot; const createWrapper = (props = {}) => { wrapper = shallowMountExtended(GridstackWrapper, { @@ -51,14 +55,14 @@ describe('GridstackWrapper', () => { panel(data) { panelSlots.push(data); }, - drawer(data) { - drawerSlot = data; - }, }, + attachTo: document.body, }); }; const findGridStackPanels = () => wrapper.findAllByTestId('grid-stack-panel'); + const findGridItemContentById = (panelId) => + wrapper.find(`[gs-id="${panelId}"]`).find('.grid-stack-item-content'); const findPanelById = (panelId) => wrapper.find(`#${panelId}`); afterEach(() => { @@ -87,14 +91,51 @@ describe('GridstackWrapper', () => { }); }); - it('does not render the grab cursor on grid panels', () => { + it('loads the parsed dashboard config', () => { + expect(mockGridLoad).toHaveBeenCalledWith(dashboard.panels.map(parsePanelToGridItem)); + }); + + it('does not render a the grab cursor on grid panels', () => { expect(findGridStackPanels().at(0).classes()).not.toContain('gl-cursor-grab'); }); - it('passes data to drawer slot', () => { - expect(drawerSlot).toStrictEqual({ - addPanels: expect.any(Function), + it('renders a panel once it has been added', async () => { + const newPanel = createNewVisualizationPanel(builtinDashboard.panels[0].visualization); + + expect(findPanelById(newPanel.id).exists()).toBe(false); + + wrapper.setProps({ + value: { + ...dashboard, + panels: [...dashboard.panels, newPanel], + }, }); + + await waitForPromises(); + + const gridItem = findGridItemContentById(newPanel.id); + const panel = findPanelById(newPanel.id); + + expect(panel.element.parentElement).toBe(gridItem.element); + }); + + it('does not render a removed panel', async () => { + const panelToRemove = dashboard.panels[0]; + + expect(findGridStackPanels()).toHaveLength(dashboard.panels.length); + expect(findPanelById(panelToRemove.id).exists()).toBe(true); + + wrapper.setProps({ + value: { + ...dashboard, + panels: dashboard.panels.filter((panel) => panel.id !== panelToRemove.id), + }, + }); + + await waitForPromises(); + + expect(findGridStackPanels()).toHaveLength(dashboard.panels.length - 1); + expect(findPanelById(panelToRemove.id).exists()).toBe(false); }); describe.each(dashboard.panels.map((panel, index) => [panel, index]))( @@ -103,22 +144,21 @@ describe('GridstackWrapper', () => { it('renders a grid panel', () => { const element = findGridStackPanels().at(index); - expect(element.attributes()).toMatchObject({ - 'gs-id': expect.stringContaining('panel-'), - 'gs-h': `${panel.gridAttributes.height}`, - 'gs-w': `${panel.gridAttributes.width}`, - }); + expect(element.attributes().id).toContain('panel-'); }); - it('passes data to the panel slot', () => { - expect(panelSlots[index]).toStrictEqual({ - panel: { - ...dashboard.panels[index], - id: expect.stringContaining('panel-'), - }, - editing: false, - deletePanel: expect.any(Function), - }); + it('sets the panel props on the panel slot', () => { + const { gridAttributes, ...panelProps } = panel; + + expect(panelSlots[index]).toStrictEqual({ panel: panelProps }); + }); + + it("renders the panel inside the grid item's content", async () => { + const gridItem = findGridItemContentById(panel.id); + + await nextTick(); + + expect(findGridStackPanels().at(index).element.parentElement).toBe(gridItem.element); }); }, ); @@ -153,49 +193,46 @@ describe('GridstackWrapper', () => { }); }); - describe('when a panel is updated', () => { - let gridPanel; - - beforeEach(() => { + describe('when the grid changes', () => { + beforeEach(async () => { loadCSSFile.mockResolvedValue(); createWrapper(); - gridPanel = findGridStackPanels().at(0); + await waitForPromises(); - wrapper.vm.updatePanelWithGridStackItem({ - id: gridPanel.attributes('id'), - x: 10, - y: 20, - w: 30, - h: 40, + const gridEl = wrapper.find('.grid-stack').element; + const event = new CustomEvent('change', { + detail: [ + { + id: dashboard.panels[1].id, + x: 10, + y: 20, + w: 30, + h: 40, + }, + ], }); - }); - it('updates the panels grid attributes', () => { - expect(gridPanel.attributes()).toMatchObject({ - 'gs-h': '40', - 'gs-w': '30', - 'gs-x': '10', - 'gs-y': '20', - }); + gridEl.dispatchEvent(event); }); it('emits the changed dashboard object', () => { - expect(wrapper.emitted('input')).toMatchObject([ + expect(wrapper.emitted('input')).toStrictEqual([ [ { ...dashboard, panels: [ + dashboard.panels[0], { - ...dashboard.panels[0], + ...dashboard.panels[1], gridAttributes: { + ...dashboard.panels[1].gridAttributes, xPos: 10, yPos: 20, width: 30, height: 40, }, }, - ...dashboard.panels.slice(1), ], }, ], @@ -203,78 +240,6 @@ describe('GridstackWrapper', () => { }); }); - describe('when panels are added', () => { - let newPanel; - - beforeEach(() => { - loadCSSFile.mockResolvedValue(); - createWrapper(); - - newPanel = createNewVisualizationPanel(builtinDashboard.panels[0].visualization); - }); - - it('adds panels to the dashboard', async () => { - expect(findGridStackPanels().length).toEqual(2); - expect(findPanelById(newPanel.id).exists()).toBe(false); - - drawerSlot.addPanels([newPanel]); - await nextTick(); - - expect(findGridStackPanels().length).toEqual(3); - expect(findPanelById(newPanel.id).exists()).toBe(true); - }); - - it('emits the changed dashboard object', async () => { - drawerSlot.addPanels([newPanel]); - await nextTick(); - - expect(wrapper.emitted('input')).toMatchObject([ - [ - { - ...dashboard, - panels: [...dashboard.panels, newPanel], - }, - ], - ]); - }); - }); - - describe('when a panel is deleted', () => { - let removePanel; - - beforeEach(() => { - loadCSSFile.mockResolvedValue(); - createWrapper(); - - removePanel = panelSlots[0].panel; - }); - - it('should remove the panel from the dashboard', async () => { - expect(findGridStackPanels().length).toEqual(2); - expect(findPanelById(removePanel.id).exists()).toBe(true); - - panelSlots[0].deletePanel(removePanel); - await nextTick(); - - expect(findGridStackPanels().length).toEqual(1); - expect(findPanelById(removePanel.id).exists()).toBe(false); - }); - - it('emits the changed dashboard object', async () => { - panelSlots[0].deletePanel(removePanel); - await nextTick(); - - expect(wrapper.emitted('input')).toMatchObject([ - [ - { - ...dashboard, - panels: dashboard.panels.slice(1), - }, - ], - ]); - }); - }); - describe('when an error occurs while loading the CSS', () => { const sentryError = new Error('Network error'); 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 e3658434d14e53621e37fdeca7adfea97c8c837f..55d1d62fe00b6c29a5c0084e1a6b5155fb947afc 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 @@ -1,4 +1,5 @@ import { __ } from '~/locale'; +import { getUniquePanelId } from 'ee/vue_shared/components/customizable_dashboard/utils'; const cubeLineChart = { type: 'LineChart', @@ -36,14 +37,16 @@ export const dashboard = { gridAttributes: { width: 3, height: 3 }, visualization: cubeLineChart, queryOverrides: null, + id: getUniquePanelId(), }, { title: __('Test B'), - gridAttributes: { width: 2, height: 4 }, + gridAttributes: { width: 2, height: 4, minHeight: 2, minWidth: 2 }, visualization: cubeLineChart, queryOverrides: { limit: 200, }, + id: getUniquePanelId(), }, ], }; @@ -77,6 +80,7 @@ export const builtinDashboard = { gridAttributes: { width: 3, height: 3 }, visualization: cubeLineChart, queryOverrides: {}, + id: getUniquePanelId(), }, ], }; @@ -86,3 +90,20 @@ export const mockDateRangeFilterChangePayload = { endDate: new Date('2016-02-01'), dateRangeOption: 'foo', }; + +export const mockPanel = { + title: __('Test A'), + gridAttributes: { + width: 1, + height: 2, + xPos: 0, + yPos: 3, + minWidth: 1, + minHeight: 2, + maxWidth: 1, + maxHeight: 2, + }, + visualization: cubeLineChart, + queryOverrides: {}, + id: getUniquePanelId(), +}; diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/utils_spec.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/utils_spec.js index f2d25f7cb17615682881d5a5f25115215dce131a..46056736902c3107e6f3059b8830d51878afd586 100644 --- a/ee/spec/frontend/vue_shared/components/customizable_dashboard/utils_spec.js +++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/utils_spec.js @@ -8,6 +8,7 @@ import { getDashboardConfig, updateApolloCache, getVisualizationCategory, + parsePanelToGridItem, } from 'ee/vue_shared/components/customizable_dashboard/utils'; import { parsePikadayDate } from '~/lib/utils/datetime_utility'; import { @@ -30,7 +31,7 @@ import { TEST_CUSTOM_DASHBOARD_GRAPHQL_SUCCESS_RESPONSE, getGraphQLDashboard, } from 'ee_jest/analytics/analytics_dashboards/mock_data'; -import { mockDateRangeFilterChangePayload, dashboard } from './mock_data'; +import { mockDateRangeFilterChangePayload, dashboard, mockPanel } from './mock_data'; const option = DATE_RANGE_OPTIONS[0]; @@ -406,3 +407,29 @@ describe('getVisualizationCategory', () => { expect(getVisualizationCategory({ type })).toBe(category); }); }); + +describe('parsePanelToGridItem', () => { + it('parses all panel configs to GridStack format', () => { + const { gridAttributes, ...rest } = mockPanel; + + expect(parsePanelToGridItem(mockPanel)).toStrictEqual({ + x: gridAttributes.xPos, + y: gridAttributes.yPos, + w: gridAttributes.width, + h: gridAttributes.height, + minH: gridAttributes.minHeight, + minW: gridAttributes.minWidth, + maxH: gridAttributes.maxHeight, + maxW: gridAttributes.maxWidth, + id: mockPanel.id, + props: rest, + }); + }); + + it('filters out props with undefined values', () => { + const local = { ...mockPanel }; + local.id = undefined; + + expect(Object.keys(parsePanelToGridItem(local))).not.toContain('id'); + }); +});