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>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10210/files#diff-fbba1700750053b9eaf38ddd30dab84531bc44131c795ebb713811e4930cd7cb">+2/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>mod.rs</strong><dd><code>Extend PanelConfig with new
ordering fields</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10210/files#diff-cbf3fe971525ba58892400391dfd7dac7e43d6049d6b4f5a9840ad104ebc5e08">+4/-0</a>&nbsp;
&nbsp; &nbsp; </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>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10210/files#diff-94123beacdf1e10ef812b4a6fda870004aa30f65eeb9e5ef6662f907dc50092d">+35/-3</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>ColumnOrderPopUp.vue</strong><dd><code>Add drag-drop popup
for column ordering</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10210/files#diff-e3f75c0798bebeefd7bffc7e3529daca6c128fc3fe655a15684105b78de3f50e">+349/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>PromQLChartConfig.vue</strong><dd><code>Integrate column
order controls in config UI</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10210/files#diff-305fcbfc89618c91ca0ee6c8c52cfa596169ca2dced6778eea6cd3c89f071573">+124/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>TableRenderer.vue</strong><dd><code>Apply sticky styling and
ordering in table renderer</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </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>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10210/files#diff-892aa3d75ab6e129ef9116aa21bec1b9a15ba69ce5366d2a19128078c690d824">+1/-0</a>&nbsp;
&nbsp; &nbsp; </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:
ktx-vaidehi
2026-01-27 20:34:03 +05:30
committed by GitHub
co-authored by github-actions[bot]
parent 3bdf5cb210
commit d8d4708dca
23 changed files with 4166 additions and 71 deletions
+4
View File
@@ -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,
+2
View File
@@ -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,
});
});
});
});
+136
View File
@@ -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,
};
}
+8 -1
View File
@@ -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",
+7
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -1405,7 +1405,14 @@
"folders": "フォルダー",
"searchFolder": "検索フォルダー",
"deleteFolder": "[フォルダを削除]",
"deleteFolderMessage": "このフォルダを削除してよろしいですか?"
"deleteFolderMessage": "このフォルダを削除してよろしいですか?",
"columnOrder": "列の順序",
"configureColumnOrder": "列の順序を設定",
"columnOrderDescription": "テーブル内の列の表示順序をカスタマイズします。順序を変更できるのは、表示されている列 (表示/非表示のフィルターに基づく) だけです。列をドラッグして順序を変更してください。",
"noColumnsOrdered": "列は順序付けられていません",
"columnsDefaultOrder": "列はデフォルトの順序で表示されます",
"moveUp": "上へ移動",
"moveDown": "下に移動"
},
"panel": {
"header": "パネル",
+8 -1
View File
@@ -1389,7 +1389,14 @@
"folders": "폴더",
"searchFolder": "검색 폴더",
"deleteFolder": "폴더 삭제",
"deleteFolderMessage": "정말로 이 폴더를 삭제하시겠습니까?"
"deleteFolderMessage": "정말로 이 폴더를 삭제하시겠습니까?",
"columnOrder": "컬럼 순서",
"configureColumnOrder": "열 순서 구성",
"columnOrderDescription": "테이블의 열 표시 순서를 사용자 지정합니다.보이는 열만 (보이는/숨겨진 필터를 기반으로 함) 재정렬할 수 있습니다.열을 드래그하여 순서를 변경할 수 있습니다.",
"noColumnsOrdered": "정렬된 컬럼 없음",
"columnsDefaultOrder": "열은 기본 순서로 표시됩니다.",
"moveUp": "위로 이동",
"moveDown": "아래로 이동"
},
"panel": {
"header": "패널",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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);