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"];