From d8d4708dca89371deb65d260c22e5a8c8441d3c4 Mon Sep 17 00:00:00 2001 From: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:34:03 +0530 Subject: [PATCH] feat: dashboard promql table chart column ordering (#10210) Design at: https://github.com/openobserve/designs/blob/main/dashboards/promql-table-chart-column-order/design.md ### **PR Type** Enhancement ___ ### **Description** - Extend panel schema with column_order & sticky_first_column - Use applyColumnOrdering in TableConverter for ordering - Add ColumnOrderPopUp & config UI in PromQLChartConfig - Update TableRenderer to apply sticky & ordered columns ___ ### Diagram Walkthrough ```mermaid flowchart LR UI["PromQLChartConfig config UI"] Popup["ColumnOrderPopUp"] Config["PanelConfig update"] Converter["TableConverter.applyColumnOrdering"] Renderer["TableRenderer sticky styles"] UI -- "open popup" --> Popup Popup -- "save order" --> Config Config -- "order & sticky flags" --> Converter Converter -- "ordered columns" --> Renderer ```

File Walkthrough

Relevant files
Configuration changes
2 files
useDashboardPanel.ts
Add sticky_first_column and column_order defaults               
+2/-0     
mod.rs
Extend PanelConfig with new ordering fields                           
+4/-0     
Enhancement
4 files
convertPromQLTableChart.ts
Implement applyColumnOrdering and integrate ordering         
+35/-3   
ColumnOrderPopUp.vue
Add drag-drop popup for column ordering                                   
+349/-0 
PromQLChartConfig.vue
Integrate column order controls in config UI                         
+124/-0 
TableRenderer.vue
Apply sticky styling and ordering in table renderer           
+193/-59
Documentation
1 files
en.json
Add translation key for Column Order                                         
+1/-0     
___ --------- Co-authored-by: github-actions[bot] --- src/config/src/meta/dashboards/v8/mod.rs | 4 + .../addPanel/ColumnOrderPopUp.spec.ts | 680 ++++++++++++ .../dashboards/addPanel/ColumnOrderPopUp.vue | 348 ++++++ .../addPanel/PromQLChartConfig.spec.ts | 994 ++++++++++++++++++ .../dashboards/addPanel/PromQLChartConfig.vue | 95 +- .../dashboards/panels/TableRenderer.spec.ts | 623 +++++++++++ .../dashboards/panels/TableRenderer.vue | 131 ++- web/src/composables/useDashboardPanel.ts | 2 + web/src/composables/useStickyColumns.spec.ts | 575 ++++++++++ web/src/composables/useStickyColumns.ts | 136 +++ web/src/locales/languages/de.json | 9 +- web/src/locales/languages/en.json | 7 + web/src/locales/languages/es.json | 9 +- web/src/locales/languages/fr.json | 9 +- web/src/locales/languages/it.json | 9 +- web/src/locales/languages/ja.json | 9 +- web/src/locales/languages/ko.json | 9 +- web/src/locales/languages/nl.json | 9 +- web/src/locales/languages/pt.json | 9 +- web/src/locales/languages/tr.json | 9 +- web/src/locales/languages/zh.json | 9 +- .../promql/convertPromQLTableChart.spec.ts | 514 +++++++++ .../promql/convertPromQLTableChart.ts | 38 +- 23 files changed, 4166 insertions(+), 71 deletions(-) create mode 100644 web/src/components/dashboards/addPanel/ColumnOrderPopUp.spec.ts create mode 100644 web/src/components/dashboards/addPanel/ColumnOrderPopUp.vue create mode 100644 web/src/components/dashboards/addPanel/PromQLChartConfig.spec.ts create mode 100644 web/src/composables/useStickyColumns.spec.ts create mode 100644 web/src/composables/useStickyColumns.ts diff --git a/src/config/src/meta/dashboards/v8/mod.rs b/src/config/src/meta/dashboards/v8/mod.rs index 021e30463b..d3be140784 100644 --- a/src/config/src/meta/dashboards/v8/mod.rs +++ b/src/config/src/meta/dashboards/v8/mod.rs @@ -434,6 +434,10 @@ pub struct PanelConfig { hidden_columns: Option>, #[serde(skip_serializing_if = "Option::is_none")] sticky_columns: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + sticky_first_column: Option, + #[serde(skip_serializing_if = "Option::is_none")] + column_order: Option>, } #[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize, ToSchema, Default)] diff --git a/web/src/components/dashboards/addPanel/ColumnOrderPopUp.spec.ts b/web/src/components/dashboards/addPanel/ColumnOrderPopUp.spec.ts new file mode 100644 index 0000000000..62f8df4df3 --- /dev/null +++ b/web/src/components/dashboards/addPanel/ColumnOrderPopUp.spec.ts @@ -0,0 +1,680 @@ +// Copyright 2023 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin"; +import { Dialog, Notify } from "quasar"; +import ColumnOrderPopUp from "./ColumnOrderPopUp.vue"; +import i18n from "@/locales"; +import store from "@/test/unit/helpers/store"; + +installQuasar({ + plugins: [Dialog, Notify], +}); + +describe("ColumnOrderPopUp", () => { + let wrapper: any; + + const defaultProps = { + columnOrder: [] as string[], + availableColumns: [] as string[], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + const createWrapper = (props = {}) => { + return mount(ColumnOrderPopUp, { + props: { + ...defaultProps, + ...props, + }, + global: { + plugins: [i18n, store], + mocks: { + $t: (key: string) => key, + }, + }, + }); + }; + + describe("Component Rendering", () => { + it("should render the popup with correct structure", () => { + wrapper = createWrapper(); + + expect( + wrapper.find('[data-test="dashboard-column-order-popup"]').exists(), + ).toBe(true); + expect(wrapper.find(".column-order-popup").exists()).toBe(true); + }); + + it("should render header with title and close button", () => { + wrapper = createWrapper(); + + expect(wrapper.text()).toContain("Column Order"); + expect( + wrapper.find('[data-test="dashboard-column-order-cancel"]').exists(), + ).toBe(true); + }); + + it("should render footer with cancel and save buttons", () => { + wrapper = createWrapper(); + + expect( + wrapper.find('[data-test="dashboard-column-order-cancel-btn"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="dashboard-column-order-save-btn"]').exists(), + ).toBe(true); + }); + + it("should render description text", () => { + wrapper = createWrapper(); + + expect(wrapper.text()).toContain("Customize the display order of columns"); + }); + }); + + describe("Empty State", () => { + it("should show empty state when no columns are provided", () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: [], + }); + + expect(wrapper.text()).toContain("No columns ordered"); + expect(wrapper.text()).toContain( + "Columns will be displayed in their default order", + ); + expect(wrapper.findComponent({ name: "QIcon" }).exists()).toBe(true); + }); + + it("should not show draggable list in empty state", () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: [], + }); + + expect( + wrapper.find('[data-test="dashboard-column-order-drag"]').exists(), + ).toBe(false); + }); + }); + + describe("Column Initialization", () => { + it("should initialize with provided columnOrder when available", async () => { + wrapper = createWrapper({ + columnOrder: ["column1", "column2"], + availableColumns: ["column1", "column2", "column3"], + }); + + await flushPromises(); + + expect(wrapper.vm.editColumnOrder).toEqual([ + "column1", + "column2", + "column3", + ]); + }); + + it("should initialize with availableColumns in natural order when columnOrder is empty", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["zebra", "apple", "banana"], + }); + + await flushPromises(); + + expect(wrapper.vm.editColumnOrder).toEqual(["zebra", "apple", "banana"]); + }); + + it("should filter out columns not in availableColumns", async () => { + wrapper = createWrapper({ + columnOrder: ["column1", "column2", "column4"], + availableColumns: ["column1", "column2", "column3"], + }); + + await flushPromises(); + + expect(wrapper.vm.editColumnOrder).toEqual([ + "column1", + "column2", + "column3", + ]); + expect(wrapper.vm.editColumnOrder).not.toContain("column4"); + }); + + it("should add remaining columns in natural order after ordered columns", async () => { + wrapper = createWrapper({ + columnOrder: ["column2"], + availableColumns: ["column2", "column1", "column3"], + }); + + await flushPromises(); + + expect(wrapper.vm.editColumnOrder).toEqual([ + "column2", + "column1", + "column3", + ]); + }); + }); + + describe("Column Order Display", () => { + it("should render all columns in the list", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2", "column3"], + }); + + await flushPromises(); + + expect(wrapper.text()).toContain("column1"); + expect(wrapper.text()).toContain("column2"); + expect(wrapper.text()).toContain("column3"); + }); + + it("should render column numbers correctly", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + expect(wrapper.text()).toContain("1."); + expect(wrapper.text()).toContain("2."); + }); + + it("should render drag handles for each column", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + const dragHandles = wrapper.findAll('[data-test^="column-order-drag-handle"]'); + expect(dragHandles.length).toBe(2); + }); + + it("should render move up/down buttons for each column", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + const moveUpButtons = wrapper.findAll('[data-test^="column-order-move-up"]'); + const moveDownButtons = wrapper.findAll('[data-test^="column-order-move-down"]'); + + expect(moveUpButtons.length).toBe(2); + expect(moveDownButtons.length).toBe(2); + }); + }); + + describe("Move Column Up", () => { + it("should move column up when move up button is clicked", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2", "column3"], + }); + + await flushPromises(); + + // Initially: column1, column2, column3 + expect(wrapper.vm.editColumnOrder[1]).toBe("column2"); + + // Click move up button for column2 (index 1) + wrapper.vm.moveColumnUp(1); + await flushPromises(); + + // Now should be: column2, column1, column3 + expect(wrapper.vm.editColumnOrder[0]).toBe("column2"); + expect(wrapper.vm.editColumnOrder[1]).toBe("column1"); + }); + + it("should not move first column up", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + const initialOrder = [...wrapper.vm.editColumnOrder]; + + // Try to move first column up + wrapper.vm.moveColumnUp(0); + await flushPromises(); + + // Order should remain the same + expect(wrapper.vm.editColumnOrder).toEqual(initialOrder); + }); + + it("should disable move up button for first column", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + const firstMoveUpButton = wrapper.findComponent('[data-test="column-order-move-up-0"]'); + expect(firstMoveUpButton.props("disable")).toBe(true); + }); + }); + + describe("Move Column Down", () => { + it("should move column down when move down button is clicked", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2", "column3"], + }); + + await flushPromises(); + + // Initially: column1, column2, column3 + expect(wrapper.vm.editColumnOrder[0]).toBe("column1"); + + // Click move down button for column1 (index 0) + wrapper.vm.moveColumnDown(0); + await flushPromises(); + + // Now should be: column2, column1, column3 + expect(wrapper.vm.editColumnOrder[0]).toBe("column2"); + expect(wrapper.vm.editColumnOrder[1]).toBe("column1"); + }); + + it("should not move last column down", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + const initialOrder = [...wrapper.vm.editColumnOrder]; + + // Try to move last column down + wrapper.vm.moveColumnDown(1); + await flushPromises(); + + // Order should remain the same + expect(wrapper.vm.editColumnOrder).toEqual(initialOrder); + }); + + it("should disable move down button for last column", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + const lastMoveDownButton = wrapper.findComponent('[data-test="column-order-move-down-1"]'); + expect(lastMoveDownButton.props("disable")).toBe(true); + }); + }); + + describe("Draggable Functionality", () => { + it("should render draggable component when columns exist", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + expect( + wrapper.find('[data-test="dashboard-column-order-drag"]').exists(), + ).toBe(true); + }); + + it("should have correct drag options", () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1"], + }); + + expect(wrapper.vm.dragOptions).toEqual({ + animation: 200, + handle: ".drag-handle", + ghostClass: "ghost", + }); + }); + + it("should update editColumnOrder when draggable model changes", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2", "column3"], + }); + + await flushPromises(); + + // Simulate drag and drop by directly updating the model + wrapper.vm.editColumnOrder = ["column3", "column1", "column2"]; + await flushPromises(); + + expect(wrapper.vm.editColumnOrder).toEqual([ + "column3", + "column1", + "column2", + ]); + }); + }); + + describe("Cancel Functionality", () => { + it("should emit cancel event when cancel button is clicked", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1"], + }); + + await wrapper + .find('[data-test="dashboard-column-order-cancel-btn"]') + .trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("cancel"); + expect(wrapper.emitted("cancel")).toHaveLength(1); + }); + + it("should emit cancel event when close icon is clicked", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1"], + }); + + await wrapper + .find('[data-test="dashboard-column-order-cancel"]') + .trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("cancel"); + expect(wrapper.emitted("cancel")).toHaveLength(1); + }); + + it("should not modify original columnOrder on cancel", async () => { + const originalOrder = ["column1", "column2"]; + wrapper = createWrapper({ + columnOrder: originalOrder, + availableColumns: ["column1", "column2", "column3"], + }); + + await flushPromises(); + + // Modify the edit order + wrapper.vm.moveColumnDown(0); + await flushPromises(); + + // Cancel + await wrapper + .find('[data-test="dashboard-column-order-cancel-btn"]') + .trigger("click"); + + // Original should not be modified + expect(originalOrder).toEqual(["column1", "column2"]); + }); + }); + + describe("Save Functionality", () => { + it("should emit save event with edited column order", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + await wrapper + .find('[data-test="dashboard-column-order-save-btn"]') + .trigger("click"); + + expect(wrapper.emitted()).toHaveProperty("save"); + expect(wrapper.emitted("save")).toHaveLength(1); + expect(wrapper.emitted("save")[0]).toEqual([["column1", "column2"]]); + }); + + it("should emit save with modified order after moves", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2", "column3"], + }); + + await flushPromises(); + + // Move column2 up + wrapper.vm.moveColumnUp(1); + await flushPromises(); + + await wrapper + .find('[data-test="dashboard-column-order-save-btn"]') + .trigger("click"); + + const savedOrder = wrapper.emitted("save")[0][0]; + expect(savedOrder[0]).toBe("column2"); + expect(savedOrder[1]).toBe("column1"); + }); + + it("should emit save with empty array when no columns", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: [], + }); + + await flushPromises(); + + await wrapper + .find('[data-test="dashboard-column-order-save-btn"]') + .trigger("click"); + + expect(wrapper.emitted("save")[0]).toEqual([[]]); + }); + }); + + describe("Props Changes", () => { + it("should re-initialize when availableColumns prop changes", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + expect(wrapper.vm.editColumnOrder).toEqual(["column1", "column2"]); + + // Update availableColumns prop + await wrapper.setProps({ + availableColumns: ["column1", "column2", "column3"], + }); + await flushPromises(); + + expect(wrapper.vm.editColumnOrder).toEqual([ + "column1", + "column2", + "column3", + ]); + }); + + it("should maintain columnOrder priority after availableColumns change", async () => { + wrapper = createWrapper({ + columnOrder: ["column2", "column1"], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + expect(wrapper.vm.editColumnOrder[0]).toBe("column2"); + + // Update availableColumns to add column3 + await wrapper.setProps({ + availableColumns: ["column1", "column2", "column3"], + }); + await flushPromises(); + + // column2 should still be first, then column1, then column3 + expect(wrapper.vm.editColumnOrder[0]).toBe("column2"); + expect(wrapper.vm.editColumnOrder[1]).toBe("column1"); + expect(wrapper.vm.editColumnOrder[2]).toBe("column3"); + }); + }); + + describe("UI Styling and Classes", () => { + it("should apply correct styling classes", () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1"], + }); + + expect(wrapper.find(".column-order-popup").exists()).toBe(true); + expect(wrapper.find(".scrollable-content").exists()).toBe(true); + expect(wrapper.find(".sticky-footer").exists()).toBe(true); + }); + + it("should render column rows with correct structure", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1"], + }); + + await flushPromises(); + + const columnRow = wrapper.find(".column-order-row"); + expect(columnRow.exists()).toBe(true); + expect(columnRow.find(".drag-handle").exists()).toBe(true); + expect(columnRow.find(".column-number").exists()).toBe(true); + expect(columnRow.find(".column-name").exists()).toBe(true); + expect(columnRow.find(".column-actions").exists()).toBe(true); + }); + + it("should have tooltips on move buttons", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + const tooltips = wrapper.findAllComponents({ name: "QTooltip" }); + expect(tooltips.length).toBeGreaterThan(0); + }); + }); + + describe("Edge Cases", () => { + it("should handle single column", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1"], + }); + + await flushPromises(); + + expect(wrapper.vm.editColumnOrder).toEqual(["column1"]); + + // Move up button should be disabled + const moveUpBtn = wrapper.findComponent('[data-test="column-order-move-up-0"]'); + expect(moveUpBtn.props("disable")).toBe(true); + + // Move down button should be disabled + const moveDownBtn = wrapper.findComponent('[data-test="column-order-move-down-0"]'); + expect(moveDownBtn.props("disable")).toBe(true); + }); + + it("should handle columnOrder with duplicates gracefully", async () => { + wrapper = createWrapper({ + columnOrder: ["column1", "column1", "column2"], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + // The component uses the columnOrder as-is when filtering + // Since both column1's are in availableColumns, both are kept initially + // But when combined with remaining columns, duplicates naturally resolve + expect(wrapper.vm.editColumnOrder.includes("column1")).toBe(true); + expect(wrapper.vm.editColumnOrder.includes("column2")).toBe(true); + }); + + it("should handle empty columnOrder with populated availableColumns", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["zebra", "apple", "banana"], + }); + + await flushPromises(); + + // Should keep natural order from availableColumns + expect(wrapper.vm.editColumnOrder).toEqual(["zebra", "apple", "banana"]); + }); + + it("should handle columnOrder with all columns in different order", async () => { + wrapper = createWrapper({ + columnOrder: ["column3", "column1", "column2"], + availableColumns: ["column1", "column2", "column3"], + }); + + await flushPromises(); + + expect(wrapper.vm.editColumnOrder).toEqual([ + "column3", + "column1", + "column2", + ]); + }); + }); + + describe("Accessibility", () => { + it("should have proper data-test attributes for testing", async () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1", "column2"], + }); + + await flushPromises(); + + expect( + wrapper.find('[data-test="dashboard-column-order-popup"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="dashboard-column-order-cancel"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="dashboard-column-order-save-btn"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="dashboard-column-order-cancel-btn"]').exists(), + ).toBe(true); + }); + + it("should have button labels for screen readers", () => { + wrapper = createWrapper({ + columnOrder: [], + availableColumns: ["column1"], + }); + + const saveBtn = wrapper.find('[data-test="dashboard-column-order-save-btn"]'); + const cancelBtn = wrapper.find('[data-test="dashboard-column-order-cancel-btn"]'); + + expect(saveBtn.text()).toContain("Save"); + expect(cancelBtn.text()).toContain("Cancel"); + }); + }); +}); diff --git a/web/src/components/dashboards/addPanel/ColumnOrderPopUp.vue b/web/src/components/dashboards/addPanel/ColumnOrderPopUp.vue new file mode 100644 index 0000000000..5549128b8b --- /dev/null +++ b/web/src/components/dashboards/addPanel/ColumnOrderPopUp.vue @@ -0,0 +1,348 @@ + + + + + + + diff --git a/web/src/components/dashboards/addPanel/PromQLChartConfig.spec.ts b/web/src/components/dashboards/addPanel/PromQLChartConfig.spec.ts new file mode 100644 index 0000000000..5948b39e4e --- /dev/null +++ b/web/src/components/dashboards/addPanel/PromQLChartConfig.spec.ts @@ -0,0 +1,994 @@ +// Copyright 2023 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin"; +import { Dialog, Notify } from "quasar"; +import PromQLChartConfig from "./PromQLChartConfig.vue"; +import i18n from "@/locales"; +import store from "@/test/unit/helpers/store"; + +installQuasar({ + plugins: [Dialog, Notify], +}); + +// Mock useDashboardPanel +vi.mock("../../../composables/useDashboardPanel", () => ({ + default: vi.fn(() => ({ + dashboardPanelData: { + data: { + config: {}, + queries: [], + }, + meta: { + streamFields: { + groupedFields: [], + }, + }, + }, + })), +})); + +describe("PromQLChartConfig", () => { + let wrapper: any; + let mockDashboardPanelData: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Reset mock data + mockDashboardPanelData = { + data: { + config: {}, + queries: [], + }, + meta: { + streamFields: { + groupedFields: [], + }, + }, + }; + + // Setup mock to return the mock data + const useDashboardPanel = vi.mocked( + await import("../../../composables/useDashboardPanel"), + ).default; + useDashboardPanel.mockReturnValue({ + dashboardPanelData: mockDashboardPanelData, + } as any); + }); + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + const createWrapper = (props = {}) => { + return mount(PromQLChartConfig, { + props: { + chartType: "table", + ...props, + }, + global: { + plugins: [i18n, store], + mocks: { + $t: (key: string) => key, + }, + provide: { + dashboardPanelDataPageKey: "dashboard", + }, + }, + }); + }; + + describe("Component Rendering", () => { + it("should render the component", () => { + wrapper = createWrapper(); + expect(wrapper.find(".promql-chart-config").exists()).toBe(true); + }); + + it("should show aggregation config for supported chart types", () => { + const supportedTypes = ["pie", "donut", "geomap", "maps"]; + + supportedTypes.forEach((type) => { + wrapper = createWrapper({ chartType: type }); + expect( + wrapper.find('[data-test="dashboard-config-aggregation"]').exists(), + ).toBe(true); + wrapper.unmount(); + }); + }); + + it("should not show aggregation config for unsupported chart types", () => { + wrapper = createWrapper({ chartType: "table" }); + expect( + wrapper.find('[data-test="dashboard-config-aggregation"]').exists(), + ).toBe(false); + }); + }); + + describe("Aggregation Function", () => { + it("should render aggregation options", () => { + wrapper = createWrapper({ chartType: "pie" }); + + expect(wrapper.vm.aggregationOptions).toHaveLength(9); + expect(wrapper.vm.aggregationOptions[0].value).toBe("last"); + expect(wrapper.vm.aggregationOptions[1].value).toBe("first"); + }); + + it("should default to 'last' aggregation", () => { + wrapper = createWrapper({ chartType: "pie" }); + expect(wrapper.vm.aggregationValue).toBe("last"); + }); + + it("should update aggregation value", async () => { + wrapper = createWrapper({ chartType: "pie" }); + + // Set the value directly on the mock object + mockDashboardPanelData.data.config.aggregation = "max"; + await flushPromises(); + + expect(mockDashboardPanelData.data.config.aggregation).toBe("max"); + }); + }); + + describe("GeoMap Configuration", () => { + it("should render geomap config for geomap chart type", () => { + wrapper = createWrapper({ chartType: "geomap" }); + + expect( + wrapper.find('[data-test="dashboard-config-geo-lat-label"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="dashboard-config-geo-lon-label"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="dashboard-config-geo-weight-label"]').exists(), + ).toBe(true); + }); + + it("should not render geomap config for other chart types", () => { + wrapper = createWrapper({ chartType: "table" }); + + expect( + wrapper.find('[data-test="dashboard-config-geo-lat-label"]').exists(), + ).toBe(false); + }); + + it("should default geomap labels", () => { + wrapper = createWrapper({ chartType: "geomap" }); + + expect(wrapper.vm.geoLatLabel).toBe("latitude"); + expect(wrapper.vm.geoLonLabel).toBe("longitude"); + expect(wrapper.vm.geoWeightLabel).toBe("weight"); + }); + + it("should update geomap labels", async () => { + wrapper = createWrapper({ chartType: "geomap" }); + + wrapper.vm.geoLatLabel = "lat"; + wrapper.vm.geoLonLabel = "lon"; + wrapper.vm.geoWeightLabel = "w"; + await flushPromises(); + + expect(mockDashboardPanelData.data.config.lat_label).toBe("lat"); + expect(mockDashboardPanelData.data.config.lon_label).toBe("lon"); + expect(mockDashboardPanelData.data.config.weight_label).toBe("w"); + }); + }); + + describe("Maps Configuration", () => { + it("should render maps config for maps chart type", () => { + wrapper = createWrapper({ chartType: "maps" }); + + expect( + wrapper.find('[data-test="dashboard-config-maps-name-label"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="dashboard-config-maps-type"]').exists(), + ).toBe(true); + }); + + it("should not render maps config for other chart types", () => { + wrapper = createWrapper({ chartType: "table" }); + + expect( + wrapper.find('[data-test="dashboard-config-maps-name-label"]').exists(), + ).toBe(false); + }); + + it("should default maps configuration", () => { + wrapper = createWrapper({ chartType: "maps" }); + + expect(wrapper.vm.mapsNameLabel).toBe("name"); + expect(wrapper.vm.mapsMapType).toBe("world"); + }); + + it("should update maps configuration", async () => { + wrapper = createWrapper({ chartType: "maps" }); + + wrapper.vm.mapsNameLabel = "country"; + await flushPromises(); + + expect(mockDashboardPanelData.data.config.name_label).toBe("country"); + }); + }); + + describe("Table Configuration - PromQL Table Mode", () => { + it("should render table config for table chart type", () => { + wrapper = createWrapper({ chartType: "table" }); + + expect( + wrapper.find('[data-test="dashboard-config-promql-table-mode"]').exists(), + ).toBe(true); + }); + + it("should have correct table mode options", () => { + wrapper = createWrapper({ chartType: "table" }); + + expect(wrapper.vm.promqlTableModeOptions).toHaveLength(3); + expect(wrapper.vm.promqlTableModeOptions[0].value).toBe("single"); + expect(wrapper.vm.promqlTableModeOptions[1].value).toBe( + "expanded_timeseries", + ); + expect(wrapper.vm.promqlTableModeOptions[2].value).toBe("all"); + }); + + it("should default to 'single' mode", () => { + wrapper = createWrapper({ chartType: "table" }); + expect(wrapper.vm.promqlTableMode).toBe("single"); + }); + + it("should update table mode", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.promqlTableMode = "all"; + await flushPromises(); + + expect(mockDashboardPanelData.data.config.promql_table_mode).toBe("all"); + }); + }); + + describe("Table Aggregations", () => { + it("should show aggregations selector in all mode", async () => { + // Set mode BEFORE creating wrapper + mockDashboardPanelData.data.config.promql_table_mode = "all"; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + expect( + wrapper.find('[data-test="dashboard-config-table-aggregations"]').exists(), + ).toBe(true); + }); + + it("should not show aggregations selector in single mode", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.promqlTableMode = "single"; + await flushPromises(); + + expect( + wrapper.find('[data-test="dashboard-config-table-aggregations"]').exists(), + ).toBe(false); + }); + + it("should default to last aggregation", () => { + wrapper = createWrapper({ chartType: "table" }); + expect(wrapper.vm.tableAggregations).toEqual(["last"]); + }); + + it("should update table aggregations", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.tableAggregations = ["min", "max", "avg"]; + await flushPromises(); + + expect(mockDashboardPanelData.data.config.table_aggregations).toEqual([ + "min", + "max", + "avg", + ]); + }); + + it("should format aggregations display correctly", async () => { + // Test single aggregation + mockDashboardPanelData.data.config.table_aggregations = ["last"]; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getTableAggregationsDisplay).toBe("last"); + wrapper.unmount(); + + // Test multiple aggregations - recreate wrapper with new data + mockDashboardPanelData.data.config.table_aggregations = ["last", "min", "max"]; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getTableAggregationsDisplay).toBe("last (+2 more)"); + }); + }); + + describe("Column Filters", () => { + it("should show column filters in all mode", async () => { + mockDashboardPanelData.data.config.promql_table_mode = "all"; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + expect(wrapper.text()).toContain("Column Filters"); + expect( + wrapper.find('[data-test="dashboard-config-visible-columns"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="dashboard-config-hidden-columns"]').exists(), + ).toBe(true); + }); + + it("should show column filters in expanded_timeseries mode", async () => { + mockDashboardPanelData.data.config.promql_table_mode = "expanded_timeseries"; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + expect(wrapper.text()).toContain("Column Filters"); + }); + + it("should not show column filters in single mode", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.promqlTableMode = "single"; + await flushPromises(); + + expect( + wrapper.find('[data-test="dashboard-config-visible-columns"]').exists(), + ).toBe(false); + }); + + it("should update visible columns", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.visibleColumns = ["job", "instance"]; + await flushPromises(); + + expect(mockDashboardPanelData.data.config.visible_columns).toEqual([ + "job", + "instance", + ]); + }); + + it("should update hidden columns", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.hiddenColumns = ["__name__"]; + await flushPromises(); + + expect(mockDashboardPanelData.data.config.hidden_columns).toEqual([ + "__name__", + ]); + }); + + it("should format visible columns display correctly", async () => { + // Test empty + mockDashboardPanelData.data.config.visible_columns = []; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getVisibleColumnsDisplay).toBe(""); + wrapper.unmount(); + + // Test single column + mockDashboardPanelData.data.config.visible_columns = ["job"]; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getVisibleColumnsDisplay).toBe("job"); + wrapper.unmount(); + + // Test multiple columns + mockDashboardPanelData.data.config.visible_columns = ["job", "instance", "status"]; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getVisibleColumnsDisplay).toBe("job (+2 more)"); + }); + + it("should format hidden columns display correctly", async () => { + // Test empty + mockDashboardPanelData.data.config.hidden_columns = []; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getHiddenColumnsDisplay).toBe(""); + wrapper.unmount(); + + // Test single column + mockDashboardPanelData.data.config.hidden_columns = ["__name__"]; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getHiddenColumnsDisplay).toBe("__name__"); + wrapper.unmount(); + + // Test multiple columns + mockDashboardPanelData.data.config.hidden_columns = ["__name__", "le", "quantile"]; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getHiddenColumnsDisplay).toBe("__name__ (+2 more)"); + }); + }); + + describe("Sticky Columns", () => { + it("should show sticky columns config in all mode", async () => { + mockDashboardPanelData.data.config.promql_table_mode = "all"; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + expect(wrapper.text()).toContain("Sticky Columns"); + expect( + wrapper.find('[data-test="dashboard-config-sticky-first-column"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="dashboard-config-sticky-columns"]').exists(), + ).toBe(true); + }); + + it("should show sticky columns config in expanded_timeseries mode", async () => { + mockDashboardPanelData.data.config.promql_table_mode = "expanded_timeseries"; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + expect(wrapper.text()).toContain("Sticky Columns"); + }); + + it("should not show sticky columns in single mode", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.promqlTableMode = "single"; + await flushPromises(); + + expect( + wrapper.find('[data-test="dashboard-config-sticky-first-column"]').exists(), + ).toBe(false); + }); + + it("should update sticky first column", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.stickyFirstColumn = true; + await flushPromises(); + + expect(mockDashboardPanelData.data.config.sticky_first_column).toBe(true); + }); + + it("should clear sticky columns when sticky first column is enabled", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.stickyColumns = ["job", "instance"]; + wrapper.vm.stickyFirstColumn = true; + await flushPromises(); + + expect(mockDashboardPanelData.data.config.sticky_columns).toBeUndefined(); + }); + + it("should update sticky columns", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.stickyColumns = ["job"]; + await flushPromises(); + + expect(mockDashboardPanelData.data.config.sticky_columns).toEqual(["job"]); + }); + + it("should disable sticky columns selector when sticky first column is enabled", async () => { + wrapper = createWrapper({ chartType: "table" }); + + mockDashboardPanelData.data.config.promql_table_mode = "all"; + mockDashboardPanelData.data.config.sticky_first_column = true; + await wrapper.vm.$nextTick(); + await flushPromises(); + + const stickyColumnsSelect = wrapper.findComponent({ name: "QSelect" }); + // Check if the component exists and has the disable prop + const disabledSelect = wrapper.findAll('[data-test="dashboard-config-sticky-columns"]').find(el => { + return el.attributes('disable') !== undefined; + }); + expect(disabledSelect || stickyColumnsSelect.exists()).toBeTruthy(); + }); + + it("should format sticky columns display correctly", async () => { + // Test empty + mockDashboardPanelData.data.config.sticky_columns = []; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getStickyColumnsDisplay).toBe(""); + wrapper.unmount(); + + // Test single column + mockDashboardPanelData.data.config.sticky_columns = ["job"]; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getStickyColumnsDisplay).toBe("job"); + wrapper.unmount(); + + // Test multiple columns + mockDashboardPanelData.data.config.sticky_columns = ["job", "instance"]; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getStickyColumnsDisplay).toBe("job (+1 more)"); + }); + }); + + describe("Column Order", () => { + it("should show column order config in all mode", async () => { + mockDashboardPanelData.data.config.promql_table_mode = "all"; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + expect( + wrapper.find('[data-test="dashboard-config-column-order-button"]').exists(), + ).toBe(true); + }); + + it("should show column order config in expanded_timeseries mode", async () => { + mockDashboardPanelData.data.config.promql_table_mode = "expanded_timeseries"; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + expect( + wrapper.find('[data-test="dashboard-config-column-order-button"]').exists(), + ).toBe(true); + }); + + it("should not show column order in single mode", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.promqlTableMode = "single"; + await flushPromises(); + + expect( + wrapper.find('[data-test="dashboard-config-column-order-button"]').exists(), + ).toBe(false); + }); + + it("should open column order popup when button is clicked", async () => { + mockDashboardPanelData.data.config.promql_table_mode = "all"; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + const button = wrapper.find('[data-test="dashboard-config-column-order-button"]'); + await button.trigger("click"); + await wrapper.vm.$nextTick(); + await flushPromises(); + + expect(wrapper.vm.showColumnOrderPopup).toBe(true); + }); + + it("should close column order popup on cancel", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.openColumnOrderPopup(); + await flushPromises(); + expect(wrapper.vm.showColumnOrderPopup).toBe(true); + + wrapper.vm.closeColumnOrderPopup(); + await flushPromises(); + expect(wrapper.vm.showColumnOrderPopup).toBe(false); + }); + + it("should save column order and close popup", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.openColumnOrderPopup(); + await flushPromises(); + + wrapper.vm.saveColumnOrder(["column1", "column2"]); + await flushPromises(); + + expect(mockDashboardPanelData.data.config.column_order).toEqual([ + "column1", + "column2", + ]); + expect(wrapper.vm.showColumnOrderPopup).toBe(false); + }); + + it("should update column order correctly", async () => { + // Test empty + mockDashboardPanelData.data.config.column_order = []; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.columnOrder).toEqual([]); + wrapper.unmount(); + + // Test single column + mockDashboardPanelData.data.config.column_order = ["job"]; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.columnOrder).toEqual(["job"]); + wrapper.unmount(); + + // Test multiple columns + mockDashboardPanelData.data.config.column_order = ["job", "instance", "status"]; + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.columnOrder).toEqual(["job", "instance", "status"]); + }); + }); + + describe("Available Column Options", () => { + it("should collect fields from all queries", async () => { + // Modify mock BEFORE creating wrapper + Object.assign(mockDashboardPanelData, { + data: { + config: {}, + queries: [ + { fields: { stream: "stream1" } }, + { fields: { stream: "stream2" } }, + ], + }, + meta: { + streamFields: { + groupedFields: [ + { + name: "stream1", + schema: [{ name: "field1" }, { name: "field2" }], + }, + { + name: "stream2", + schema: [{ name: "field2" }, { name: "field3" }], + }, + ], + }, + }, + }); + + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + // Access availableColumnOptions through filter function + const updateFn = vi.fn((fn) => fn()); + wrapper.vm.filterVisibleColumns("", updateFn); + + const options = wrapper.vm.visibleColumnsFilteredOptions; + expect(options).toContain("field1"); + expect(options).toContain("field2"); + expect(options).toContain("field3"); + // Should have unique fields + expect(options.filter((f: string) => f === "field2").length).toBe(1); + }); + + it("should return sorted field names", async () => { + // Update mockDashboardPanelData before creating wrapper + Object.assign(mockDashboardPanelData, { + data: { + config: {}, + queries: [{ fields: { stream: "stream1" } }], + }, + meta: { + streamFields: { + groupedFields: [ + { + name: "stream1", + schema: [ + { name: "zebra" }, + { name: "apple" }, + { name: "banana" }, + ], + }, + ], + }, + }, + }); + + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + // Access availableColumnOptions through filter function + const updateFn = vi.fn((fn) => fn()); + wrapper.vm.filterVisibleColumns("", updateFn); + + expect(wrapper.vm.visibleColumnsFilteredOptions).toEqual([ + "apple", + "banana", + "zebra", + ]); + }); + + it("should return empty array when no stream fields", async () => { + Object.assign(mockDashboardPanelData, { + data: { + config: {}, + queries: [], + }, + meta: { + streamFields: { + groupedFields: undefined, + }, + }, + }); + + wrapper = createWrapper({ chartType: "table" }); + await wrapper.vm.$nextTick(); + await flushPromises(); + + // Access availableColumnOptions through filter function + const updateFn = vi.fn((fn) => fn()); + wrapper.vm.filterVisibleColumns("", updateFn); + + expect(wrapper.vm.visibleColumnsFilteredOptions).toEqual([]); + }); + }); + + describe("Filter Functions", () => { + it("should filter visible columns", async () => { + mockDashboardPanelData.data.queries = [ + { fields: { stream: "stream1" } }, + ]; + mockDashboardPanelData.meta.streamFields.groupedFields = [ + { + name: "stream1", + schema: [ + { name: "job" }, + { name: "instance" }, + { name: "status" }, + ], + }, + ]; + + wrapper = createWrapper({ chartType: "table" }); + await flushPromises(); + + const updateFn = vi.fn((fn) => fn()); + wrapper.vm.filterVisibleColumns("job", updateFn); + + expect(updateFn).toHaveBeenCalled(); + expect(wrapper.vm.visibleColumnsFilteredOptions).toContain("job"); + expect(wrapper.vm.visibleColumnsFilteredOptions).not.toContain("instance"); + }); + + it("should filter hidden columns", async () => { + mockDashboardPanelData.data.queries = [ + { fields: { stream: "stream1" } }, + ]; + mockDashboardPanelData.meta.streamFields.groupedFields = [ + { + name: "stream1", + schema: [ + { name: "job" }, + { name: "__name__" }, + ], + }, + ]; + + wrapper = createWrapper({ chartType: "table" }); + await flushPromises(); + + const updateFn = vi.fn((fn) => fn()); + wrapper.vm.filterHiddenColumns("__name__", updateFn); + + expect(updateFn).toHaveBeenCalled(); + expect(wrapper.vm.hiddenColumnsFilteredOptions).toContain("__name__"); + expect(wrapper.vm.hiddenColumnsFilteredOptions).not.toContain("job"); + }); + + it("should filter sticky columns", async () => { + mockDashboardPanelData.data.queries = [ + { fields: { stream: "stream1" } }, + ]; + mockDashboardPanelData.meta.streamFields.groupedFields = [ + { + name: "stream1", + schema: [ + { name: "job" }, + { name: "instance" }, + ], + }, + ]; + + wrapper = createWrapper({ chartType: "table" }); + await flushPromises(); + + const updateFn = vi.fn((fn) => fn()); + wrapper.vm.filterStickyColumns("instance", updateFn); + + expect(updateFn).toHaveBeenCalled(); + expect(wrapper.vm.stickyColumnsFilteredOptions).toContain("instance"); + expect(wrapper.vm.stickyColumnsFilteredOptions).not.toContain("job"); + }); + + it("should show all options when filter is empty", async () => { + mockDashboardPanelData.data.queries = [ + { fields: { stream: "stream1" } }, + ]; + mockDashboardPanelData.meta.streamFields.groupedFields = [ + { + name: "stream1", + schema: [ + { name: "job" }, + { name: "instance" }, + ], + }, + ]; + + wrapper = createWrapper({ chartType: "table" }); + await flushPromises(); + + const updateFn = vi.fn((fn) => fn()); + wrapper.vm.filterVisibleColumns("", updateFn); + + expect(wrapper.vm.visibleColumnsFilteredOptions).toHaveLength(2); + }); + }); + + describe("Create Column Value", () => { + it("should create new column value when valid", () => { + wrapper = createWrapper({ chartType: "table" }); + + const doneFn = vi.fn(); + wrapper.vm.createColumnValue(" new_column ", doneFn); + + expect(doneFn).toHaveBeenCalledWith("new_column"); + }); + + it("should not create column value when empty", () => { + wrapper = createWrapper({ chartType: "table" }); + + const doneFn = vi.fn(); + wrapper.vm.createColumnValue(" ", doneFn); + + expect(doneFn).not.toHaveBeenCalled(); + }); + }); + + describe("Filtered Available Columns", () => { + it("should return filtered columns based on visible_columns", () => { + mockDashboardPanelData.data.queries = [ + { fields: { stream: "stream1" } }, + ]; + mockDashboardPanelData.meta.streamFields.groupedFields = [ + { + name: "stream1", + schema: [ + { name: "field1" }, + { name: "field2" }, + { name: "field3" }, + ], + }, + ]; + + wrapper = createWrapper({ chartType: "table" }); + wrapper.vm.visibleColumns = ["field1", "field2"]; + + expect(wrapper.vm.filteredAvailableColumns).toEqual(["field1", "field2"]); + }); + + it("should return filtered columns based on hidden_columns", () => { + mockDashboardPanelData.data.queries = [ + { fields: { stream: "stream1" } }, + ]; + mockDashboardPanelData.meta.streamFields.groupedFields = [ + { + name: "stream1", + schema: [ + { name: "field1" }, + { name: "field2" }, + { name: "field3" }, + ], + }, + ]; + + wrapper = createWrapper({ chartType: "table" }); + wrapper.vm.hiddenColumns = ["field3"]; + + const filtered = wrapper.vm.filteredAvailableColumns; + expect(filtered).toContain("field1"); + expect(filtered).toContain("field2"); + expect(filtered).not.toContain("field3"); + }); + + it("should prioritize visible_columns over hidden_columns", () => { + mockDashboardPanelData.data.queries = [ + { fields: { stream: "stream1" } }, + ]; + mockDashboardPanelData.meta.streamFields.groupedFields = [ + { + name: "stream1", + schema: [ + { name: "field1" }, + { name: "field2" }, + { name: "field3" }, + ], + }, + ]; + + wrapper = createWrapper({ chartType: "table" }); + wrapper.vm.visibleColumns = ["field1"]; + wrapper.vm.hiddenColumns = ["field2"]; + + expect(wrapper.vm.filteredAvailableColumns).toEqual(["field1"]); + }); + + it("should return all columns when no filters", () => { + mockDashboardPanelData.data.queries = [ + { fields: { stream: "stream1" } }, + ]; + mockDashboardPanelData.meta.streamFields.groupedFields = [ + { + name: "stream1", + schema: [ + { name: "field1" }, + { name: "field2" }, + ], + }, + ]; + + wrapper = createWrapper({ chartType: "table" }); + + expect(wrapper.vm.filteredAvailableColumns).toEqual(["field1", "field2"]); + }); + }); + + describe("Edge Cases", () => { + it("should handle undefined config", () => { + mockDashboardPanelData.data.config = undefined; + + wrapper = createWrapper({ chartType: "table" }); + + // Should not throw errors + expect(wrapper.vm.promqlTableMode).toBe("single"); + }); + + it("should initialize config when setting values", async () => { + mockDashboardPanelData.data.config = undefined; + + wrapper = createWrapper({ chartType: "pie" }); + + wrapper.vm.aggregationValue = "max"; + await flushPromises(); + + expect(mockDashboardPanelData.data.config).toBeDefined(); + expect(mockDashboardPanelData.data.config.aggregation).toBe("max"); + }); + + it("should clear values when set to empty array", async () => { + wrapper = createWrapper({ chartType: "table" }); + + wrapper.vm.visibleColumns = ["field1"]; + await flushPromises(); + expect(mockDashboardPanelData.data.config.visible_columns).toEqual(["field1"]); + + wrapper.vm.visibleColumns = []; + await flushPromises(); + expect(mockDashboardPanelData.data.config.visible_columns).toBeUndefined(); + }); + }); + + describe("Tooltips", () => { + it("should render info tooltips for configurations", async () => { + wrapper = createWrapper({ chartType: "pie" }); + + const tooltips = wrapper.findAllComponents({ name: "QTooltip" }); + expect(tooltips.length).toBeGreaterThan(0); + }); + + it("should have tooltip for aggregation function", () => { + wrapper = createWrapper({ chartType: "pie" }); + + expect(wrapper.html()).toContain("Aggregation Function"); + }); + }); +}); diff --git a/web/src/components/dashboards/addPanel/PromQLChartConfig.vue b/web/src/components/dashboards/addPanel/PromQLChartConfig.vue index b1c4c62cb5..9c623b66fc 100644 --- a/web/src/components/dashboards/addPanel/PromQLChartConfig.vue +++ b/web/src/components/dashboards/addPanel/PromQLChartConfig.vue @@ -393,7 +393,9 @@ along with this program. If not, see . size="lg" > + + + @@ -472,9 +506,13 @@ along with this program. If not, see . import { defineComponent, computed, ref, inject, watch } from "vue"; import { useI18n } from "vue-i18n"; import useDashboardPanelData from "../../../composables/useDashboardPanel"; +import ColumnOrderPopUp from "./ColumnOrderPopUp.vue"; export default defineComponent({ name: "PromQLChartConfig", + components: { + ColumnOrderPopUp, + }, props: { chartType: { type: String, @@ -826,6 +864,55 @@ export default defineComponent({ return `${selected[0]} (+${selected.length - 1} more)`; }); + // Column order configuration + const columnOrder = computed({ + get: () => dashboardPanelData.data.config?.column_order || [], + set: (value: string[]) => { + if (!dashboardPanelData.data.config) { + dashboardPanelData.data.config = {}; + } + dashboardPanelData.data.config.column_order = + value && value.length > 0 ? value : undefined; + }, + }); + + // Column order popup state + const showColumnOrderPopup = ref(false); + + // Get filtered available columns based on visible/hidden column settings + const filteredAvailableColumns = computed(() => { + const allColumns = availableColumnOptions.value; + const visible = visibleColumns.value || []; + const hidden = hiddenColumns.value || []; + + // If visible_columns is specified, only show those columns + if (visible.length > 0) { + return allColumns.filter((col) => visible.includes(col)); + } + // If hidden_columns is specified, hide those columns + else if (hidden.length > 0) { + return allColumns.filter((col) => !hidden.includes(col)); + } + // Otherwise show all columns + return allColumns; + }); + + // Open column order popup + const openColumnOrderPopup = () => { + showColumnOrderPopup.value = true; + }; + + // Close column order popup + const closeColumnOrderPopup = () => { + showColumnOrderPopup.value = false; + }; + + // Save column order from popup + const saveColumnOrder = (newOrder: string[]) => { + columnOrder.value = newOrder; + showColumnOrderPopup.value = false; + }; + return { t, aggregationOptions, @@ -856,6 +943,12 @@ export default defineComponent({ stickyFirstColumn, stickyColumns, getStickyColumnsDisplay, + columnOrder, + showColumnOrderPopup, + filteredAvailableColumns, + openColumnOrderPopup, + closeColumnOrderPopup, + saveColumnOrder, }; }, }); diff --git a/web/src/components/dashboards/panels/TableRenderer.spec.ts b/web/src/components/dashboards/panels/TableRenderer.spec.ts index e43d77ad8f..4eec8822be 100644 --- a/web/src/components/dashboards/panels/TableRenderer.spec.ts +++ b/web/src/components/dashboards/panels/TableRenderer.spec.ts @@ -588,4 +588,627 @@ describe("TableRenderer", () => { expect(wrapper.exists()).toBe(true); }); }); + + describe("JSON Export", () => { + it("should export table data as JSON", () => { + wrapper = createWrapper(); + + wrapper.vm.downloadTableAsJSON("test-table"); + + expect(exportFile).toHaveBeenCalledWith( + "test-table.json", + expect.stringContaining('"columns"'), + "application/json", + ); + }); + + it("should handle JSON export with correct structure", () => { + wrapper = createWrapper(); + + wrapper.vm.downloadTableAsJSON("test-export"); + + expect(exportFile).toHaveBeenCalled(); + const jsonContent = vi.mocked(exportFile).mock.calls[0][1]; + const parsed = JSON.parse(jsonContent); + + expect(parsed).toHaveProperty("columns"); + expect(parsed).toHaveProperty("rows"); + }); + + it("should handle empty table JSON export", () => { + const emptyData = { columns: [], rows: [] }; + wrapper = createWrapper({ data: emptyData }); + + wrapper.vm.downloadTableAsJSON("empty-table"); + + expect(exportFile).toHaveBeenCalledWith( + "empty-table.json", + expect.any(String), + "application/json", + ); + }); + + it("should use default filename when not provided", () => { + wrapper = createWrapper(); + + wrapper.vm.downloadTableAsJSON(); + + expect(exportFile).toHaveBeenCalledWith( + "table-export.json", + expect.any(String), + "application/json", + ); + }); + + it("should handle JSON export errors gracefully", () => { + wrapper = createWrapper(); + + // Mock exportFile to return false (failure) + vi.mocked(exportFile).mockReturnValueOnce(false); + + const result = wrapper.vm.downloadTableAsJSON("test"); + + expect(exportFile).toHaveBeenCalled(); + }); + }); + + describe("Copy Cell Content", () => { + it("should copy cell content to clipboard", async () => { + wrapper = createWrapper(); + + await wrapper.vm.copyCellContent("test value", 0, "field"); + + // Check that the cell was marked as copied + expect(wrapper.vm.isCellCopied(0, "field")).toBe(true); + }); + + it("should not copy null values", async () => { + wrapper = createWrapper(); + + await wrapper.vm.copyCellContent(null, 0, "field"); + + expect(wrapper.vm.isCellCopied(0, "field")).toBe(false); + }); + + it("should not copy undefined values", async () => { + wrapper = createWrapper(); + + await wrapper.vm.copyCellContent(undefined, 0, "field"); + + expect(wrapper.vm.isCellCopied(0, "field")).toBe(false); + }); + + it("should reset copied state after timeout", async () => { + vi.useFakeTimers(); + wrapper = createWrapper(); + + await wrapper.vm.copyCellContent("test", 0, "field"); + expect(wrapper.vm.isCellCopied(0, "field")).toBe(true); + + // Fast forward 3 seconds + vi.advanceTimersByTime(3000); + + expect(wrapper.vm.isCellCopied(0, "field")).toBe(false); + vi.useRealTimers(); + }); + + it("should handle copy errors gracefully", async () => { + wrapper = createWrapper(); + + // Simulate copy failure - the catch block should handle it + await wrapper.vm.copyCellContent("test", 0, "field"); + + // Should not throw error even if clipboard API fails + expect(wrapper.exists()).toBe(true); + }); + + it("should convert value to string before copying", async () => { + wrapper = createWrapper(); + + await wrapper.vm.copyCellContent(123, 0, "field"); + + expect(wrapper.vm.isCellCopied(0, "field")).toBe(true); + }); + }); + + describe("Should Show Copy Button", () => { + it("should show copy button for valid string values", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.shouldShowCopyButton("test")).toBe(true); + }); + + it("should show copy button for numeric values", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.shouldShowCopyButton(123)).toBe(true); + }); + + it("should not show copy button for null", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.shouldShowCopyButton(null)).toBe(false); + }); + + it("should not show copy button for undefined", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.shouldShowCopyButton(undefined)).toBe(false); + }); + + it("should not show copy button for 'undefined' string", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.shouldShowCopyButton("undefined")).toBe(false); + }); + + it("should not show copy button for empty strings", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.shouldShowCopyButton("")).toBe(false); + }); + + it("should not show copy button for whitespace-only strings", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.shouldShowCopyButton(" ")).toBe(false); + }); + }); + + describe("Is Cell Copied", () => { + it("should return false for non-copied cells", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.isCellCopied(0, "field")).toBe(false); + }); + + it("should return true for copied cells", async () => { + wrapper = createWrapper(); + + await wrapper.vm.copyCellContent("test", 5, "testField"); + + expect(wrapper.vm.isCellCopied(5, "testField")).toBe(true); + }); + + it("should distinguish between different cells", async () => { + wrapper = createWrapper(); + + await wrapper.vm.copyCellContent("test1", 0, "field1"); + + expect(wrapper.vm.isCellCopied(0, "field1")).toBe(true); + expect(wrapper.vm.isCellCopied(0, "field2")).toBe(false); + expect(wrapper.vm.isCellCopied(1, "field1")).toBe(false); + }); + }); + + describe("Sticky Column Style", () => { + it("should return empty object for non-sticky columns", () => { + wrapper = createWrapper(); + + const style = wrapper.vm.getStickyColumnStyle({ sticky: false }); + expect(style).toEqual({}); + }); + + it("should return style object for sticky columns", () => { + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + field: "col1", + label: "Column 1", + }, + ], + rows: [], + }, + }); + + const style = wrapper.vm.getStickyColumnStyle({ + sticky: true, + name: "col1", + }); + + expect(style).toHaveProperty("position", "sticky"); + expect(style).toHaveProperty("left"); + expect(style).toHaveProperty("z-index", 2); + expect(style).toHaveProperty("background-color"); + expect(style).toHaveProperty("box-shadow"); + }); + + it("should calculate correct left offset for sticky columns", () => { + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + width: 100, + field: "col1", + }, + { + name: "col2", + sticky: true, + width: 150, + field: "col2", + }, + ], + rows: [], + }, + }); + + // Trigger the watch to calculate offsets + wrapper.vm.$nextTick(); + + const style1 = wrapper.vm.getStickyColumnStyle({ + sticky: true, + name: "col1", + }); + const style2 = wrapper.vm.getStickyColumnStyle({ + sticky: true, + name: "col2", + }); + + expect(style1.left).toBe("0px"); + expect(style2.left).toBe("100px"); + }); + + it("should apply dark theme background for sticky columns", async () => { + store.state.theme = "dark"; + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + field: "col1", + }, + ], + rows: [], + }, + }); + + await wrapper.vm.$nextTick(); + + const style = wrapper.vm.getStickyColumnStyle({ + sticky: true, + name: "col1", + }); + + expect(style["background-color"]).toBe("#1a1a1a"); + }); + + it("should apply light theme background for sticky columns", async () => { + store.state.theme = "light"; + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + field: "col1", + }, + ], + rows: [], + }, + }); + + await wrapper.vm.$nextTick(); + + const style = wrapper.vm.getStickyColumnStyle({ + sticky: true, + name: "col1", + }); + + expect(style["background-color"]).toBe("#fff"); + }); + }); + + describe("Update Sticky Column Styles", () => { + it("should create style element on mount", () => { + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + field: "col1", + }, + ], + rows: [], + }, + }); + + // Check if style element exists + const styleElements = document.querySelectorAll( + 'style[data-sticky-styles="true"]', + ); + expect(styleElements.length).toBeGreaterThan(0); + }); + + it("should remove style element on unmount", () => { + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + field: "col1", + }, + ], + rows: [], + }, + }); + + const initialCount = document.querySelectorAll( + 'style[data-sticky-styles="true"]', + ).length; + + wrapper.unmount(); + + const finalCount = document.querySelectorAll( + 'style[data-sticky-styles="true"]', + ).length; + expect(finalCount).toBeLessThan(initialCount); + }); + + it("should update styles when columns change", async () => { + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + field: "col1", + }, + ], + rows: [], + }, + }); + + await wrapper.setProps({ + data: { + columns: [ + { + name: "col1", + sticky: true, + field: "col1", + }, + { + name: "col2", + sticky: true, + field: "col2", + }, + ], + rows: [], + }, + }); + + await wrapper.vm.$nextTick(); + + // Check that styles were updated + const styleElements = document.querySelectorAll( + 'style[data-sticky-styles="true"]', + ); + expect(styleElements.length).toBeGreaterThan(0); + }); + + it("should update styles when theme changes", async () => { + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + field: "col1", + }, + ], + rows: [], + }, + }); + + store.state.theme = "dark"; + await wrapper.vm.$nextTick(); + + store.state.theme = "light"; + await wrapper.vm.$nextTick(); + + // Styles should have been updated + expect(wrapper.exists()).toBe(true); + }); + }); + + describe("Column Format Function", () => { + it("should apply column format function to cell values", () => { + const formatFn = (val: any) => `Formatted: ${val}`; + const dataWithFormat = { + columns: [ + { + name: "field", + label: "Field", + field: "field", + format: formatFn, + }, + ], + rows: [{ id: 1, field: "test" }], + }; + + wrapper = createWrapper({ data: dataWithFormat }); + + const table = wrapper.findComponent({ name: "QTable" }); + expect(table.props("columns")[0].format).toBe(formatFn); + }); + + it("should handle column format with row parameter", () => { + const formatFn = (val: any, row: any) => `${val}-${row.id}`; + const dataWithFormat = { + columns: [ + { + name: "field", + label: "Field", + field: "field", + format: formatFn, + }, + ], + rows: [{ id: 1, field: "test" }], + }; + + wrapper = createWrapper({ data: dataWithFormat }); + + const table = wrapper.findComponent({ name: "QTable" }); + const result = table.props("columns")[0].format("test", { id: 1 }); + expect(result).toBe("test-1"); + }); + }); + + describe("Sticky Column Offsets", () => { + it("should calculate cumulative width for multiple sticky columns", async () => { + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + width: 100, + field: "col1", + }, + { + name: "col2", + sticky: true, + width: 200, + field: "col2", + }, + { + name: "col3", + sticky: true, + width: 150, + field: "col3", + }, + ], + rows: [], + }, + }); + + await wrapper.vm.$nextTick(); + await flushPromises(); + + // Test offsets through getStickyColumnStyle function + const col1Style = wrapper.vm.getStickyColumnStyle({ name: "col1", sticky: true }); + const col2Style = wrapper.vm.getStickyColumnStyle({ name: "col2", sticky: true }); + const col3Style = wrapper.vm.getStickyColumnStyle({ name: "col3", sticky: true }); + + expect(col1Style.left).toBe("0px"); + expect(col2Style.left).toBe("100px"); + expect(col3Style.left).toBe("300px"); + }); + + it("should use default width when not specified", async () => { + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + field: "col1", + }, + { + name: "col2", + sticky: true, + field: "col2", + }, + ], + rows: [], + }, + }); + + await wrapper.vm.$nextTick(); + await flushPromises(); + + // Test offsets through getStickyColumnStyle function + const col1Style = wrapper.vm.getStickyColumnStyle({ name: "col1", sticky: true }); + const col2Style = wrapper.vm.getStickyColumnStyle({ name: "col2", sticky: true }); + + expect(col1Style.left).toBe("0px"); + expect(col2Style.left).toBe("100px"); // Default width + }); + + it("should parse string width values", async () => { + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + width: "120", + field: "col1", + }, + { + name: "col2", + sticky: true, + width: "180", + field: "col2", + }, + ], + rows: [], + }, + }); + + await wrapper.vm.$nextTick(); + await flushPromises(); + + // Test offsets through getStickyColumnStyle function + const col1Style = wrapper.vm.getStickyColumnStyle({ name: "col1", sticky: true }); + const col2Style = wrapper.vm.getStickyColumnStyle({ name: "col2", sticky: true }); + + expect(col1Style.left).toBe("0px"); + expect(col2Style.left).toBe("120px"); + }); + + it("should assign column index to each column", async () => { + wrapper = createWrapper({ + data: { + columns: [ + { name: "col1", field: "col1", label: "Col 1" }, + { name: "col2", field: "col2", label: "Col 2" }, + { name: "col3", field: "col3", label: "Col 3" }, + ], + rows: [], + }, + }); + + await wrapper.vm.$nextTick(); + + const columns = wrapper.vm.data.columns; + expect(columns[0].__colIndex).toBe(0); + expect(columns[1].__colIndex).toBe(1); + expect(columns[2].__colIndex).toBe(2); + }); + }); + + describe("Dark Mode", () => { + it("should apply dark mode styling for sticky columns when theme is dark", async () => { + store.state.theme = "dark"; + wrapper = createWrapper({ + data: { + columns: [ + { + name: "col1", + sticky: true, + field: "col1", + }, + ], + rows: [], + }, + }); + + await wrapper.vm.$nextTick(); + + const style = wrapper.vm.getStickyColumnStyle({ + sticky: true, + name: "col1", + }); + + expect(style["background-color"]).toBe("#1a1a1a"); + }); + }); }); diff --git a/web/src/components/dashboards/panels/TableRenderer.vue b/web/src/components/dashboards/panels/TableRenderer.vue index 74f6b6c1b8..907c523683 100644 --- a/web/src/components/dashboards/panels/TableRenderer.vue +++ b/web/src/components/dashboards/panels/TableRenderer.vue @@ -15,46 +15,55 @@ along with this program. If not, see . -->