From 6dd3ba75e4c79df2576deade1ea49a980cbc7c10 Mon Sep 17 00:00:00 2001 From: dhrp-odoo Date: Wed, 27 May 2026 11:04:41 +0530 Subject: [PATCH] [IMP] header: add resize panel for rows and columns Allow users to resize selected rows or columns from the header context menu. The panel supports setting an exact size in pixels and fitting the selection to its data. Task: 6234780 --- src/actions/menu_items_actions.ts | 26 +++ src/actions/view_actions.ts | 16 ++ src/components/icons/icons.xml | 10 ++ src/components/number_input/number_input.xml | 1 + .../header_resize_panel.ts | 123 +++++++++++++ .../header_resize_panel.xml | 53 ++++++ src/registries/menus/col_menu_registry.ts | 5 +- src/registries/menus/row_menu_registry.ts | 5 +- src/registries/side_panel_registry.ts | 24 ++- .../header_resize_panel_component.test.ts | 169 ++++++++++++++++++ tests/menus/menu_items_registry.test.ts | 71 ++++++++ 11 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 src/components/side_panel/header_resize_panel/header_resize_panel.ts create mode 100644 src/components/side_panel/header_resize_panel/header_resize_panel.xml create mode 100644 tests/headers/header_resize_panel_component.test.ts diff --git a/src/actions/menu_items_actions.ts b/src/actions/menu_items_actions.ts index 3023adfcca..38dcfb6178 100644 --- a/src/actions/menu_items_actions.ts +++ b/src/actions/menu_items_actions.ts @@ -389,6 +389,32 @@ export const HIDE_ROWS_NAME = (env: SpreadsheetChildEnv) => { } }; +export const RESIZE_COLUMNS_NAME = (env: SpreadsheetChildEnv) => { + const cols = [...env.model.getters.getActiveCols()].sort((a, b) => a - b); + const first = cols[0]; + const last = cols[cols.length - 1]; + if (cols.length === 1) { + return _t("Resize column %s", numberToLetters(first)); + } else if (last - first + 1 === cols.length) { + return _t("Resize columns %s - %s", numberToLetters(first), numberToLetters(last)); + } else { + return _t("Resize columns"); + } +}; + +export const RESIZE_ROWS_NAME = (env: SpreadsheetChildEnv) => { + const rows = [...env.model.getters.getActiveRows()].sort((a, b) => a - b); + const first = rows[0]; + const last = rows[rows.length - 1]; + if (rows.length === 1) { + return _t("Resize row %s", first + 1); + } else if (last - first + 1 === rows.length) { + return _t("Resize rows %s - %s", first + 1, last + 1); + } else { + return _t("Resize rows"); + } +}; + //------------------------------------------------------------------------------ // Charts //------------------------------------------------------------------------------ diff --git a/src/actions/view_actions.ts b/src/actions/view_actions.ts index a48725a6ad..d6c7ca0988 100644 --- a/src/actions/view_actions.ts +++ b/src/actions/view_actions.ts @@ -112,6 +112,22 @@ export const unhideAllRows: ActionSpec = { icon: "o-spreadsheet-Icon.UNHIDE_ROW", }; +export const resizeCols: ActionSpec = { + name: ACTIONS.RESIZE_COLUMNS_NAME, + execute: (env) => env.openSidePanel("HeaderResizePanel", { dimension: "COL" }), + isVisible: (env) => env.model.getters.getActiveCols().size > 0, + isEnabled: (env) => !env.model.getters.isCurrentSheetLocked(), + icon: "o-spreadsheet-Icon.RESIZE_HORIZONTAL", +}; + +export const resizeRows: ActionSpec = { + name: ACTIONS.RESIZE_ROWS_NAME, + execute: (env) => env.openSidePanel("HeaderResizePanel", { dimension: "ROW" }), + isVisible: (env) => env.model.getters.getActiveRows().size > 0, + isEnabled: (env) => !env.model.getters.isCurrentSheetLocked(), + icon: "o-spreadsheet-Icon.RESIZE_VERTICAL", +}; + export const unFreezePane: ActionSpec = { name: _t("Unfreeze"), isVisible: (env) => { diff --git a/src/components/icons/icons.xml b/src/components/icons/icons.xml index 5298a1f9a0..95bdbc203a 100644 --- a/src/components/icons/icons.xml +++ b/src/components/icons/icons.xml @@ -220,6 +220,16 @@ /> + +
+ +
+
+ +
+ +
+
{ + static template = "o-spreadsheet-HeaderResizePanel"; + static components = { NumberInput, Section, ValidationMessages }; + + protected props = props({ + sheetId: types.UID(), + dimension: types.Dimension(), + elements: types.array(types.HeaderIndex()), + onCloseSidePanel: types.function([]), + }); + + state = proxy({ + mode: "exactSize", + inputValue: String(this.currentSize), + }); + + get minSize(): number { + return this.props.dimension === "COL" ? MIN_COL_WIDTH : MIN_ROW_HEIGHT; + } + + get maxSize(): number { + return 2000; + } + + get sizeInputLabel(): string { + return this.props.dimension === "COL" + ? _t("Enter new column width in pixels. (Default: %s)", DEFAULT_CELL_WIDTH) + : _t("Enter new row height in pixels. (Default: %s)", DEFAULT_CELL_HEIGHT); + } + + get errorMessages(): string[] { + if (this.state.mode === "fitToData") { + return []; + } + const size = this.state.inputValue.trim(); + if (!size) { + return [_t("Enter a size in pixels.")]; + } + if (!/^\d+$/.test(size)) { + return [_t("Size must be an integer.")]; + } + const sizeNumber = Number(size); + if (sizeNumber < this.minSize || sizeNumber > this.maxSize) { + return [ + _t( + "Size must be between %s and %s pixels.", + this.minSize.toString(), + this.maxSize.toString() + ), + ]; + } + return []; + } + + onModeChanged(mode: ResizeMode) { + this.state.mode = mode; + } + + onSizeChanged(inputValue: string) { + this.state.inputValue = inputValue; + this.state.mode = "exactSize"; + } + + applyResize() { + if (this.errorMessages.length > 0) { + return; + } + + let result: DispatchResult; + if (this.state.mode === "exactSize") { + const size = Number(this.state.inputValue.trim()); + result = this.env.model.dispatch("RESIZE_COLUMNS_ROWS", { + sheetId: this.props.sheetId, + dimension: this.props.dimension, + elements: this.props.elements, + size, + }); + } else if (this.props.dimension === "COL") { + result = this.env.model.dispatch("AUTORESIZE_COLUMNS", { + sheetId: this.props.sheetId, + cols: this.props.elements, + }); + } else { + result = this.env.model.dispatch("AUTORESIZE_ROWS", { + sheetId: this.props.sheetId, + rows: this.props.elements, + }); + } + if (result.isSuccessful) { + this.props.onCloseSidePanel(); + } + } + + private get currentSize(): number { + const element = this.props.elements[0]; + return this.props.dimension === "COL" + ? this.env.model.getters.getColSize(this.props.sheetId, element) + : this.env.model.getters.getRowSize(this.props.sheetId, element); + } +} diff --git a/src/components/side_panel/header_resize_panel/header_resize_panel.xml b/src/components/side_panel/header_resize_panel/header_resize_panel.xml new file mode 100644 index 0000000000..a66ecfb293 --- /dev/null +++ b/src/components/side_panel/header_resize_panel/header_resize_panel.xml @@ -0,0 +1,53 @@ + + +
+
+ + + +
+
+ +
+
+
+ +
+
+
+
+ diff --git a/src/registries/menus/col_menu_registry.ts b/src/registries/menus/col_menu_registry.ts index b253170e06..0362ac8995 100644 --- a/src/registries/menus/col_menu_registry.ts +++ b/src/registries/menus/col_menu_registry.ts @@ -76,11 +76,14 @@ colMenuRegistry .add("hide_columns", { ...ACTION_VIEW.hideCols, sequence: 105, - separator: true, }) .add("unhide_columns", { ...ACTION_VIEW.unhideCols, sequence: 106, + }) + .add("resize_columns", { + ...ACTION_VIEW.resizeCols, + sequence: 107, separator: true, }) .add("conditional_formatting", { diff --git a/src/registries/menus/row_menu_registry.ts b/src/registries/menus/row_menu_registry.ts index b6be89e102..61dd64e285 100644 --- a/src/registries/menus/row_menu_registry.ts +++ b/src/registries/menus/row_menu_registry.ts @@ -53,11 +53,14 @@ rowMenuRegistry .add("hide_rows", { ...ACTION_VIEW.hideRows, sequence: 85, - separator: true, }) .add("unhide_rows", { ...ACTION_VIEW.unhideRows, sequence: 86, + }) + .add("resize_rows", { + ...ACTION_VIEW.resizeRows, + sequence: 87, separator: true, }) .add("conditional_formatting", { diff --git a/src/registries/side_panel_registry.ts b/src/registries/side_panel_registry.ts index ed68fc91d9..c45ddd4f03 100644 --- a/src/registries/side_panel_registry.ts +++ b/src/registries/side_panel_registry.ts @@ -1,3 +1,4 @@ +import { RESIZE_COLUMNS_NAME, RESIZE_ROWS_NAME } from "../actions/menu_items_actions"; import { CarouselPanel } from "../components/side_panel/carousel_panel/carousel_panel"; import { ChartPanel } from "../components/side_panel/chart/main_chart_panel/main_chart_panel"; import { ColumnStatsPanel } from "../components/side_panel/column_stats/column_stats_panel"; @@ -6,6 +7,7 @@ import { ConditionalFormatPreviewList } from "../components/side_panel/condition import { DataValidationPanel } from "../components/side_panel/data_validation/data_validation_panel"; import { DataValidationEditor } from "../components/side_panel/data_validation/dv_editor/dv_editor"; import { FindAndReplacePanel } from "../components/side_panel/find_and_replace/find_and_replace"; +import { HeaderResizePanel } from "../components/side_panel/header_resize_panel/header_resize_panel"; import { MoreFormatsPanel } from "../components/side_panel/more_formats/more_formats"; import { NamedRangesPanel } from "../components/side_panel/named_ranges_panel/named_ranges_panel"; import { PerfProfilePanel } from "../components/side_panel/perf_profile/perf_profile_panel"; @@ -21,7 +23,7 @@ import { getTableTopLeft } from "../helpers/table_helpers"; import { _t } from "../translation"; import { ConditionalFormat } from "../types/conditional_formatting"; import { Getters } from "../types/getters"; -import { UID } from "../types/misc"; +import { Dimension, UID } from "../types/misc"; import { PropsOf } from "../types/props_of"; import { SpreadsheetChildEnv } from "../types/spreadsheet_env"; import { Registry } from "./registry"; @@ -126,6 +128,26 @@ sidePanelRegistry.add("ColumnStats", { Body: ColumnStatsPanel, }); +sidePanelRegistry.add("HeaderResizePanel", { + title: (env: SpreadsheetChildEnv, props: PropsOf) => + props.dimension === "COL" ? RESIZE_COLUMNS_NAME(env) : RESIZE_ROWS_NAME(env), + Body: HeaderResizePanel, + computeState: (getters: Getters, props: { dimension: Dimension }) => { + const sheetId = getters.getActiveSheetId(); + const elements = + props.dimension === "COL" + ? [...getters.getActiveCols()].sort((a, b) => a - b) + : [...getters.getActiveRows()].sort((a, b) => a - b); + if (!elements.length) { + return { isOpen: false }; + } + return { + isOpen: true, + props: { ...props, sheetId, elements }, + }; + }, +}); + sidePanelRegistry.add("TableSidePanel", { title: _t("Edit table"), Body: TablePanel, diff --git a/tests/headers/header_resize_panel_component.test.ts b/tests/headers/header_resize_panel_component.test.ts new file mode 100644 index 0000000000..6883192197 --- /dev/null +++ b/tests/headers/header_resize_panel_component.test.ts @@ -0,0 +1,169 @@ +import { Model } from "../../src"; +import { HeaderResizePanel } from "../../src/components/side_panel/header_resize_panel/header_resize_panel"; +import { SidePanels } from "../../src/components/side_panel/side_panels/side_panels"; +import { MIN_COL_WIDTH } from "../../src/constants"; +import { UID } from "../../src/types/misc"; +import { PropsOf } from "../../src/types/props_of"; +import { SpreadsheetChildEnv } from "../../src/types/spreadsheet_env"; +import { + click, + selectCell, + selectColumn, + setInputValueAndTrigger, + simulateClick, +} from "../test_helpers"; +import { + mountComponentWithPortalTarget, + nextTick, + spyModelDispatch, +} from "../test_helpers/helpers"; + +const SELECTORS = { + panel: ".o-header-resize-panel", + applyButton: ".o-header-resize-panel .o-sidePanelButtons .o-button.primary", + sizeInput: ".o-header-resize-panel input[type='number']", + exactSizeRadio: ".o-header-resize-panel input[value='exactSize']", + fitToDataRadio: ".o-header-resize-panel input[value='fitToData']", +}; + +describe("header resize side panel component", () => { + let model: Model; + let fixture: HTMLElement; + let sheetId: UID; + let dispatch: jest.SpyInstance; + let onCloseSidePanel: jest.Mock; + + beforeEach(() => { + model = new Model(); + sheetId = model.getters.getActiveSheetId(); + onCloseSidePanel = jest.fn(); + }); + + async function mountHeaderResizePanel(props: Partial> = {}) { + ({ fixture } = await mountComponentWithPortalTarget(HeaderResizePanel, { + model, + props: { + sheetId, + dimension: "COL", + elements: [1], + onCloseSidePanel, + ...props, + }, + })); + dispatch = spyModelDispatch(model); + } + + test("Apply exact size resizes selected columns", async () => { + await mountHeaderResizePanel({ dimension: "COL", elements: [1, 2] }); + await setInputValueAndTrigger(SELECTORS.sizeInput, "147"); + await click(fixture, SELECTORS.applyButton); + expect(dispatch).toHaveBeenCalledWith("RESIZE_COLUMNS_ROWS", { + sheetId, + dimension: "COL", + elements: [1, 2], + size: 147, + }); + expect(onCloseSidePanel).toHaveBeenCalled(); + }); + + test("Apply exact size resizes selected rows", async () => { + await mountHeaderResizePanel({ dimension: "ROW", elements: [2, 3] }); + await setInputValueAndTrigger(SELECTORS.sizeInput, "42"); + await click(fixture, SELECTORS.applyButton); + expect(dispatch).toHaveBeenCalledWith("RESIZE_COLUMNS_ROWS", { + sheetId, + dimension: "ROW", + elements: [2, 3], + size: 42, + }); + expect(onCloseSidePanel).toHaveBeenCalled(); + }); + + test("Apply fit to data autoresizes selected columns", async () => { + await mountHeaderResizePanel({ dimension: "COL", elements: [1, 2] }); + await simulateClick(SELECTORS.fitToDataRadio); + await click(fixture, SELECTORS.applyButton); + expect(dispatch).toHaveBeenCalledWith("AUTORESIZE_COLUMNS", { + sheetId, + cols: [1, 2], + }); + expect(onCloseSidePanel).toHaveBeenCalled(); + }); + + test("Apply fit to data autoresizes selected rows", async () => { + await mountHeaderResizePanel({ dimension: "ROW", elements: [2, 3] }); + await simulateClick(SELECTORS.fitToDataRadio); + await click(fixture, SELECTORS.applyButton); + expect(dispatch).toHaveBeenCalledWith("AUTORESIZE_ROWS", { + sheetId, + rows: [2, 3], + }); + expect(onCloseSidePanel).toHaveBeenCalled(); + }); + + test.each([ + ["", "Enter a size in pixels."], + ["10.5", "Size must be an integer."], + [(MIN_COL_WIDTH - 1).toString(), `Size must be between ${MIN_COL_WIDTH} and 2000 pixels.`], + ["2001", `Size must be between ${MIN_COL_WIDTH} and 2000 pixels.`], + ])("Invalid exact size %s does not resize", async (value, message) => { + await mountHeaderResizePanel({ dimension: "COL", elements: [1] }); + await setInputValueAndTrigger(SELECTORS.sizeInput, value); + + expect(".o-validation-error").toHaveCount(1); + expect(".o-validation-error").toHaveText(message); + expect(SELECTORS.applyButton).toHaveClass("o-disabled"); + + await click(fixture, SELECTORS.applyButton); + expect(dispatch).not.toHaveBeenCalled(); + expect(onCloseSidePanel).not.toHaveBeenCalled(); + }); + + test("Focusing the size input selects exact size mode", async () => { + await mountHeaderResizePanel(); + await simulateClick(SELECTORS.fitToDataRadio); + expect(document.querySelector(SELECTORS.fitToDataRadio)!.checked).toBe(true); + await simulateClick(SELECTORS.sizeInput); + expect(document.querySelector(SELECTORS.exactSizeRadio)!.checked).toBe(true); + }); +}); + +describe("header resize side panel integration", () => { + let model: Model; + let env: SpreadsheetChildEnv; + + beforeEach(() => { + model = new Model(); + }); + + async function mountSidePanels() { + ({ env } = await mountComponentWithPortalTarget(SidePanels, { model })); + } + + test("Panel title and resize target follow the selected columns", async () => { + await mountSidePanels(); + selectColumn(model, 1, "overrideSelection"); + selectColumn(model, 2, "updateAnchor"); + + env.openSidePanel("HeaderResizePanel", { dimension: "COL" }); + await nextTick(); + expect(".o-sidePanelTitle").toHaveText("Resize columns B - C"); + + selectColumn(model, 3, "overrideSelection"); + await nextTick(); + expect(".o-sidePanelTitle").toHaveText("Resize column D"); + }); + + test("Panel closes when the selection has no headers for its dimension", async () => { + await mountSidePanels(); + selectColumn(model, 1, "overrideSelection"); + + env.openSidePanel("HeaderResizePanel", { dimension: "COL" }); + await nextTick(); + expect(".o-sidePanel").toHaveCount(1); + + selectCell(model, "A1"); + await nextTick(); + expect(".o-sidePanel").toHaveCount(0); + }); +}); diff --git a/tests/menus/menu_items_registry.test.ts b/tests/menus/menu_items_registry.test.ts index 753a5509df..21b227d002 100644 --- a/tests/menus/menu_items_registry.test.ts +++ b/tests/menus/menu_items_registry.test.ts @@ -14,6 +14,7 @@ import { groupRows, hideColumns, hideRows, + lockSheet, selectAll, selectCell, selectColumn, @@ -1564,6 +1565,41 @@ describe("Menu Item actions", () => { }); }); }); + describe("Resize Columns", () => { + const resizePath = ["resize_columns"]; + + test("Action on single column selection", async () => { + selectColumn(model, 1, "overrideSelection"); + expect(getName(resizePath, env, colMenuRegistry)).toBe("Resize column B"); + expect(getNode(resizePath, env, colMenuRegistry).isVisible(env)).toBeTruthy(); + const openSidePanel = jest.spyOn(env, "openSidePanel"); + await doAction(resizePath, env, colMenuRegistry); + expect(openSidePanel).toHaveBeenCalledWith("HeaderResizePanel", { dimension: "COL" }); + }); + + test("Action on consecutive column selection", () => { + selectColumn(model, 1, "overrideSelection"); + selectColumn(model, 2, "updateAnchor"); + expect(getName(resizePath, env, colMenuRegistry)).toBe("Resize columns B - C"); + }); + + test("Action on non-consecutive column selection", () => { + selectColumn(model, 1, "overrideSelection"); + selectColumn(model, 3, "newAnchor"); + expect(getName(resizePath, env, colMenuRegistry)).toBe("Resize columns"); + }); + + test("Action is hidden without selected columns", () => { + selectRow(model, 1, "overrideSelection"); + expect(getNode(resizePath, env, colMenuRegistry).isVisible(env)).toBeFalsy(); + }); + + test("Action is disabled on locked sheet", () => { + selectColumn(model, 1, "overrideSelection"); + lockSheet(model, sheetId); + expect(getNode(resizePath, env, colMenuRegistry).isEnabled(env)).toBeFalsy(); + }); + }); describe("Hide/Unhide Rows", () => { const hidePath = ["hide_rows"]; const unhidePath = ["unhide_rows"]; @@ -1885,6 +1921,41 @@ describe("Menu Item actions", () => { }); }); }); + describe("Resize Rows", () => { + const resizePath = ["resize_rows"]; + + test("Action on single row selection", async () => { + selectRow(model, 1, "overrideSelection"); + expect(getName(resizePath, env, rowMenuRegistry)).toBe("Resize row 2"); + expect(getNode(resizePath, env, rowMenuRegistry).isVisible(env)).toBeTruthy(); + const openSidePanel = jest.spyOn(env, "openSidePanel"); + await doAction(resizePath, env, rowMenuRegistry); + expect(openSidePanel).toHaveBeenCalledWith("HeaderResizePanel", { dimension: "ROW" }); + }); + + test("Action on consecutive row selection", () => { + selectRow(model, 1, "overrideSelection"); + selectRow(model, 2, "updateAnchor"); + expect(getName(resizePath, env, rowMenuRegistry)).toBe("Resize rows 2 - 3"); + }); + + test("Action on non-consecutive row selection", () => { + selectRow(model, 1, "overrideSelection"); + selectRow(model, 3, "newAnchor"); + expect(getName(resizePath, env, rowMenuRegistry)).toBe("Resize rows"); + }); + + test("Action is hidden without selected rows", () => { + selectColumn(model, 1, "overrideSelection"); + expect(getNode(resizePath, env, rowMenuRegistry).isVisible(env)).toBeFalsy(); + }); + + test("Action is disabled on locked sheet", () => { + selectRow(model, 1, "overrideSelection"); + lockSheet(model, sheetId); + expect(getNode(resizePath, env, rowMenuRegistry).isEnabled(env)).toBeFalsy(); + }); + }); test("View -> Set gridlines visibility", async () => { const path_gridlines = ["view", "show", "view_gridlines"];