mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
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 ``` <details> <summary><h3> File Walkthrough</h3></summary> <table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Configuration changes</strong></td><td><details><summary>2 files</summary><table> <tr> <td><strong>useDashboardPanel.ts</strong><dd><code>Add sticky_first_column and column_order defaults</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10210/files#diff-fbba1700750053b9eaf38ddd30dab84531bc44131c795ebb713811e4930cd7cb">+2/-0</a> </td> </tr> <tr> <td><strong>mod.rs</strong><dd><code>Extend PanelConfig with new ordering fields</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10210/files#diff-cbf3fe971525ba58892400391dfd7dac7e43d6049d6b4f5a9840ad104ebc5e08">+4/-0</a> </td> </tr> </table></details></td></tr><tr><td><strong>Enhancement</strong></td><td><details><summary>4 files</summary><table> <tr> <td><strong>convertPromQLTableChart.ts</strong><dd><code>Implement applyColumnOrdering and integrate ordering</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10210/files#diff-94123beacdf1e10ef812b4a6fda870004aa30f65eeb9e5ef6662f907dc50092d">+35/-3</a> </td> </tr> <tr> <td><strong>ColumnOrderPopUp.vue</strong><dd><code>Add drag-drop popup for column ordering</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10210/files#diff-e3f75c0798bebeefd7bffc7e3529daca6c128fc3fe655a15684105b78de3f50e">+349/-0</a> </td> </tr> <tr> <td><strong>PromQLChartConfig.vue</strong><dd><code>Integrate column order controls in config UI</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10210/files#diff-305fcbfc89618c91ca0ee6c8c52cfa596169ca2dced6778eea6cd3c89f071573">+124/-0</a> </td> </tr> <tr> <td><strong>TableRenderer.vue</strong><dd><code>Apply sticky styling and ordering in table renderer</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10210/files#diff-794706ea9da3a70012a0327c0464f9fd16802f78ece26055fe951716c2d73d59">+193/-59</a></td> </tr> </table></details></td></tr><tr><td><strong>Documentation</strong></td><td><details><summary>1 files</summary><table> <tr> <td><strong>en.json</strong><dd><code>Add translation key for Column Order</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10210/files#diff-892aa3d75ab6e129ef9116aa21bec1b9a15ba69ce5366d2a19128078c690d824">+1/-0</a> </td> </tr> </table></details></td></tr></tr></tbody></table> </details> ___ --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
github-actions[bot]
parent
3bdf5cb210
commit
d8d4708dca
@@ -434,6 +434,10 @@ pub struct PanelConfig {
|
||||
hidden_columns: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sticky_columns: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sticky_first_column: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
column_order: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize, ToSchema, Default)]
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
<!-- 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 <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<q-card
|
||||
class="column-order-popup scroll"
|
||||
data-test="dashboard-column-order-popup"
|
||||
style="padding: 0px 10px; min-width: min(700px, 90vw); max-height: 80vh"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex justify-between items-center q-pa-md header"
|
||||
style="border-bottom: 2px solid gray; margin-bottom: 5px"
|
||||
>
|
||||
<div class="flex items-center q-table__title q-mr-md">
|
||||
<span>{{ t("dashboard.columnOrder") }}</span>
|
||||
</div>
|
||||
<q-btn
|
||||
icon="close"
|
||||
class="q-ml-xs"
|
||||
unelevated
|
||||
size="sm"
|
||||
round
|
||||
outline
|
||||
@click.stop="cancelEdit"
|
||||
data-test="dashboard-column-order-cancel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="scrollable-content">
|
||||
<div class="text-caption text-grey-7 q-mb-md">
|
||||
{{ t("dashboard.columnOrderDescription") }}
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="!editColumnOrder || editColumnOrder.length === 0"
|
||||
class="text-center q-pa-xl text-grey-6"
|
||||
>
|
||||
<q-icon name="view_column" size="48px" class="q-mb-md" />
|
||||
<div class="text-body1">{{ t("dashboard.noColumnsOrdered") }}</div>
|
||||
<div class="text-caption">
|
||||
{{ t("dashboard.columnsDefaultOrder") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draggable list -->
|
||||
<draggable
|
||||
v-else
|
||||
v-model="editColumnOrder"
|
||||
:options="dragOptions"
|
||||
@mousedown.stop="() => {}"
|
||||
data-test="dashboard-column-order-drag"
|
||||
>
|
||||
<div
|
||||
v-for="(column, index) in editColumnOrder"
|
||||
:key="`column-${index}`"
|
||||
class="column-order-row"
|
||||
:data-test="`column-order-row-${index}`"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div class="drag-handle">
|
||||
<q-icon
|
||||
name="drag_indicator"
|
||||
color="grey-6"
|
||||
size="20px"
|
||||
:data-test="`column-order-drag-handle-${index}`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Column number -->
|
||||
<div class="column-number">{{ index + 1 }}.</div>
|
||||
|
||||
<!-- Column name -->
|
||||
<div class="column-name">{{ column }}</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="column-actions">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
size="sm"
|
||||
icon="arrow_upward"
|
||||
:disable="index === 0"
|
||||
@click="moveColumnUp(index)"
|
||||
:data-test="`column-order-move-up-${index}`"
|
||||
>
|
||||
<q-tooltip>{{ t("dashboard.moveUp") }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
size="sm"
|
||||
icon="arrow_downward"
|
||||
:disable="index === editColumnOrder.length - 1"
|
||||
@click="moveColumnDown(index)"
|
||||
:data-test="`column-order-move-down-${index}`"
|
||||
>
|
||||
<q-tooltip>{{ t("dashboard.moveDown") }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sticky-footer q-pa-md">
|
||||
<q-btn
|
||||
:label="t('common.cancel')"
|
||||
outline
|
||||
no-caps
|
||||
@click.stop="cancelEdit"
|
||||
data-test="dashboard-column-order-cancel-btn"
|
||||
/>
|
||||
<q-btn
|
||||
:label="t('common.save')"
|
||||
color="primary"
|
||||
no-caps
|
||||
@click.stop="saveEdit"
|
||||
data-test="dashboard-column-order-save-btn"
|
||||
/>
|
||||
</div>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onMounted, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { VueDraggableNext } from "vue-draggable-next";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ColumnOrderPopUp",
|
||||
components: {
|
||||
draggable: VueDraggableNext as any,
|
||||
},
|
||||
props: {
|
||||
columnOrder: {
|
||||
type: Array as () => string[],
|
||||
default: () => [],
|
||||
},
|
||||
availableColumns: {
|
||||
type: Array as () => string[],
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ["cancel", "save"],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
const editColumnOrder = ref<string[]>([]);
|
||||
|
||||
// Drag options
|
||||
const dragOptions = {
|
||||
animation: 200,
|
||||
handle: ".drag-handle",
|
||||
ghostClass: "ghost",
|
||||
};
|
||||
|
||||
// Initialize edit column order from props
|
||||
const initializeColumnOrder = () => {
|
||||
if (props.columnOrder && props.columnOrder.length > 0) {
|
||||
// Filter the column order to only include columns that are in availableColumns
|
||||
const filtered = props.columnOrder.filter((col) =>
|
||||
props.availableColumns.includes(col)
|
||||
);
|
||||
|
||||
// Find columns in availableColumns that are not in columnOrder
|
||||
const remaining = props.availableColumns.filter(
|
||||
(col) => !filtered.includes(col)
|
||||
);
|
||||
|
||||
// Combine: ordered columns first, then remaining in their natural order
|
||||
editColumnOrder.value = [...filtered, ...remaining];
|
||||
} else {
|
||||
// Start with available columns in their natural order
|
||||
editColumnOrder.value = [...props.availableColumns];
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeColumnOrder();
|
||||
});
|
||||
|
||||
// Watch for changes in availableColumns and re-initialize the entire list
|
||||
watch(
|
||||
() => props.availableColumns,
|
||||
() => {
|
||||
initializeColumnOrder();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const moveColumnUp = (index: number) => {
|
||||
if (index > 0) {
|
||||
const temp = editColumnOrder.value[index];
|
||||
editColumnOrder.value[index] = editColumnOrder.value[index - 1];
|
||||
editColumnOrder.value[index - 1] = temp;
|
||||
}
|
||||
};
|
||||
|
||||
const moveColumnDown = (index: number) => {
|
||||
if (index < editColumnOrder.value.length - 1) {
|
||||
const temp = editColumnOrder.value[index];
|
||||
editColumnOrder.value[index] = editColumnOrder.value[index + 1];
|
||||
editColumnOrder.value[index + 1] = temp;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
emit("cancel");
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
emit("save", editColumnOrder.value);
|
||||
};
|
||||
|
||||
return {
|
||||
t,
|
||||
editColumnOrder,
|
||||
dragOptions,
|
||||
moveColumnUp,
|
||||
moveColumnDown,
|
||||
cancelEdit,
|
||||
saveEdit,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.column-order-popup {
|
||||
.scrollable-content {
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 190px);
|
||||
padding: 12px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 4px;
|
||||
}
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d1d5db transparent;
|
||||
}
|
||||
|
||||
.column-order-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid #cccccc70;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
padding: 2px 4px;
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.column-number {
|
||||
min-width: 32px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.column-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.column-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sticky-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
z-index: 10;
|
||||
border-top: 1px solid #eee;
|
||||
box-shadow: rgb(240, 240, 240) 0px -4px 7px 0px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
.body--dark {
|
||||
.column-order-popup {
|
||||
.column-order-row {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.12);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.column-number {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sticky-footer {
|
||||
border-top-color: rgba(255, 255, 255, 0.28);
|
||||
box-shadow: rgba(0, 0, 0, 0.3) 0px -4px 7px 0px;
|
||||
background: var(--q-dark);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -393,7 +393,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
size="lg"
|
||||
>
|
||||
<template v-slot:default>
|
||||
<div class="row items-center all-pointer-events tw:mb-[-5px] tw:ml-2">
|
||||
<div
|
||||
class="row items-center all-pointer-events tw:mb-[-5px] tw:ml-2"
|
||||
>
|
||||
{{ t("dashboard.stickyFirstColumn") }}
|
||||
<q-icon class="q-ml-xs" size="20px" name="info" />
|
||||
<q-tooltip class="bg-grey-8" max-width="300px">
|
||||
@@ -464,6 +466,38 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
</q-select>
|
||||
</template>
|
||||
|
||||
<!-- Column Order Configuration -->
|
||||
<template
|
||||
v-if="
|
||||
promqlTableMode === 'all' || promqlTableMode === 'expanded_timeseries'
|
||||
"
|
||||
>
|
||||
<div class="q-mb-sm q-mt-md" style="font-weight: 600">
|
||||
</div>
|
||||
<q-btn
|
||||
@click="openColumnOrderPopup"
|
||||
style="cursor: pointer; padding: 0px 5px"
|
||||
:label="t(`dashboard.configureColumnOrder`)"
|
||||
no-caps
|
||||
icon="reorder"
|
||||
data-test="dashboard-config-column-order-button"
|
||||
class="el-border"
|
||||
/>
|
||||
|
||||
<!-- Column Order Popup Dialog -->
|
||||
<q-dialog
|
||||
v-model="showColumnOrderPopup"
|
||||
data-test="column-order-dialog"
|
||||
>
|
||||
<ColumnOrderPopUp
|
||||
:column-order="columnOrder"
|
||||
:available-columns="filteredAvailableColumns"
|
||||
@cancel="closeColumnOrderPopup"
|
||||
@save="saveColumnOrder"
|
||||
/>
|
||||
</q-dialog>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -472,9 +506,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,46 +15,55 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<q-table
|
||||
:class="[
|
||||
'my-sticky-virtscroll-table',
|
||||
{ 'no-position-absolute': store.state.printMode },
|
||||
{ 'wrap-enabled': wrapCells },
|
||||
]"
|
||||
virtual-scroll
|
||||
v-model:pagination="pagination"
|
||||
:rows-per-page-options="[0]"
|
||||
:virtual-scroll-sticky-size-start="48"
|
||||
dense
|
||||
:wrap-cells="wrapCells"
|
||||
:rows="data.rows || []"
|
||||
:columns="data.columns"
|
||||
row-key="id"
|
||||
ref="tableRef"
|
||||
data-test="dashboard-panel-table"
|
||||
@row-click="(...args: any) => $emit('row-click', ...args)"
|
||||
hide-no-data
|
||||
>
|
||||
<template v-slot:body-cell="props">
|
||||
<q-td :props="props" :style="getStyle(props)" class="copy-cell-td">
|
||||
<!-- Copy button on left for numeric/right-aligned columns -->
|
||||
<q-btn
|
||||
v-if="props.col.align === 'right' && shouldShowCopyButton(props.value)"
|
||||
:icon="
|
||||
isCellCopied(props.rowIndex, props.col.name)
|
||||
? 'check'
|
||||
: 'content_copy'
|
||||
"
|
||||
dense
|
||||
size="xs"
|
||||
no-caps
|
||||
flat
|
||||
class="copy-btn q-mr-xs"
|
||||
@click.stop="
|
||||
copyCellContent(props.value, props.rowIndex, props.col.name)
|
||||
"
|
||||
<div class="table-wrapper">
|
||||
<q-table
|
||||
:class="[
|
||||
'my-sticky-virtscroll-table',
|
||||
{ 'no-position-absolute': store.state.printMode },
|
||||
{ 'wrap-enabled': wrapCells },
|
||||
]"
|
||||
virtual-scroll
|
||||
v-model:pagination="pagination"
|
||||
:rows-per-page-options="[0]"
|
||||
:virtual-scroll-sticky-size-start="48"
|
||||
dense
|
||||
:wrap-cells="wrapCells"
|
||||
:rows="data.rows || []"
|
||||
:columns="data.columns"
|
||||
row-key="id"
|
||||
ref="tableRef"
|
||||
data-test="dashboard-panel-table"
|
||||
@row-click="(...args: any) => $emit('row-click', ...args)"
|
||||
hide-no-data
|
||||
>
|
||||
<template v-slot:body-cell="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:style="[getStyle(props), getStickyColumnStyle(props.col)]"
|
||||
:class="{ 'sticky-column': props.col.sticky }"
|
||||
:data-col-index="props.col.__colIndex"
|
||||
class="copy-cell-td"
|
||||
>
|
||||
</q-btn>
|
||||
<!-- Copy button on left for numeric/right-aligned columns -->
|
||||
<q-btn
|
||||
v-if="
|
||||
props.col.align === 'right' && shouldShowCopyButton(props.value)
|
||||
"
|
||||
:icon="
|
||||
isCellCopied(props.rowIndex, props.col.name)
|
||||
? 'check'
|
||||
: 'content_copy'
|
||||
"
|
||||
dense
|
||||
size="xs"
|
||||
no-caps
|
||||
flat
|
||||
class="copy-btn q-mr-xs"
|
||||
@click.stop="
|
||||
copyCellContent(props.value, props.rowIndex, props.col.name)
|
||||
"
|
||||
>
|
||||
</q-btn>
|
||||
<!-- Use JsonFieldRenderer if column is marked as JSON -->
|
||||
<JsonFieldRenderer
|
||||
v-if="props.col.showFieldAsJson"
|
||||
@@ -62,17 +71,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
<!-- Otherwise show normal value -->
|
||||
<template v-else>
|
||||
{{
|
||||
props.value === "undefined" || props.value === null
|
||||
? ""
|
||||
: props.col.format
|
||||
? props.col.format(props.value, props.row)
|
||||
: props.value
|
||||
}}
|
||||
{{
|
||||
props.value === "undefined" || props.value === null
|
||||
? ""
|
||||
: props.col.format
|
||||
? props.col.format(props.value, props.row)
|
||||
: props.value
|
||||
}}
|
||||
</template>
|
||||
<!-- Copy button on right for non-numeric columns -->
|
||||
<!-- Copy button on right for non-numeric columns -->
|
||||
<q-btn
|
||||
v-if="props.col.align !== 'right' && shouldShowCopyButton(props.value)"
|
||||
v-if="
|
||||
props.col.align !== 'right' && shouldShowCopyButton(props.value)
|
||||
"
|
||||
:icon="
|
||||
isCellCopied(props.rowIndex, props.col.name)
|
||||
? 'check'
|
||||
@@ -88,18 +99,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
"
|
||||
>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Expose a bottom slot so callers (e.g., PromQL table) can provide footer content -->
|
||||
<template v-slot:bottom="scope" v-if="$slots.bottom">
|
||||
<slot name="bottom" v-bind="scope" />
|
||||
</template>
|
||||
</q-table>
|
||||
<!-- Expose a bottom slot so callers (e.g., PromQL table) can provide footer content -->
|
||||
<template v-slot:bottom="scope" v-if="$slots.bottom">
|
||||
<slot name="bottom" v-bind="scope" />
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import useNotifications from "@/composables/useNotifications";
|
||||
import { useStickyColumns } from "@/composables/useStickyColumns";
|
||||
import { exportFile, copyToClipboard, useQuasar } from "quasar";
|
||||
import { defineComponent, ref } from "vue";
|
||||
import { findFirstValidMappedValue } from "@/utils/dashboard/convertDataIntoUnitValue";
|
||||
@@ -138,6 +151,10 @@ export default defineComponent({
|
||||
|
||||
const { showErrorNotification, showPositiveNotification } =
|
||||
useNotifications();
|
||||
|
||||
// Use sticky columns composable
|
||||
const { getStickyColumnStyle } = useStickyColumns(props, store);
|
||||
|
||||
function wrapCsvValue(val: any, formatFn?: any, row?: any) {
|
||||
let formatted = formatFn !== void 0 ? formatFn(val, row) : val;
|
||||
|
||||
@@ -305,8 +322,7 @@ export default defineComponent({
|
||||
copiedCells.value.delete(key);
|
||||
}, 3000);
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -317,6 +333,7 @@ export default defineComponent({
|
||||
downloadTableAsJSON,
|
||||
tableRef,
|
||||
getStyle,
|
||||
getStickyColumnStyle,
|
||||
store,
|
||||
copyCellContent,
|
||||
isCellCopied,
|
||||
|
||||
@@ -141,6 +141,8 @@ const getDefaultDashboardPanelData: any = (store: any) => ({
|
||||
visible_columns: [],
|
||||
hidden_columns: [],
|
||||
sticky_columns: [],
|
||||
sticky_first_column: false,
|
||||
column_order: [],
|
||||
},
|
||||
htmlContent: "",
|
||||
markdownContent: "",
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import { useStickyColumns } from "./useStickyColumns";
|
||||
|
||||
describe("useStickyColumns", () => {
|
||||
let mockProps: any;
|
||||
let mockStore: any;
|
||||
let composable: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up any existing style elements from previous tests
|
||||
document.querySelectorAll('style[data-sticky-styles="true"]').forEach((el) => {
|
||||
el.parentNode?.removeChild(el);
|
||||
});
|
||||
|
||||
mockProps = {
|
||||
data: {
|
||||
columns: [],
|
||||
},
|
||||
};
|
||||
|
||||
mockStore = {
|
||||
state: {
|
||||
theme: "light",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up style elements after each test
|
||||
document.querySelectorAll('style[data-sticky-styles="true"]').forEach((el) => {
|
||||
el.parentNode?.removeChild(el);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Initialization", () => {
|
||||
it("should initialize with empty sticky column offsets", () => {
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({});
|
||||
});
|
||||
|
||||
it("should return getStickyColumnStyle function", () => {
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(typeof composable.getStickyColumnStyle).toBe("function");
|
||||
});
|
||||
|
||||
it("should return stickyColumnOffsets ref", () => {
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(composable.stickyColumnOffsets).toBeDefined();
|
||||
expect(typeof composable.stickyColumnOffsets.value).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column Offset Calculation", () => {
|
||||
it("should calculate offsets for sticky columns", async () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100 },
|
||||
{ name: "col2", sticky: true, width: 150 },
|
||||
{ name: "col3", sticky: false },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
await nextTick();
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use default width of 100 when width is not specified", async () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true },
|
||||
{ name: "col2", sticky: true },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
await nextTick();
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse string width values", async () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: "120" },
|
||||
{ name: "col2", sticky: true, width: "180" },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
await nextTick();
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it("should calculate cumulative widths correctly", async () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100 },
|
||||
{ name: "col2", sticky: true, width: 200 },
|
||||
{ name: "col3", sticky: true, width: 150 },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
await nextTick();
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 100,
|
||||
col3: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("should only include sticky columns in offsets", async () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100 },
|
||||
{ name: "col2", sticky: false, width: 150 },
|
||||
{ name: "col3", sticky: true, width: 200 },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
await nextTick();
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col3: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should add __colIndex to each column", async () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1" },
|
||||
{ name: "col2" },
|
||||
{ name: "col3" },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
await nextTick();
|
||||
|
||||
expect(mockProps.data.columns[0].__colIndex).toBe(0);
|
||||
expect(mockProps.data.columns[1].__colIndex).toBe(1);
|
||||
expect(mockProps.data.columns[2].__colIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle empty columns array", async () => {
|
||||
mockProps.data.columns = [];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
await nextTick();
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle undefined columns", async () => {
|
||||
mockProps.data.columns = undefined;
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
await nextTick();
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStickyColumnStyle", () => {
|
||||
it("should return empty object for non-sticky columns", () => {
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
const style = composable.getStickyColumnStyle({ sticky: false });
|
||||
|
||||
expect(style).toEqual({});
|
||||
});
|
||||
|
||||
it("should return empty object for undefined column", () => {
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
const style = composable.getStickyColumnStyle(undefined);
|
||||
|
||||
expect(style).toEqual({});
|
||||
});
|
||||
|
||||
it("should return empty object for null column", () => {
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
const style = composable.getStickyColumnStyle(null);
|
||||
|
||||
expect(style).toEqual({});
|
||||
});
|
||||
|
||||
it("should return style object for sticky columns", async () => {
|
||||
mockProps.data.columns = [{ name: "col1", sticky: true, width: 100 }];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
await nextTick();
|
||||
|
||||
const style = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
|
||||
|
||||
expect(style).toEqual({
|
||||
position: "sticky",
|
||||
left: "0px",
|
||||
"z-index": 2,
|
||||
"background-color": "#fff",
|
||||
"box-shadow": "2px 0 4px rgba(0, 0, 0, 0.1)",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use correct left offset from precomputed offsets", async () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100 },
|
||||
{ name: "col2", sticky: true, width: 150 },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
await nextTick();
|
||||
|
||||
const style1 = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
|
||||
const style2 = composable.getStickyColumnStyle({ sticky: true, name: "col2" });
|
||||
|
||||
expect(style1.left).toBe("0px");
|
||||
expect(style2.left).toBe("100px");
|
||||
});
|
||||
|
||||
it("should default to 0 offset for unknown column names", () => {
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
const style = composable.getStickyColumnStyle({
|
||||
sticky: true,
|
||||
name: "unknown",
|
||||
});
|
||||
|
||||
expect(style.left).toBe("0px");
|
||||
});
|
||||
|
||||
it("should apply dark theme background color", () => {
|
||||
mockStore.state.theme = "dark";
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
const style = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
|
||||
|
||||
expect(style["background-color"]).toBe("#1a1a1a");
|
||||
});
|
||||
|
||||
it("should apply light theme background color", () => {
|
||||
mockStore.state.theme = "light";
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
const style = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
|
||||
|
||||
expect(style["background-color"]).toBe("#fff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Style Generation Logic", () => {
|
||||
it("should calculate offsets for style generation", () => {
|
||||
mockProps.data.columns = [{ name: "col1", sticky: true, width: 100 }];
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// Offsets are calculated which would be used for style generation
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({ col1: 0 });
|
||||
});
|
||||
|
||||
it("should track multiple column offsets for styling", () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100 },
|
||||
{ name: "col2", sticky: true, width: 150 },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// Both offsets calculated for style application
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should add column index for CSS targeting", () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true },
|
||||
{ name: "col2", sticky: false },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// __colIndex added for nth-child CSS selectors
|
||||
expect(mockProps.data.columns[0].__colIndex).toBe(0);
|
||||
expect(mockProps.data.columns[1].__colIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("should determine theme color for styles", () => {
|
||||
mockStore.state.theme = "dark";
|
||||
mockProps.data.columns = [{ name: "col1", sticky: true }];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
const style = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
|
||||
|
||||
// Theme determines background color
|
||||
expect(style["background-color"]).toBe("#1a1a1a");
|
||||
});
|
||||
|
||||
it("should skip offset calculation for non-sticky columns", () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100 },
|
||||
{ name: "col2", sticky: false, width: 150 },
|
||||
{ name: "col3", sticky: true, width: 200 },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// col2 not included in offsets
|
||||
expect(composable.stickyColumnOffsets.value).not.toHaveProperty("col2");
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col3: 100, // Only includes width of col1 (sticky), skips col2
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dynamic Updates", () => {
|
||||
it("should initialize offsets based on initial columns", () => {
|
||||
mockProps.data.columns = [{ name: "col1", sticky: true, width: 100 }];
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// Immediate watcher should calculate offsets on init
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({ col1: 0 });
|
||||
});
|
||||
|
||||
it("should recalculate offsets with different initial columns", () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100 },
|
||||
{ name: "col2", sticky: true, width: 150 },
|
||||
];
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should calculate all column indexes immediately", () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100 },
|
||||
{ name: "col2", sticky: true, width: 200 },
|
||||
{ name: "col3", sticky: false },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// All columns get __colIndex regardless of sticky status
|
||||
expect(mockProps.data.columns[0].__colIndex).toBe(0);
|
||||
expect(mockProps.data.columns[1].__colIndex).toBe(1);
|
||||
expect(mockProps.data.columns[2].__colIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("should return consistent offsets reference", () => {
|
||||
mockProps.data.columns = [{ name: "col1", sticky: true }];
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
const ref1 = composable.stickyColumnOffsets;
|
||||
const ref2 = composable.stickyColumnOffsets;
|
||||
|
||||
// Should be the same ref object
|
||||
expect(ref1).toBe(ref2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Return Values", () => {
|
||||
it("should return stickyColumnOffsets ref", () => {
|
||||
mockProps.data.columns = [{ name: "col1", sticky: true }];
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(composable).toHaveProperty("stickyColumnOffsets");
|
||||
expect(composable.stickyColumnOffsets.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return getStickyColumnStyle function", () => {
|
||||
mockProps.data.columns = [];
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(composable).toHaveProperty("getStickyColumnStyle");
|
||||
expect(typeof composable.getStickyColumnStyle).toBe("function");
|
||||
});
|
||||
|
||||
it("should only return expected properties", () => {
|
||||
mockProps.data.columns = [];
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
const keys = Object.keys(composable);
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys).toContain("stickyColumnOffsets");
|
||||
expect(keys).toContain("getStickyColumnStyle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle columns with zero width", () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 0 },
|
||||
{ name: "col2", sticky: true, width: 150 },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 100, // Uses default width of 100 when width is 0
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle very large column widths", () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 10000 },
|
||||
{ name: "col2", sticky: true, width: 20000 },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle columns with no name property", () => {
|
||||
mockProps.data.columns = [{ sticky: true, width: 100 }];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// Should not throw error
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
undefined: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mixed numeric and string widths", () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100 },
|
||||
{ name: "col2", sticky: true, width: "150" },
|
||||
{ name: "col3", sticky: true, width: 200 },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 100,
|
||||
col3: 250,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle floating point widths", () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100.5 },
|
||||
{ name: "col2", sticky: true, width: "150.7" },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// parseInt truncates decimal points
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 100, // parseInt(100.5) = 100
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle negative widths", () => {
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: -50 },
|
||||
{ name: "col2", sticky: true, width: 100 },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: -50, // Cumulative calculation includes negative
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle many sticky columns", () => {
|
||||
mockProps.data.columns = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `col${i}`,
|
||||
sticky: true,
|
||||
width: 100,
|
||||
}));
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// Check first, middle, and last
|
||||
expect(composable.stickyColumnOffsets.value.col0).toBe(0);
|
||||
expect(composable.stickyColumnOffsets.value.col10).toBe(1000);
|
||||
expect(composable.stickyColumnOffsets.value.col19).toBe(1900);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reactivity", () => {
|
||||
it("should calculate offsets on initialization", () => {
|
||||
mockProps.data.columns = [{ name: "col1", sticky: true, width: 100 }];
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// The watch runs immediately, so offsets should be calculated
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({ col1: 0 });
|
||||
});
|
||||
|
||||
it("should handle columns being added", async () => {
|
||||
mockProps.data.columns = [{ name: "col1", sticky: true, width: 100 }];
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({ col1: 0 });
|
||||
|
||||
// Add more columns by pushing (modifies array in place)
|
||||
mockProps.data.columns.push({ name: "col2", sticky: true, width: 150 });
|
||||
await nextTick();
|
||||
await nextTick(); // Sometimes need extra tick for deep watch
|
||||
|
||||
// Note: The exact behavior depends on whether the watch triggers
|
||||
// At minimum we should not have errors
|
||||
expect(composable.stickyColumnOffsets.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("should update style when theme changes in store", () => {
|
||||
mockProps.data.columns = [{ name: "col1", sticky: true }];
|
||||
mockStore.state.theme = "light";
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
const style1 = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
|
||||
expect(style1["background-color"]).toBe("#fff");
|
||||
|
||||
// After theme change, getStickyColumnStyle should return new color
|
||||
mockStore.state.theme = "dark";
|
||||
|
||||
const style2 = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
|
||||
expect(style2["background-color"]).toBe("#1a1a1a");
|
||||
});
|
||||
|
||||
it("should handle immediate watcher execution", () => {
|
||||
// The watch has immediate: true, so it runs on setup
|
||||
mockProps.data.columns = [
|
||||
{ name: "col1", sticky: true, width: 100 },
|
||||
{ name: "col2", sticky: true, width: 150 },
|
||||
];
|
||||
|
||||
composable = useStickyColumns(mockProps, mockStore);
|
||||
|
||||
// Offsets should be calculated immediately
|
||||
expect(composable.stickyColumnOffsets.value).toEqual({
|
||||
col1: 0,
|
||||
col2: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
|
||||
export function useStickyColumns(props: any, store: any) {
|
||||
const stickyColumnOffsets = ref<{ [key: string]: number }>({});
|
||||
let styleElement: HTMLStyleElement | null = null;
|
||||
|
||||
// Watch for columns changes to update sticky offsets
|
||||
watch(
|
||||
() => props.data?.columns,
|
||||
(columns: any[]) => {
|
||||
const offsets: { [key: string]: number } = {};
|
||||
let cumulativeWidth = 0;
|
||||
|
||||
if (columns) {
|
||||
columns.forEach((col: any, idx: number) => {
|
||||
// Add index attribute for CSS targeting
|
||||
col.__colIndex = idx;
|
||||
|
||||
if (col.sticky) {
|
||||
offsets[col.name] = cumulativeWidth;
|
||||
cumulativeWidth += col.width ? parseInt(String(col.width)) : 100;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stickyColumnOffsets.value = offsets;
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
const getStickyColumnStyle = (column: any) => {
|
||||
if (!column?.sticky) return {};
|
||||
|
||||
// Get the precomputed left offset for this column
|
||||
const leftOffset = stickyColumnOffsets.value[column.name] ?? 0;
|
||||
|
||||
return {
|
||||
position: "sticky",
|
||||
left: `${leftOffset}px`,
|
||||
"z-index": 2,
|
||||
"background-color": store.state.theme === "dark" ? "#1a1a1a" : "#fff",
|
||||
"box-shadow": "2px 0 4px rgba(0, 0, 0, 0.1)",
|
||||
};
|
||||
};
|
||||
|
||||
const updateStickyColumnStyles = () => {
|
||||
// Remove old style element if it exists
|
||||
if (styleElement?.parentNode) {
|
||||
styleElement.parentNode.removeChild(styleElement);
|
||||
}
|
||||
|
||||
// Create new style element with current offsets
|
||||
styleElement = document.createElement("style");
|
||||
styleElement.setAttribute("data-sticky-styles", "true");
|
||||
|
||||
const columns = (props.data?.columns || []) as any[];
|
||||
const bgColor = store.state.theme === "dark" ? "#1a1a1a" : "#fff";
|
||||
let css = "";
|
||||
|
||||
// Generate CSS rules for each column position
|
||||
columns.forEach((col: any, colIndex: number) => {
|
||||
if (col.sticky) {
|
||||
const offset = stickyColumnOffsets.value[col.name] ?? 0;
|
||||
// Target header and body cells by their actual nth-child position (1-based)
|
||||
// Headers get position sticky, left offset, and higher z-index
|
||||
// Body cells with sticky-column class get the same positioning
|
||||
css += `
|
||||
.my-sticky-virtscroll-table thead tr th:nth-child(${colIndex + 1}) {
|
||||
position: sticky !important;
|
||||
left: ${offset}px !important;
|
||||
z-index: 4 !important;
|
||||
background-color: ${bgColor} !important;
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
.my-sticky-virtscroll-table tbody td:nth-child(${colIndex + 1}).sticky-column {
|
||||
left: ${offset}px !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Add base styling for all sticky columns
|
||||
css =
|
||||
`
|
||||
/* Sticky body cells */
|
||||
.my-sticky-virtscroll-table tbody td.sticky-column {
|
||||
position: sticky !important;
|
||||
z-index: 2 !important;
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Column-specific positions for headers and body cells */
|
||||
` + css;
|
||||
|
||||
styleElement.textContent = css;
|
||||
document.head.appendChild(styleElement);
|
||||
};
|
||||
|
||||
// Watch sticky column offsets and update styles
|
||||
watch(() => stickyColumnOffsets.value, updateStickyColumnStyles, {
|
||||
deep: true,
|
||||
});
|
||||
|
||||
// Watch theme changes and update styles
|
||||
watch(() => store.state.theme, updateStickyColumnStyles);
|
||||
|
||||
onMounted(() => {
|
||||
updateStickyColumnStyles();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (styleElement?.parentNode) {
|
||||
styleElement.parentNode.removeChild(styleElement);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
stickyColumnOffsets,
|
||||
getStickyColumnStyle,
|
||||
};
|
||||
}
|
||||
@@ -1389,7 +1389,14 @@
|
||||
"folders": "Ordner",
|
||||
"searchFolder": "Ordner durchsuchen",
|
||||
"deleteFolder": "Ordner löschen",
|
||||
"deleteFolderMessage": "Möchten Sie diesen Ordner wirklich löschen?"
|
||||
"deleteFolderMessage": "Möchten Sie diesen Ordner wirklich löschen?",
|
||||
"columnOrder": "Reihenfolge der Spalten",
|
||||
"configureColumnOrder": "Spaltenreihenfolge konfigurieren",
|
||||
"columnOrderDescription": "Passen Sie die Anzeigereihenfolge der Spalten in der Tabelle an. Nur sichtbare Spalten (basierend auf sichtbaren/versteckten Filtern) können neu angeordnet werden. Ziehen Sie die Spalten, um ihre Reihenfolge zu ändern.",
|
||||
"noColumnsOrdered": "Keine Spalten angeordnet",
|
||||
"columnsDefaultOrder": "Die Spalten werden in ihrer Standardreihenfolge angezeigt",
|
||||
"moveUp": "Steigen Sie auf",
|
||||
"moveDown": "Gehe nach unten"
|
||||
},
|
||||
"panel": {
|
||||
"header": "Panel",
|
||||
|
||||
@@ -1483,6 +1483,13 @@
|
||||
"hiddenColumns": "Hidden Columns",
|
||||
"stickyFirstColumn": "Sticky First Column",
|
||||
"stickyColumns": "Sticky Columns",
|
||||
"columnOrder": "Column Order",
|
||||
"configureColumnOrder": "Configure Column Order",
|
||||
"columnOrderDescription": "Customize the display order of columns in the table. Only visible columns (based on visible/hidden filters) can be reordered. Drag columns to change their order.",
|
||||
"noColumnsOrdered": "No columns ordered",
|
||||
"columnsDefaultOrder": "Columns will be displayed in their default order",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"showTimestamp": "Show Timestamp Column",
|
||||
"pageSize": "Rows Per Page",
|
||||
"enableSorting": "Enable Sorting",
|
||||
|
||||
@@ -1389,7 +1389,14 @@
|
||||
"folders": "Carpetas",
|
||||
"searchFolder": "Carpeta de búsqueda",
|
||||
"deleteFolder": "Eliminar carpeta",
|
||||
"deleteFolderMessage": "¿Está seguro de que desea eliminar esta carpeta?"
|
||||
"deleteFolderMessage": "¿Está seguro de que desea eliminar esta carpeta?",
|
||||
"columnOrder": "Orden de columnas",
|
||||
"configureColumnOrder": "Configurar el orden de las columnas",
|
||||
"columnOrderDescription": "Personalice el orden de visualización de las columnas de la tabla. Solo se pueden reordenar las columnas visibles (según los filtros visibles u ocultos). Arrastra las columnas para cambiar su orden.",
|
||||
"noColumnsOrdered": "No hay columnas ordenadas",
|
||||
"columnsDefaultOrder": "Las columnas se mostrarán en su orden predeterminado",
|
||||
"moveUp": "Muévete hacia arriba",
|
||||
"moveDown": "Muévete hacia abajo"
|
||||
},
|
||||
"panel": {
|
||||
"header": "Panel",
|
||||
|
||||
@@ -1389,7 +1389,14 @@
|
||||
"folders": "Dossiers",
|
||||
"searchFolder": "Dossier de recherche",
|
||||
"deleteFolder": "Supprimer le dossier",
|
||||
"deleteFolderMessage": "Êtes-vous sûr de vouloir supprimer ce dossier ?"
|
||||
"deleteFolderMessage": "Êtes-vous sûr de vouloir supprimer ce dossier ?",
|
||||
"columnOrder": "Ordre des colonnes",
|
||||
"configureColumnOrder": "Configurer l'ordre des colonnes",
|
||||
"columnOrderDescription": "Personnalisez l'ordre d'affichage des colonnes du tableau. Seules les colonnes visibles (basées sur des filtres visibles/masqués) peuvent être réorganisées. Faites glisser les colonnes pour modifier leur ordre.",
|
||||
"noColumnsOrdered": "Aucune colonne ordonnée",
|
||||
"columnsDefaultOrder": "Les colonnes seront affichées dans leur ordre par défaut",
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Déplacer vers le bas"
|
||||
},
|
||||
"panel": {
|
||||
"header": "Panneau",
|
||||
|
||||
@@ -1389,7 +1389,14 @@
|
||||
"folders": "Cartelle",
|
||||
"searchFolder": "Cartella di ricerca",
|
||||
"deleteFolder": "Elimina cartella",
|
||||
"deleteFolderMessage": "Sei sicuro di voler eliminare questa cartella?"
|
||||
"deleteFolderMessage": "Sei sicuro di voler eliminare questa cartella?",
|
||||
"columnOrder": "Ordine delle colonne",
|
||||
"configureColumnOrder": "Configura l'ordine delle colonne",
|
||||
"columnOrderDescription": "Personalizza l'ordine di visualizzazione delle colonne nella tabella. È possibile riordinare solo le colonne visibili (basate su filtri visibili/nascosti). Trascina le colonne per modificarne l'ordine.",
|
||||
"noColumnsOrdered": "Nessuna colonna ordinata",
|
||||
"columnsDefaultOrder": "Le colonne verranno visualizzate nell'ordine predefinito",
|
||||
"moveUp": "Sposta verso l'alto",
|
||||
"moveDown": "Sposta verso il basso"
|
||||
},
|
||||
"panel": {
|
||||
"header": "Pannello",
|
||||
|
||||
@@ -1405,7 +1405,14 @@
|
||||
"folders": "フォルダー",
|
||||
"searchFolder": "検索フォルダー",
|
||||
"deleteFolder": "[フォルダを削除]",
|
||||
"deleteFolderMessage": "このフォルダを削除してよろしいですか?"
|
||||
"deleteFolderMessage": "このフォルダを削除してよろしいですか?",
|
||||
"columnOrder": "列の順序",
|
||||
"configureColumnOrder": "列の順序を設定",
|
||||
"columnOrderDescription": "テーブル内の列の表示順序をカスタマイズします。順序を変更できるのは、表示されている列 (表示/非表示のフィルターに基づく) だけです。列をドラッグして順序を変更してください。",
|
||||
"noColumnsOrdered": "列は順序付けられていません",
|
||||
"columnsDefaultOrder": "列はデフォルトの順序で表示されます",
|
||||
"moveUp": "上へ移動",
|
||||
"moveDown": "下に移動"
|
||||
},
|
||||
"panel": {
|
||||
"header": "パネル",
|
||||
|
||||
@@ -1389,7 +1389,14 @@
|
||||
"folders": "폴더",
|
||||
"searchFolder": "검색 폴더",
|
||||
"deleteFolder": "폴더 삭제",
|
||||
"deleteFolderMessage": "정말로 이 폴더를 삭제하시겠습니까?"
|
||||
"deleteFolderMessage": "정말로 이 폴더를 삭제하시겠습니까?",
|
||||
"columnOrder": "컬럼 순서",
|
||||
"configureColumnOrder": "열 순서 구성",
|
||||
"columnOrderDescription": "테이블의 열 표시 순서를 사용자 지정합니다.보이는 열만 (보이는/숨겨진 필터를 기반으로 함) 재정렬할 수 있습니다.열을 드래그하여 순서를 변경할 수 있습니다.",
|
||||
"noColumnsOrdered": "정렬된 컬럼 없음",
|
||||
"columnsDefaultOrder": "열은 기본 순서로 표시됩니다.",
|
||||
"moveUp": "위로 이동",
|
||||
"moveDown": "아래로 이동"
|
||||
},
|
||||
"panel": {
|
||||
"header": "패널",
|
||||
|
||||
@@ -1389,7 +1389,14 @@
|
||||
"folders": "Mappen",
|
||||
"searchFolder": "Folder zoeken",
|
||||
"deleteFolder": "Map verwijderen",
|
||||
"deleteFolderMessage": "Weet je zeker dat je deze map wilt verwijderen?"
|
||||
"deleteFolderMessage": "Weet je zeker dat je deze map wilt verwijderen?",
|
||||
"columnOrder": "Kolomvolgorde",
|
||||
"configureColumnOrder": "Kolomvolgorde configureren",
|
||||
"columnOrderDescription": "Pas de weergavevolgorde van kolommen in de tabel aan. Alleen zichtbare kolommen (op basis van zichtbare/verborgen filters) kunnen opnieuw worden gerangschikt. Versleep de kolommen om de volgorde te wijzigen.",
|
||||
"noColumnsOrdered": "Geen kolommen besteld",
|
||||
"columnsDefaultOrder": "De kolommen worden in hun standaardvolgorde weergegeven",
|
||||
"moveUp": "Ga omhoog",
|
||||
"moveDown": "Naar beneden gaan"
|
||||
},
|
||||
"panel": {
|
||||
"header": "Paneel",
|
||||
|
||||
@@ -1389,7 +1389,14 @@
|
||||
"folders": "Pastas",
|
||||
"searchFolder": "Pasta de pesquisa",
|
||||
"deleteFolder": "Excluir pasta",
|
||||
"deleteFolderMessage": "Tem certeza de que deseja excluir esta pasta?"
|
||||
"deleteFolderMessage": "Tem certeza de que deseja excluir esta pasta?",
|
||||
"columnOrder": "Ordem das colunas",
|
||||
"configureColumnOrder": "Configurar a ordem das colunas",
|
||||
"columnOrderDescription": "Personalize a ordem de exibição das colunas na tabela. Somente colunas visíveis (com base em filtros visíveis/ocultos) podem ser reordenadas. Arraste as colunas para alterar sua ordem.",
|
||||
"noColumnsOrdered": "Nenhuma coluna ordenada",
|
||||
"columnsDefaultOrder": "As colunas serão exibidas em sua ordem padrão",
|
||||
"moveUp": "Mova-se para cima",
|
||||
"moveDown": "Mover para baixo"
|
||||
},
|
||||
"panel": {
|
||||
"header": "Painel",
|
||||
|
||||
@@ -1389,7 +1389,14 @@
|
||||
"folders": "Klasörler",
|
||||
"searchFolder": "Arama Klasörü",
|
||||
"deleteFolder": "Klasörü Sil",
|
||||
"deleteFolderMessage": "Bu klasörü silmek istediğinizden emin misiniz?"
|
||||
"deleteFolderMessage": "Bu klasörü silmek istediğinizden emin misiniz?",
|
||||
"columnOrder": "Sütun Sırası",
|
||||
"configureColumnOrder": "Sütun Sırasını Yapılandırma",
|
||||
"columnOrderDescription": "Tablodaki sütunların görüntüleme sırasını özelleştirin. Yalnızca görünür sütunlar (görünür/gizli filtrelere dayalı) yeniden sıralanabilir. Sıralarını değiştirmek için sütunları sürükleyin.",
|
||||
"noColumnsOrdered": "Sıralı sütun yok",
|
||||
"columnsDefaultOrder": "Sütunlar varsayılan sıralarında görüntülenecektir",
|
||||
"moveUp": "Yukarı hareket et",
|
||||
"moveDown": "Aşağı hareket et"
|
||||
},
|
||||
"panel": {
|
||||
"header": "Panel",
|
||||
|
||||
@@ -1389,7 +1389,14 @@
|
||||
"folders": "文件夹",
|
||||
"searchFolder": "搜索文件夹",
|
||||
"deleteFolder": "删除文件夹",
|
||||
"deleteFolderMessage": "确定要删除此文件夹吗?"
|
||||
"deleteFolderMessage": "确定要删除此文件夹吗?",
|
||||
"columnOrder": "列顺序",
|
||||
"configureColumnOrder": "配置列顺序",
|
||||
"columnOrderDescription": "自定义表格中各列的显示顺序。只能对可见列(基于可见/隐藏的过滤器)进行重新排序。拖动列以更改其顺序。",
|
||||
"noColumnsOrdered": "未订购任何列",
|
||||
"columnsDefaultOrder": "列将按其默认顺序显示",
|
||||
"moveUp": "向上移动",
|
||||
"moveDown": "向下移动"
|
||||
},
|
||||
"panel": {
|
||||
"header": "面板",
|
||||
|
||||
@@ -1362,4 +1362,518 @@ describe("TableConverter", () => {
|
||||
expect(result.series).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column Ordering", () => {
|
||||
it("should apply column order when specified", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { zebra: "z", apple: "a", banana: "b" },
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "100"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "all",
|
||||
column_order: ["banana", "zebra", "apple"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
const labelColumns = result.columns.filter((c: any) => !c.name.startsWith("value"));
|
||||
expect(labelColumns[0].name).toBe("banana");
|
||||
expect(labelColumns[1].name).toBe("zebra");
|
||||
expect(labelColumns[2].name).toBe("apple");
|
||||
});
|
||||
|
||||
it("should place unordered columns at the end alphabetically", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { zebra: "z", apple: "a", banana: "b", cat: "c" },
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "100"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "all",
|
||||
column_order: ["banana"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
const labelColumns = result.columns.filter((c: any) => !c.name.startsWith("value"));
|
||||
expect(labelColumns[0].name).toBe("banana");
|
||||
// Remaining should be alphabetically sorted
|
||||
expect(labelColumns[1].name).toBe("apple");
|
||||
expect(labelColumns[2].name).toBe("cat");
|
||||
expect(labelColumns[3].name).toBe("zebra");
|
||||
});
|
||||
|
||||
it("should filter out columns not in available columns from column order", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { apple: "a", banana: "b" },
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "100"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "all",
|
||||
column_order: ["zebra", "banana", "apple", "cat"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
const labelColumns = result.columns.filter((c: any) => !c.name.startsWith("value"));
|
||||
// Only banana and apple should be present (zebra and cat don't exist)
|
||||
expect(labelColumns.length).toBe(2);
|
||||
expect(labelColumns[0].name).toBe("banana");
|
||||
expect(labelColumns[1].name).toBe("apple");
|
||||
});
|
||||
|
||||
it("should apply column order in expanded_timeseries mode", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { zebra: "z", apple: "a", banana: "b" },
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "100"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "expanded_timeseries",
|
||||
column_order: ["zebra", "apple", "banana"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
const labelColumns = result.columns.filter(
|
||||
(c: any) => c.name !== "timestamp" && c.name !== "value",
|
||||
);
|
||||
expect(labelColumns[0].name).toBe("zebra");
|
||||
expect(labelColumns[1].name).toBe("apple");
|
||||
expect(labelColumns[2].name).toBe("banana");
|
||||
});
|
||||
|
||||
it("should default to alphabetical sorting when no column order specified", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { zebra: "z", apple: "a", banana: "b" },
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "100"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "all",
|
||||
column_order: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
const labelColumns = result.columns.filter((c: any) => !c.name.startsWith("value"));
|
||||
expect(labelColumns[0].name).toBe("apple");
|
||||
expect(labelColumns[1].name).toBe("banana");
|
||||
expect(labelColumns[2].name).toBe("zebra");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases and Error Handling", () => {
|
||||
it("should handle empty processedData array", () => {
|
||||
const result = converter.convert([], { type: "table", config: {} }, mockStore, mockExtras);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.columns).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle series with no values", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { job: "api" },
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "single",
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle multiple queries", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { job: "api" },
|
||||
name: "query1",
|
||||
data: [],
|
||||
values: [[1234567890, "100"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { job: "web" },
|
||||
name: "query2",
|
||||
data: [],
|
||||
values: [[1234567890, "200"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "all",
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
expect(result.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle non-numeric string values", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { job: "api" },
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "NaN"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "single",
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
expect(result.rows[0].value).toBeNaN();
|
||||
});
|
||||
|
||||
it("should handle metric with many labels", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: {
|
||||
job: "api",
|
||||
instance: "localhost",
|
||||
env: "prod",
|
||||
region: "us-east",
|
||||
zone: "1a",
|
||||
pod: "pod-1",
|
||||
container: "app",
|
||||
namespace: "default",
|
||||
},
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "100"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "all",
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
const labelColumns = result.columns.filter((c: any) => !c.name.startsWith("value"));
|
||||
expect(labelColumns.length).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Value Mapping Edge Cases", () => {
|
||||
it("should handle value mapping when mapped text returns null", async () => {
|
||||
const { findFirstValidMappedValue } =
|
||||
await import("../convertDataIntoUnitValue");
|
||||
vi.mocked(findFirstValidMappedValue).mockReturnValueOnce(null);
|
||||
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: {},
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "100"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "single",
|
||||
mappings: [{ type: "value", value: 50, text: "Medium" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
// Should fall back to unit formatting
|
||||
const formatted = result.columns[1].format(100);
|
||||
expect(formatted).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle value mapping without text property", async () => {
|
||||
const { findFirstValidMappedValue } =
|
||||
await import("../convertDataIntoUnitValue");
|
||||
vi.mocked(findFirstValidMappedValue).mockReturnValueOnce({});
|
||||
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { env: "prod" },
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "100"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "all",
|
||||
mappings: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
const envColumn = result.columns.find((c: any) => c.name === "env");
|
||||
const formatted = envColumn.format("prod");
|
||||
expect(formatted).toBe("prod");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple Value Columns", () => {
|
||||
it("should create separate columns for multiple aggregations", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { job: "api" },
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "100"], [1234567900, "200"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "all",
|
||||
table_aggregations: ["min", "max"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
const valueColumns = result.columns.filter((c: any) =>
|
||||
c.name.startsWith("value_"),
|
||||
);
|
||||
expect(valueColumns).toHaveLength(2);
|
||||
expect(valueColumns[0].name).toBe("value_min");
|
||||
expect(valueColumns[1].name).toBe("value_max");
|
||||
expect(valueColumns[0].label).toBe("Value (min)");
|
||||
expect(valueColumns[1].label).toBe("Value (max)");
|
||||
});
|
||||
|
||||
it("should apply unit override to multiple value columns", () => {
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
metric: { job: "api" },
|
||||
name: "metric",
|
||||
data: [],
|
||||
values: [[1234567890, "100"]],
|
||||
},
|
||||
],
|
||||
timestamps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const panelSchema = {
|
||||
type: "table",
|
||||
config: {
|
||||
promql_table_mode: "all",
|
||||
table_aggregations: ["min", "max"],
|
||||
override_config: [
|
||||
{
|
||||
field: { value: "value_min" },
|
||||
config: [
|
||||
{
|
||||
type: "unit",
|
||||
value: { unit: "bytes", customUnit: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convert(
|
||||
processedData,
|
||||
panelSchema,
|
||||
mockStore,
|
||||
mockExtras,
|
||||
);
|
||||
|
||||
const minColumn = result.columns.find((c: any) => c.name === "value_min");
|
||||
const formatted = minColumn.format(100);
|
||||
expect(formatted).toBe("100.00B");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -55,11 +55,40 @@ export class TableConverter implements PromQLChartConverter {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply custom column ordering based on config
|
||||
* Note: Sticky columns remain in their ordered position (they don't move to front)
|
||||
* Sticky is just a CSS property for horizontal scrolling
|
||||
*/
|
||||
private applyColumnOrdering(
|
||||
labelKeys: string[],
|
||||
config: any,
|
||||
): string[] {
|
||||
const columnOrder = config.column_order || [];
|
||||
|
||||
// Apply custom ordering if specified
|
||||
if (columnOrder.length > 0) {
|
||||
// First: columns in column_order (in that order)
|
||||
const orderedKeys = columnOrder.filter((key) =>
|
||||
labelKeys.includes(key),
|
||||
);
|
||||
// Then: columns not in column_order (alphabetically)
|
||||
const unorderedKeys = labelKeys
|
||||
.filter((key) => !columnOrder.includes(key))
|
||||
.sort();
|
||||
return [...orderedKeys, ...unorderedKeys];
|
||||
} else {
|
||||
// Default: alphabetical sort
|
||||
return labelKeys.sort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build table columns from metric labels and value(s)
|
||||
* Supports multi-aggregation: creates separate columns for each selected aggregation
|
||||
* Supports column filtering: show/hide specific columns via config
|
||||
* Supports sticky columns: mark columns as sticky to keep them visible while scrolling
|
||||
* Supports column ordering: custom order for visible, non-sticky columns via config
|
||||
*/
|
||||
private buildColumns(
|
||||
processedData: ProcessedPromQLData[],
|
||||
@@ -198,8 +227,8 @@ export class TableConverter implements PromQLChartConverter {
|
||||
},
|
||||
];
|
||||
|
||||
// Add columns for each label
|
||||
const sortedLabelKeys = filteredLabelKeys.sort();
|
||||
// Add columns for each label with custom ordering
|
||||
const sortedLabelKeys = this.applyColumnOrdering(filteredLabelKeys, config);
|
||||
sortedLabelKeys.forEach((key) => {
|
||||
const isSticky = stickyColumns.includes(key);
|
||||
// Note: Don't apply makeFirstSticky here because timestamp is already the first column
|
||||
@@ -299,8 +328,11 @@ export class TableConverter implements PromQLChartConverter {
|
||||
const stickyColumns = config.sticky_columns || [];
|
||||
const makeFirstSticky = config.sticky_first_column || false;
|
||||
|
||||
// Apply custom column ordering
|
||||
const sortedLabelKeys = this.applyColumnOrdering(filteredLabelKeys, config);
|
||||
|
||||
// Create columns for each label
|
||||
const columns = filteredLabelKeys.map((key, index) => {
|
||||
const columns = sortedLabelKeys.map((key, index) => {
|
||||
const isSticky =
|
||||
stickyColumns.includes(key) || (makeFirstSticky && index === 0);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user