diff --git a/src/pivot-table/components/PivotTable.tsx b/src/pivot-table/components/PivotTable.tsx index da64dcfd..34820361 100644 --- a/src/pivot-table/components/PivotTable.tsx +++ b/src/pivot-table/components/PivotTable.tsx @@ -94,6 +94,7 @@ export const StickyPivotTable = ({ showLastRightBorder, getRightGridColumnWidth, getHeaderCellsIconsVisibilityStatus, + overrideLeftGridWidth, } = useColumnWidth( layoutService, tableRect, @@ -162,6 +163,7 @@ export const StickyPivotTable = ({ translator={translator} changeSortOrder={changeSortOrder} changeActivelySortedHeader={changeActivelySortedHeader} + overrideLeftGridWidth={overrideLeftGridWidth} /> { +const ColumnAdjuster = ({ cellInfo, columnWidth, dataModel, isLastColumn, overrideLeftGridWidth }: AdjusterProps) => { const { interactions } = useBaseContext(); const { isActive } = useSelectionsContext(); const [, forceRerender] = useState({}); @@ -36,6 +37,7 @@ const ColumnAdjuster = ({ cellInfo, columnWidth, dataModel, isLastColumn }: Adju const adjustedWidth = Math.max(tempWidth.initWidth + deltaWidth, ColumnWidthValues.PixelsMin); forceRerender({}); tempWidth.columnWidth = adjustedWidth; + overrideLeftGridWidth?.(adjustedWidth, cellInfo.dimensionInfoIndex); }; const mouseUpHandler = (evt: MouseEvent) => { diff --git a/src/pivot-table/components/cells/DimensionTitleCell.tsx b/src/pivot-table/components/cells/DimensionTitleCell.tsx index c3112162..ce4a1612 100644 --- a/src/pivot-table/components/cells/DimensionTitleCell.tsx +++ b/src/pivot-table/components/cells/DimensionTitleCell.tsx @@ -8,7 +8,7 @@ import type { ChangeActivelySortedHeader, ChangeSortOrder, DataModel, HeaderCell import { HEADER_ICON_SIZE } from "../../constants"; import { useBaseContext } from "../../contexts/BaseProvider"; import { useStyleContext } from "../../contexts/StyleProvider"; -import type { GetHeaderCellsIconsVisibilityStatus } from "../../hooks/use-column-width"; +import type { GetHeaderCellsIconsVisibilityStatus, OverrideLeftGridWidth } from "../../hooks/use-column-width"; import { useHeadCellDim } from "../../hooks/use-head-cell-dim"; import { baseCellStyle, getBorderStyle } from "../shared-styles"; import ColumnAdjuster from "./ColumnAdjuster"; @@ -24,6 +24,7 @@ interface DimensionTitleCellProps { changeActivelySortedHeader: ChangeActivelySortedHeader; iconsVisibilityStatus: ReturnType; columnWidth: number; + overrideLeftGridWidth: OverrideLeftGridWidth; } export const testId = "title-cell"; @@ -44,6 +45,7 @@ const DimensionTitleCell = ({ changeActivelySortedHeader, iconsVisibilityStatus, columnWidth, + overrideLeftGridWidth, }: DimensionTitleCellProps): JSX.Element => { const listboxRef = useRef(null); const styleService = useStyleContext(); @@ -122,6 +124,7 @@ const DimensionTitleCell = ({ columnWidth={columnWidth} dataModel={dataModel} isLastColumn={isLastColumn} + overrideLeftGridWidth={overrideLeftGridWidth} /> ); diff --git a/src/pivot-table/components/cells/__tests__/DimensionTitleCell.test.tsx b/src/pivot-table/components/cells/__tests__/DimensionTitleCell.test.tsx index 4947157c..c54d9889 100644 --- a/src/pivot-table/components/cells/__tests__/DimensionTitleCell.test.tsx +++ b/src/pivot-table/components/cells/__tests__/DimensionTitleCell.test.tsx @@ -17,6 +17,7 @@ describe("DimensionTitleCell", () => { const translator = { get: (s) => s } as stardust.Translator; const changeSortOrder = jest.fn(); const changeActivelySortedColumn = jest.fn(); + const overrideLeftGridWidth = jest.fn(); const style: React.CSSProperties = { position: "relative", left: "25px", @@ -28,7 +29,6 @@ describe("DimensionTitleCell", () => { shouldShowMenuIcon: true, shouldShowLockIcon: true, }; - let component: React.JSX.Element; beforeEach(() => { @@ -43,6 +43,7 @@ describe("DimensionTitleCell", () => { changeActivelySortedHeader={changeActivelySortedColumn} iconsVisibilityStatus={iconsVisibilityStatus} columnWidth={100} + overrideLeftGridWidth={overrideLeftGridWidth} /> ); }); diff --git a/src/pivot-table/components/grids/HeaderGrid.tsx b/src/pivot-table/components/grids/HeaderGrid.tsx index 85422283..43d7d242 100644 --- a/src/pivot-table/components/grids/HeaderGrid.tsx +++ b/src/pivot-table/components/grids/HeaderGrid.tsx @@ -1,7 +1,7 @@ import type { stardust } from "@nebula.js/stardust"; import React, { memo } from "react"; import type { ChangeActivelySortedHeader, ChangeSortOrder, DataModel, HeadersData } from "../../../types/types"; -import type { GetHeaderCellsIconsVisibilityStatus } from "../../hooks/use-column-width"; +import type { GetHeaderCellsIconsVisibilityStatus, OverrideLeftGridWidth } from "../../hooks/use-column-width"; import DimensionTitleCell from "../cells/DimensionTitleCell"; import EmptyHeaderCell from "../cells/EmptyHeaderCell"; @@ -15,6 +15,7 @@ interface HeaderGridProps { changeActivelySortedHeader: ChangeActivelySortedHeader; getHeaderCellsIconsVisibilityStatus: GetHeaderCellsIconsVisibilityStatus; height: number; + overrideLeftGridWidth: OverrideLeftGridWidth; } const containerStyle: React.CSSProperties = { @@ -32,6 +33,7 @@ const HeaderGrid = ({ changeActivelySortedHeader, getHeaderCellsIconsVisibilityStatus, height, + overrideLeftGridWidth, }: HeaderGridProps): JSX.Element | null => (
, ); } diff --git a/src/pivot-table/hooks/__tests__/use-column-width.test.ts b/src/pivot-table/hooks/__tests__/use-column-width.test.ts index b2e4b771..125b6020 100644 --- a/src/pivot-table/hooks/__tests__/use-column-width.test.ts +++ b/src/pivot-table/hooks/__tests__/use-column-width.test.ts @@ -4,7 +4,7 @@ import { type MeasureTextHook, type UseMeasureTextProps, } from "@qlik/nebula-table-utils/lib/hooks"; -import { renderHook } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import type { ExtendedDimensionInfo, ExtendedMeasureInfo } from "../../../types/QIX"; import { ColumnWidthType } from "../../../types/QIX"; import type { HeadersData, LayoutService, Rect, VisibleDimensionInfo } from "../../../types/types"; @@ -75,6 +75,7 @@ describe("useColumnWidth", () => { mockedUseMeasureText.mockReturnValue(mockedMeasureText); verticalScrollbarWidth = 0; horizontalScrollbarHeightSetter = jest.fn(); + headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); }); afterEach(() => { @@ -84,17 +85,16 @@ describe("useColumnWidth", () => { const renderUseColumnWidth = () => { const { result: { current }, - } = renderHook(() => { - headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); - return useColumnWidth( + } = renderHook(() => + useColumnWidth( layoutService, rect, headersData, visibleTopDimensionInfo, verticalScrollbarWidth, horizontalScrollbarHeightSetter, - ); - }); + ), + ); return current; }; @@ -132,6 +132,7 @@ describe("useColumnWidth", () => { qGroupFieldDefs: [""], } as ExtendedDimensionInfo; visibleLeftDimensionInfo = [dimInfo, dimInfoWithoutPixels, dimInfoWithNaN]; + headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); const { leftGridColumnWidths } = renderUseColumnWidth(); expect(leftGridColumnWidths[0]).toBe(pixels); @@ -154,6 +155,7 @@ describe("useColumnWidth", () => { qGroupFieldDefs: [""], } as ExtendedDimensionInfo; visibleLeftDimensionInfo = [dimInfo, dimInfoWithoutPixels, dimInfoWithNaN]; + headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); const { leftGridColumnWidths } = renderUseColumnWidth(); expect(leftGridColumnWidths[0]).toBe(percentage * percentageConversion); @@ -172,6 +174,7 @@ describe("useColumnWidth", () => { { columnWidth: { type: ColumnWidthType.Percentage, percentage: 10 } } as ExtendedMeasureInfo, { columnWidth: { type: ColumnWidthType.Pixels, pixels: 60 } } as ExtendedMeasureInfo, ]; + headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); const { leftGridColumnWidths } = renderUseColumnWidth(); expect(leftGridColumnWidths[3]).toBe(60); @@ -188,12 +191,37 @@ describe("useColumnWidth", () => { qGroupFieldDefs: [""], } as ExtendedDimensionInfo; visibleLeftDimensionInfo = [dimInfo, dimInfo, dimInfoWithoutPixels]; - const { leftGridColumnWidths } = renderUseColumnWidth(); + headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); + const { leftGridColumnWidths } = renderUseColumnWidth(); expect(leftGridColumnWidths[0]).toBe(pixels); expect(leftGridColumnWidths[1]).toBe(pixels); expect(leftGridColumnWidths[2]).toBe(ColumnWidthValues.PixelsDefault); }); + + test("should return left column width when overridden using overrideLeftColumnWidth", () => { + const width = 25; + mockEstimateWidth(width); + mockMeasureText(width); + + // Need to render this explicitly, since renderUseColumnWidth returns current, and thus leftGridColumnWidths wont update after overrideLeftGridWidth() + const { result } = renderHook(() => + useColumnWidth( + layoutService, + rect, + headersData, + visibleTopDimensionInfo, + verticalScrollbarWidth, + horizontalScrollbarHeightSetter, + ), + ); + + act(() => result.current.overrideLeftGridWidth(width * 3, 0)); + + expect(result.current.leftGridColumnWidths[0]).toBe(width * 3); + expect(result.current.leftGridColumnWidths[1]).toBe(width + EXPAND_ICON_SIZE + TOTAL_CELL_PADDING); + expect(result.current.leftGridColumnWidths[2]).toBe(width + TOTAL_CELL_PADDING + MENU_ICON_SIZE); + }); }); describe("getRightGridColumnWidth", () => { @@ -209,6 +237,7 @@ describe("useColumnWidth", () => { } as ExtendedDimensionInfo, ]; visibleTopDimensionInfo = [dimInfo, dimInfo, -1]; + headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); }); test("should return right column width when columnWidth is undefined", () => { @@ -415,7 +444,7 @@ describe("useColumnWidth", () => { }); }); - describe("getHeaderCellsIconsVisibilityStatus()", () => { + describe("getHeaderCellsIconsVisibilityStatus", () => { const columnWidthInPixels = 100; test("should return `shouldShowMenuIcon` as true, b/c estimated width for text is small and there is enough space in each column", () => { @@ -423,7 +452,7 @@ describe("useColumnWidth", () => { type: ColumnWidthType.Pixels, pixels: columnWidthInPixels, }; - + headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); mockMeasureText(columnWidthInPixels - TOTAL_CELL_PADDING - MENU_ICON_SIZE); const { getHeaderCellsIconsVisibilityStatus } = renderUseColumnWidth(); @@ -438,6 +467,7 @@ describe("useColumnWidth", () => { type: ColumnWidthType.Pixels, pixels: columnWidthInPixels + MENU_ICON_SIZE - 1, // -1 is what makes the test pass }; + headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); mockMeasureText(columnWidthInPixels - TOTAL_CELL_PADDING); const { getHeaderCellsIconsVisibilityStatus } = renderUseColumnWidth(); @@ -453,7 +483,7 @@ describe("useColumnWidth", () => { type: ColumnWidthType.Pixels, pixels: columnWidthInPixels, }; - + headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); mockMeasureText(columnWidthInPixels - TOTAL_CELL_PADDING - LOCK_ICON_SIZE - MENU_ICON_SIZE); const { getHeaderCellsIconsVisibilityStatus } = renderUseColumnWidth(); @@ -468,6 +498,7 @@ describe("useColumnWidth", () => { type: ColumnWidthType.Pixels, pixels: columnWidthInPixels, }; + headersData = createHeadersData(layoutService, visibleTopDimensionInfo, visibleLeftDimensionInfo); // Mock the measureTextForHeader call inside getHeaderCellsIconsVisibilityStatus() mockMeasureText(columnWidthInPixels - TOTAL_CELL_PADDING - LOCK_ICON_SIZE); diff --git a/src/pivot-table/hooks/use-column-width.ts b/src/pivot-table/hooks/use-column-width.ts index 71342e5a..39ea4c78 100644 --- a/src/pivot-table/hooks/use-column-width.ts +++ b/src/pivot-table/hooks/use-column-width.ts @@ -1,5 +1,5 @@ -import { useMeasureText } from "@qlik/nebula-table-utils/lib/hooks"; -import { useCallback, useEffect, useMemo } from "react"; +import { useMeasureText, useOnPropsChange } from "@qlik/nebula-table-utils/lib/hooks"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { PSEUDO_DIMENSION_INDEX, PSEUDO_DIMENSION_KEY } from "../../constants"; import { ColumnWidthType, type ColumnWidth } from "../../types/QIX"; import type { @@ -14,12 +14,15 @@ import { CELL_PADDING, DOUBLE_CELL_PADDING } from "../components/shared-styles"; import { GRID_BORDER, HEADER_ICON_SIZE, PLUS_MINUS_ICON_SIZE } from "../constants"; import { useStyleContext } from "../contexts/StyleProvider"; +export type OverrideLeftGridWidth = (width: number, index: number) => void; + interface ColumnWidthHook extends LeftGridWidthInfo { rightGridWidth: number; totalWidth: number; showLastRightBorder: boolean; getRightGridColumnWidth: (index?: number) => number; getHeaderCellsIconsVisibilityStatus: GetHeaderCellsIconsVisibilityStatus; + overrideLeftGridWidth: OverrideLeftGridWidth; } export interface GetHeaderCellsIconsVisibilityStatus { @@ -97,59 +100,83 @@ export default function useColumnWidth( }); /** - * The widths of the left columns. Scales the width to fit LEFT_SIDE_MAX_WIDTH_RATIO * rect.width if wider than that + * Calculates widths of the left columns as well as the sum of the widths. + * If the sum of widths exceed LEFT_SIDE_MAX_WIDTH_RATIO * rect.width, the left side will become scrollable */ - const leftGridWidthInfo = useMemo(() => { - const getColumnWidth = (columnWidth: ColumnWidth | undefined, fitToContentWidth: number) => { - switch (columnWidth?.type) { - case ColumnWidthType.Pixels: - return getPixelValue(columnWidth.pixels); - case ColumnWidthType.Percentage: - return getPercentageValue(columnWidth.percentage) * rect.width; - default: - // fit to content / auto - return fitToContentWidth; - } - }; - - let sumOfWidths = 0; - - const lastRow = headersData.data[headersData.size.y - 1] as HeaderCell[]; - const columnWidths = lastRow.map((cell, index) => { - let width: number; + const calculateLeftGridWidthInfo = useCallback( + (widthOverride?: number, overrideIndex?: number) => { + const getColumnWidth = (columnWidth: ColumnWidth | undefined, fitToContentWidth: number) => { + switch (columnWidth?.type) { + case ColumnWidthType.Pixels: + return getPixelValue(columnWidth.pixels); + case ColumnWidthType.Percentage: + return getPercentageValue(columnWidth.percentage) * rect.width; + default: + // fit to content / auto + return fitToContentWidth; + } + }; - if (cell.id === PSEUDO_DIMENSION_KEY) { - // Use the max width of all measures - width = Math.max( - ...qMeasureInfo.map(({ qFallbackTitle, columnWidth }) => { - const fitToContentWidth = measureTextForDimensionValue(qFallbackTitle) + TOTAL_CELL_PADDING; - return getColumnWidth(columnWidth, fitToContentWidth); - }), - ); - } else { - const { label, qApprMaxGlyphCount, columnWidth, isLocked } = cell; - const expandIconSize = !isFullyExpanded && index < qNoOfLeftDims - 1 ? EXPAND_ICON_SIZE : 0; - const lockedIconSize = isLocked ? LOCK_ICON_SIZE : 0; - - const fitToContentWidth = - TOTAL_CELL_PADDING + - Math.max( - measureTextForHeader(label) + MENU_ICON_SIZE + lockedIconSize, - estimateWidthForDimensionValue(qApprMaxGlyphCount as number) + expandIconSize, + let sumOfWidths = 0; + + const lastRow = headersData.data[headersData.size.y - 1] as HeaderCell[]; + const columnWidths = lastRow.map((cell, index) => { + let width: number; + + if (widthOverride && overrideIndex !== undefined && overrideIndex === index) { + width = widthOverride; + } else if (cell.id === PSEUDO_DIMENSION_KEY) { + // Use the max width of all measures + width = Math.max( + ...qMeasureInfo.map(({ qFallbackTitle, columnWidth }) => { + const fitToContentWidth = measureTextForDimensionValue(qFallbackTitle) + TOTAL_CELL_PADDING; + return getColumnWidth(columnWidth, fitToContentWidth); + }), ); + } else { + const { label, qApprMaxGlyphCount, columnWidth, isLocked } = cell; + const expandIconSize = !isFullyExpanded && index < qNoOfLeftDims - 1 ? EXPAND_ICON_SIZE : 0; + const lockedIconSize = isLocked ? LOCK_ICON_SIZE : 0; + + const fitToContentWidth = + TOTAL_CELL_PADDING + + Math.max( + measureTextForHeader(label) + MENU_ICON_SIZE + lockedIconSize, + estimateWidthForDimensionValue(qApprMaxGlyphCount as number) + expandIconSize, + ); + + width = getColumnWidth(columnWidth, fitToContentWidth); + } - width = getColumnWidth(columnWidth, fitToContentWidth); - } + sumOfWidths += width; + return width; + }); - sumOfWidths += width; - return width; - }); + return { + leftGridWidth: Math.min(rect.width * LEFT_GRID_MAX_WIDTH_RATIO, sumOfWidths), + leftGridColumnWidths: columnWidths, + leftGridFullWidth: sumOfWidths, + }; + }, + [ + estimateWidthForDimensionValue, + headersData.data, + headersData.size.y, + isFullyExpanded, + measureTextForDimensionValue, + measureTextForHeader, + qMeasureInfo, + qNoOfLeftDims, + rect.width, + ], + ); - return { - leftGridWidth: Math.min(rect.width * LEFT_GRID_MAX_WIDTH_RATIO, sumOfWidths), - leftGridColumnWidths: columnWidths, - leftGridFullWidth: sumOfWidths, - }; + // Note that it is not `calculateLeftGridWidthInfo` itself that is stored on the state, + // it is the return value of that function, react will run it on first render + const [leftGridWidthInfo, setLeftGridWidthInfo] = useState(calculateLeftGridWidthInfo); + + useOnPropsChange(() => { + setLeftGridWidthInfo(calculateLeftGridWidthInfo()); }, [ headersData, rect.width, @@ -159,8 +186,16 @@ export default function useColumnWidth( qNoOfLeftDims, measureTextForHeader, estimateWidthForDimensionValue, + calculateLeftGridWidthInfo, ]); + const overrideLeftGridWidth = useCallback( + (width: number, index: number) => { + setLeftGridWidthInfo(calculateLeftGridWidthInfo(width, index)); + }, + [calculateLeftGridWidthInfo], + ); + const getHeaderCellsIconsVisibilityStatus = useCallback( (idx, isLocked, title = "") => { const colWidth = leftGridWidthInfo.leftGridColumnWidths[idx]; @@ -331,5 +366,6 @@ export default function useColumnWidth( showLastRightBorder, getRightGridColumnWidth, getHeaderCellsIconsVisibilityStatus, + overrideLeftGridWidth, }; }