Files
openobserve/web/src/composables/useStickyColumns.spec.ts
T
2026-07-24 07:11:39 +00:00

574 lines
18 KiB
TypeScript

// Copyright 2026 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";
import { chartColor } from "@/utils/chartTheme";
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 = {
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.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.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.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.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.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.columns = [{ name: "col1" }, { name: "col2" }, { name: "col3" }];
composable = useStickyColumns(mockProps, mockStore);
await nextTick();
expect(mockProps.columns[0].__colIndex).toBe(0);
expect(mockProps.columns[1].__colIndex).toBe(1);
expect(mockProps.columns[2].__colIndex).toBe(2);
});
it("should handle empty columns array", async () => {
mockProps.columns = [];
composable = useStickyColumns(mockProps, mockStore);
await nextTick();
expect(composable.stickyColumnOffsets.value).toEqual({});
});
it("should handle undefined columns", async () => {
mockProps.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.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": chartColor("--color-surface-base"),
"box-shadow": "4px 0 8px rgba(0, 0, 0, 0.15)",
});
});
it("should use correct left offset from precomputed offsets", async () => {
mockProps.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 the surface-base token color in dark theme", () => {
mockStore.state.theme = "dark";
composable = useStickyColumns(mockProps, mockStore);
const style = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
// Background is now the resolved --color-surface-base token (theme-independent in jsdom)
expect(style["background-color"]).toBe(chartColor("--color-surface-base"));
});
it("should apply the surface-base token color in light theme", () => {
mockStore.state.theme = "light";
composable = useStickyColumns(mockProps, mockStore);
const style = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
expect(style["background-color"]).toBe(chartColor("--color-surface-base"));
});
});
describe("Style Generation Logic", () => {
it("should calculate offsets for style generation", () => {
mockProps.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.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.columns = [
{ name: "col1", sticky: true },
{ name: "col2", sticky: false },
];
composable = useStickyColumns(mockProps, mockStore);
// __colIndex added for nth-child CSS selectors
expect(mockProps.columns[0].__colIndex).toBe(0);
expect(mockProps.columns[1].__colIndex).toBe(1);
});
it("should determine theme color for styles", () => {
mockStore.state.theme = "dark";
mockProps.columns = [{ name: "col1", sticky: true }];
composable = useStickyColumns(mockProps, mockStore);
const style = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
// Background color is driven by the --color-surface-base token
expect(style["background-color"]).toBe(chartColor("--color-surface-base"));
});
it("should skip offset calculation for non-sticky columns", () => {
mockProps.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.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.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.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.columns[0].__colIndex).toBe(0);
expect(mockProps.columns[1].__colIndex).toBe(1);
expect(mockProps.columns[2].__colIndex).toBe(2);
});
it("should return consistent offsets reference", () => {
mockProps.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.columns = [{ name: "col1", sticky: true }];
composable = useStickyColumns(mockProps, mockStore);
expect(composable).toHaveProperty("stickyColumnOffsets");
expect(composable.stickyColumnOffsets.value).toBeDefined();
});
it("should return getStickyColumnStyle function", () => {
mockProps.columns = [];
composable = useStickyColumns(mockProps, mockStore);
expect(composable).toHaveProperty("getStickyColumnStyle");
expect(typeof composable.getStickyColumnStyle).toBe("function");
});
it("should only return expected properties", () => {
mockProps.columns = [];
composable = useStickyColumns(mockProps, mockStore);
const keys = Object.keys(composable);
expect(keys).toHaveLength(3);
expect(keys).toContain("stickyColumnOffsets");
expect(keys).toContain("getStickyColumnStyle");
expect(keys).toContain("tableId");
});
});
describe("Edge Cases", () => {
it("should handle columns with zero width", () => {
mockProps.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.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.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.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.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.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.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.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.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.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 return the token-driven color regardless of theme", () => {
mockProps.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(chartColor("--color-surface-base"));
// The color now comes from the --color-surface-base token, which resolves the
// same in jsdom for both themes (dark handled by CSS, not JS).
mockStore.state.theme = "dark";
const style2 = composable.getStickyColumnStyle({ sticky: true, name: "col1" });
expect(style2["background-color"]).toBe(chartColor("--color-surface-base"));
});
it("should handle immediate watcher execution", () => {
// The watch has immediate: true, so it runs on setup
mockProps.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,
});
});
});
});