From 1f63e08e4b73345e4d4439a5fe349521c95c3c0f Mon Sep 17 00:00:00 2001 From: "Marceline (matho)" Date: Thu, 27 Nov 2025 13:29:24 +0100 Subject: [PATCH 1/2] [FIX] tests: fix useless shortcuts tests old tests did not actually test the shortcuts functionality there was nothing asserted Task : 5231802 --- tests/grid/grid_component.test.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/grid/grid_component.test.ts b/tests/grid/grid_component.test.ts index a7b8db3659..5a09daba5a 100644 --- a/tests/grid/grid_component.test.ts +++ b/tests/grid/grid_component.test.ts @@ -1953,27 +1953,31 @@ describe("Copy paste keyboard shortcut", () => { setCellContent(model, "B2", "b2"); selectCell(model, "B2"); keyDown({ key: "D", ctrlKey: true }); - expect(model.dispatch("COPY_PASTE_CELLS_ABOVE")).toBeSuccessfullyDispatched(); + expect(getCell(model, "B2")?.content).toBe("b1"); setCellContent(model, "C1", "c1"); setCellContent(model, "D1", "d1"); setSelection(model, ["B2:D2"]); keyDown({ key: "D", ctrlKey: true }); - expect(model.dispatch("COPY_PASTE_CELLS_ABOVE")).toBeSuccessfullyDispatched(); + expect(getCell(model, "B2")?.content).toBe("b1"); + expect(getCell(model, "C2")?.content).toBe("c1"); + expect(getCell(model, "D2")?.content).toBe("d1"); }); test("can copy and paste cell(s) on left using CTRL+R", async () => { - setCellContent(model, "A1", "a1"); - setCellContent(model, "B1", "b1"); - selectCell(model, "B1"); + setCellContent(model, "A2", "a2"); + setCellContent(model, "B2", "b2"); + selectCell(model, "B2"); keyDown({ key: "R", ctrlKey: true }); - expect(model.dispatch("COPY_PASTE_CELLS_ON_LEFT")).toBeSuccessfullyDispatched(); + expect(getCell(model, "B2")?.content).toBe("a2"); - setCellContent(model, "A2", "a2"); setCellContent(model, "A3", "a3"); - setSelection(model, ["B1:B3"]); + setCellContent(model, "A4", "a4"); + setSelection(model, ["B2:B4"]); keyDown({ key: "R", ctrlKey: true }); - expect(model.dispatch("COPY_PASTE_CELLS_ON_LEFT")).toBeSuccessfullyDispatched(); + expect(getCell(model, "B2")?.content).toBe("a2"); + expect(getCell(model, "B3")?.content).toBe("a3"); + expect(getCell(model, "B4")?.content).toBe("a4"); }); test("Clipboard visible zones (copy) will be cleaned after hitting esc", async () => { From ba724767aafc4a5641dbbf82b56f72878d539866 Mon Sep 17 00:00:00 2001 From: "Marceline (matho)" Date: Wed, 26 Nov 2025 10:08:12 +0100 Subject: [PATCH 2/2] [IMP] Added some shortcuts shift+F11 (new sheet), ctrl+enter (fill range) and alt+t (new table) Task: 5231802 --- .../src/plugins/ui_stateful/clipboard.ts | 59 ++++++++++++++++++- .../src/types/commands.ts | 5 ++ src/actions/insert_actions.ts | 3 + src/components/grid/grid.ts | 24 +++++++- src/helpers/ui/paste_interactive.ts | 18 +++++- tests/clipboard/clipboard_plugin.test.ts | 28 +++++++++ tests/grid/grid_component.test.ts | 50 +++++++++++++++- tests/link/link_display_component.test.ts | 6 +- .../context_menu_component.test.ts.snap | 5 ++ tests/test_helpers/commands_helpers.ts | 7 +++ tests/test_helpers/helpers.ts | 2 +- 11 files changed, 197 insertions(+), 10 deletions(-) diff --git a/packages/o-spreadsheet-engine/src/plugins/ui_stateful/clipboard.ts b/packages/o-spreadsheet-engine/src/plugins/ui_stateful/clipboard.ts index a633b4ae7d..7dac665586 100644 --- a/packages/o-spreadsheet-engine/src/plugins/ui_stateful/clipboard.ts +++ b/packages/o-spreadsheet-engine/src/plugins/ui_stateful/clipboard.ts @@ -95,14 +95,53 @@ export class ClipboardPlugin extends UIPlugin { if (zones.length > 1 || (zones[0].top === 0 && zones[0].bottom === 0)) { return CommandResult.InvalidCopyPasteSelection; } - break; + const zone = this.getters.getSelectedZone(); + const multipleRowsInSelection = zone.top !== zone.bottom; + const copyTarget = { + ...zone, + bottom: multipleRowsInSelection ? zone.top : zone.top - 1, + top: multipleRowsInSelection ? zone.top : zone.top - 1, + }; + this.originSheetId = this.getters.getActiveSheetId(); + const copiedData = this.copy([copyTarget]); + return this.isPasteAllowed(zones, copiedData, { + isCutOperation: false, + }); } case "COPY_PASTE_CELLS_ON_LEFT": { const zones = this.getters.getSelectedZones(); if (zones.length > 1 || (zones[0].left === 0 && zones[0].right === 0)) { return CommandResult.InvalidCopyPasteSelection; } - break; + const zone = this.getters.getSelectedZone(); + const multipleColsInSelection = zone.left !== zone.right; + const copyTarget = { + ...zone, + right: multipleColsInSelection ? zone.left : zone.left - 1, + left: multipleColsInSelection ? zone.left : zone.left - 1, + }; + this.originSheetId = this.getters.getActiveSheetId(); + const copiedData = this.copy([copyTarget]); + return this.isPasteAllowed(zones, copiedData, { + isCutOperation: false, + }); + } + case "COPY_PASTE_CELLS_ON_ZONE": { + const zones = this.getters.getSelectedZones(); + if (zones.length > 1) { + return CommandResult.InvalidCopyPasteSelection; + } + const zone = this.getters.getSelectedZone(); + const copyTarget = { + ...zone, + right: zone.left, + bottom: zone.top, + }; + this.originSheetId = this.getters.getActiveSheetId(); + const copiedData = this.copy([copyTarget]); + return this.isPasteAllowed(zones, copiedData, { + isCutOperation: false, + }); } case "INSERT_CELL": { const { cut, paste } = this.getInsertCellsTargets(cmd.zone, cmd.shiftDimension); @@ -212,6 +251,22 @@ export class ClipboardPlugin extends UIPlugin { }); } break; + case "COPY_PASTE_CELLS_ON_ZONE": + { + const zone = this.getters.getSelectedZone(); + const copyTarget = { + ...zone, + right: zone.left, + bottom: zone.top, + }; + this.originSheetId = this.getters.getActiveSheetId(); + const copiedData = this.copy([copyTarget]); + this.paste([zone], copiedData, { + isCutOperation: false, + selectTarget: true, + }); + } + break; case "CLEAN_CLIPBOARD_HIGHLIGHT": this.status = "invisible"; break; diff --git a/packages/o-spreadsheet-engine/src/types/commands.ts b/packages/o-spreadsheet-engine/src/types/commands.ts index 820644c733..da962c9079 100644 --- a/packages/o-spreadsheet-engine/src/types/commands.ts +++ b/packages/o-spreadsheet-engine/src/types/commands.ts @@ -860,6 +860,10 @@ export interface CopyPasteCellsOnLeftCommand { type: "COPY_PASTE_CELLS_ON_LEFT"; } +export interface CopyPasteCellsOnZoneCommand { + type: "COPY_PASTE_CELLS_ON_ZONE"; +} + export interface RepeatPasteCommand { type: "REPEAT_PASTE"; target: Zone[]; @@ -1237,6 +1241,7 @@ export type LocalCommand = | PasteCommand | CopyPasteCellsAboveCommand | CopyPasteCellsOnLeftCommand + | CopyPasteCellsOnZoneCommand | RepeatPasteCommand | CleanClipBoardHighlightCommand | AutoFillCellCommand diff --git a/src/actions/insert_actions.ts b/src/actions/insert_actions.ts index a610b4d074..7cfff34816 100644 --- a/src/actions/insert_actions.ts +++ b/src/actions/insert_actions.ts @@ -204,6 +204,7 @@ export const insertImage: ActionSpec = { export const insertTable: ActionSpec = { name: () => _t("Table"), + description: "Alt+T", execute: ACTIONS.INSERT_TABLE, isVisible: (env) => ACTIONS.IS_SELECTION_CONTINUOUS(env) && !env.model.getters.getFirstTableInSelection(), @@ -275,6 +276,7 @@ export const categoriesFunctionListMenuBuilder: ActionBuilder = () => { export const insertLink: ActionSpec = { name: _t("Link"), + description: "Ctrl+K", execute: ACTIONS.INSERT_LINK, icon: "o-spreadsheet-Icon.INSERT_LINK", }; @@ -336,6 +338,7 @@ export const insertDropdown: ActionSpec = { export const insertSheet: ActionSpec = { name: _t("Insert sheet"), + description: "Shift+F11", execute: (env) => { const activeSheetId = env.model.getters.getActiveSheetId(); const position = env.model.getters.getSheetIds().indexOf(activeSheetId) + 1; diff --git a/src/components/grid/grid.ts b/src/components/grid/grid.ts index 764d103d5a..d4f18ead6e 100644 --- a/src/components/grid/grid.ts +++ b/src/components/grid/grid.ts @@ -17,6 +17,7 @@ import { useRef, useState, } from "@odoo/owl"; +import { insertSheet, insertTable } from "../../actions/insert_actions"; import { CREATE_IMAGE, INSERT_COLUMNS_BEFORE_ACTION, @@ -27,7 +28,11 @@ import { import { canUngroupHeaders } from "../../actions/view_actions"; import { isInside } from "../../helpers/index"; import { interactiveCut } from "../../helpers/ui/cut_interactive"; -import { interactivePaste, interactivePasteFromOS } from "../../helpers/ui/paste_interactive"; +import { + handleCopyPasteResult, + interactivePaste, + interactivePasteFromOS, +} from "../../helpers/ui/paste_interactive"; import { cellMenuRegistry } from "../../registries/menus/cell_menu_registry"; import { colMenuRegistry } from "../../registries/menus/col_menu_registry"; import { @@ -359,8 +364,15 @@ export class Grid extends Component { const position = this.env.model.getters.getActivePosition(); this.env.model.selection.selectZone({ cell: position, zone: newZone }); }, - "Ctrl+D": async () => this.env.model.dispatch("COPY_PASTE_CELLS_ABOVE"), - "Ctrl+R": async () => this.env.model.dispatch("COPY_PASTE_CELLS_ON_LEFT"), + "Ctrl+D": () => { + handleCopyPasteResult(this.env, { type: "COPY_PASTE_CELLS_ABOVE" }); + }, + "Ctrl+R": () => { + handleCopyPasteResult(this.env, { type: "COPY_PASTE_CELLS_ON_LEFT" }); + }, + "Ctrl+Enter": () => { + handleCopyPasteResult(this.env, { type: "COPY_PASTE_CELLS_ON_ZONE" }); + }, "Ctrl+H": () => this.sidePanel.open("FindAndReplace", {}), "Ctrl+F": () => this.sidePanel.open("FindAndReplace", {}), "Ctrl+Shift+E": () => this.setHorizontalAlign("center"), @@ -409,6 +421,12 @@ export class Grid extends Component { "Shift+PageUp": () => { this.env.model.dispatch("ACTIVATE_PREVIOUS_SHEET"); }, + "Shift+F11": () => { + insertSheet.execute?.(this.env); + }, + "Alt+T": () => { + insertTable.execute?.(this.env); + }, PageDown: () => this.env.model.dispatch("SHIFT_VIEWPORT_DOWN"), PageUp: () => this.env.model.dispatch("SHIFT_VIEWPORT_UP"), "Ctrl+K": () => INSERT_LINK(this.env), diff --git a/src/helpers/ui/paste_interactive.ts b/src/helpers/ui/paste_interactive.ts index 9707029d23..40c9d1871c 100644 --- a/src/helpers/ui/paste_interactive.ts +++ b/src/helpers/ui/paste_interactive.ts @@ -1,16 +1,32 @@ -import { RemoveDuplicateTerms } from "@odoo/o-spreadsheet-engine/components/translations_terms"; +import { + MergeErrorMessage, + RemoveDuplicateTerms, +} from "@odoo/o-spreadsheet-engine/components/translations_terms"; import { getCurrentVersion } from "@odoo/o-spreadsheet-engine/migrations/data"; import { _t } from "@odoo/o-spreadsheet-engine/translation"; import { SpreadsheetChildEnv } from "@odoo/o-spreadsheet-engine/types/spreadsheet_env"; import { ClipboardPasteOptions, CommandResult, + CopyPasteCellsAboveCommand, + CopyPasteCellsOnLeftCommand, + CopyPasteCellsOnZoneCommand, DispatchResult, ParsedOSClipboardContent, ParsedOsClipboardContentWithImageData, Zone, } from "../../types"; +export const handleCopyPasteResult = ( + env: SpreadsheetChildEnv, + command: CopyPasteCellsAboveCommand | CopyPasteCellsOnLeftCommand | CopyPasteCellsOnZoneCommand +) => { + const result = env.model.dispatch(command.type); + if (result.isCancelledBecause(CommandResult.WillRemoveExistingMerge)) { + env.raiseError(MergeErrorMessage); + } +}; + export const PasteInteractiveContent = { wrongPasteSelection: _t("This operation is not allowed with multiple selections."), willRemoveExistingMerge: RemoveDuplicateTerms.Errors.WillRemoveExistingMerge, diff --git a/tests/clipboard/clipboard_plugin.test.ts b/tests/clipboard/clipboard_plugin.test.ts index 9351a90de8..4ffddc2886 100644 --- a/tests/clipboard/clipboard_plugin.test.ts +++ b/tests/clipboard/clipboard_plugin.test.ts @@ -34,6 +34,7 @@ import { copy, copyPasteAboveCells, copyPasteCellsOnLeft, + copyPasteCellsOnZone, createDynamicTable, createImage, createSheet, @@ -2412,6 +2413,33 @@ describe("clipboard: pasting outside of sheet", () => { expect(getCellContent(model, "D2")).toBe("d1"); }); + test("do not fill down if filling down would unmerge cells", () => { + const model = new Model(); + setCellContent(model, "A1", "a1"); + merge(model, "A2:A3"); + setSelection(model, ["A1:A3"]); + const result = copyPasteAboveCells(model); + expect(result).toBeCancelledBecause(CommandResult.WillRemoveExistingMerge); + }); + + test("do not fill right if filling right would unmerge cells", () => { + const model = new Model(); + setCellContent(model, "A1", "a1"); + merge(model, "B1:C1"); + setSelection(model, ["A1:C1"]); + const result = copyPasteCellsOnLeft(model); + expect(result).toBeCancelledBecause(CommandResult.WillRemoveExistingMerge); + }); + + test("do not fill if filling would unmerge cells", () => { + const model = new Model(); + setCellContent(model, "A1", "a1"); + merge(model, "A2:A3"); + setSelection(model, ["A1:A3"]); + const result = copyPasteCellsOnZone(model); + expect(result).toBeCancelledBecause(CommandResult.WillRemoveExistingMerge); + }); + test("fill right selection with single column -> for each cell, replicates the cell on its left", async () => { const model = new Model(); setCellContent(model, "B1", "b1"); diff --git a/tests/grid/grid_component.test.ts b/tests/grid/grid_component.test.ts index 5a09daba5a..d9935db872 100644 --- a/tests/grid/grid_component.test.ts +++ b/tests/grid/grid_component.test.ts @@ -24,9 +24,11 @@ import { resetTimeoutDuration } from "../../src/components/helpers/touch_scroll_ import { PaintFormatStore } from "../../src/components/paint_format_button/paint_format_store"; import { CellPopoverStore } from "../../src/components/popover"; import { buildSheetLink, toCartesian, toZone, zoneToXc } from "../../src/helpers"; +import { handleCopyPasteResult } from "../../src/helpers/ui/paste_interactive"; import { Store } from "../../src/store_engine"; import { ClientFocusStore } from "../../src/stores/client_focus_store"; import { HighlightStore } from "../../src/stores/highlight_store"; +import { NotificationStore } from "../../src/stores/notification_store"; import { Align, ClipboardMIMEType } from "../../src/types"; import { FileStore } from "../__mocks__/mock_file_store"; import { MockTransportService } from "../__mocks__/transport_service"; @@ -127,6 +129,14 @@ let composerFocusStore: Store; jest.useFakeTimers(); mockChart(); +jest.mock("../../src/actions/menu_items_actions.ts", () => { + const originalModule = jest.requireActual("../../src/actions/menu_items_actions.ts"); + return { + __esModule: true, + ...originalModule, + INSERT_TABLE: jest.fn(originalModule.INSERT_TABLE), + }; +}); describe("Grid component", () => { beforeEach(async () => { @@ -942,6 +952,14 @@ describe("Grid component", () => { expect(model.getters.getActiveSheetId()).toBe("third"); }); + test("Pressing Shift+F11 insert a new sheet", () => { + expect(model.getters.getSheetIds()).toHaveLength(1); + keyDown({ key: "F11", shiftKey: true }); + const sheetIds = model.getters.getSheetIds(); + expect(sheetIds).toHaveLength(2); + expect(model.getters.getActiveSheetId()).toBe(sheetIds[1]); + }); + test("pressing Ctrl+K opens the link editor", async () => { await keyDown({ key: "k", ctrlKey: true }); expect(fixture.querySelector(".o-link-editor")).not.toBeNull(); @@ -1791,7 +1809,7 @@ describe("Copy paste keyboard shortcut", () => { const fileStore = new FileStore(); beforeEach(async () => { clipboardData = new MockClipboardData(); - ({ parent, model, fixture } = await mountSpreadsheet({ + ({ parent, model, fixture, env } = await mountSpreadsheet({ model: new Model({}, { external: { fileStore } }), })); sheetId = model.getters.getActiveSheetId(); @@ -1964,6 +1982,16 @@ describe("Copy paste keyboard shortcut", () => { expect(getCell(model, "D2")?.content).toBe("d1"); }); + test("banane", () => { + setCellContent(model, "A1", "a1"); + merge(model, "A2:A3"); + setSelection(model, ["A1:A3"]); + handleCopyPasteResult(env, { type: "COPY_PASTE_CELLS_ON_ZONE" }); + // @ts-ignore + const notificationStore = env.__spreadsheet_stores__.get(NotificationStore); + expect(notificationStore.raiseError).toHaveBeenCalled(); + }); + test("can copy and paste cell(s) on left using CTRL+R", async () => { setCellContent(model, "A2", "a2"); setCellContent(model, "B2", "b2"); @@ -1980,6 +2008,26 @@ describe("Copy paste keyboard shortcut", () => { expect(getCell(model, "B4")?.content).toBe("a4"); }); + test("can copy and paste cell(s) on zone using CTRL+ENTER", async () => { + setCellContent(model, "A1", "a1"); + setSelection(model, ["A1:B2"]); + keyDown({ key: "Enter", ctrlKey: true }); + expect(getCell(model, "A1")?.content).toBe("a1"); + expect(getCell(model, "A2")?.content).toBe("a1"); + expect(getCell(model, "B1")?.content).toBe("a1"); + expect(getCell(model, "B2")?.content).toBe("a1"); + }); + + test("Alt+T -> Table", async () => { + setSelection(model, ["A1:A5"]); + await keyDown({ key: "T", altKey: true }); + expect(model.getters.getTable({ sheetId, row: 0, col: 0 })).toMatchObject({ + range: { zone: toZone("A1:A5") }, + }); + const { INSERT_TABLE } = require("../../src/actions/menu_items_actions"); + expect(INSERT_TABLE as jest.Mock).toHaveBeenCalled(); + }); + test("Clipboard visible zones (copy) will be cleaned after hitting esc", async () => { setCellContent(model, "A1", "things"); selectCell(model, "A1"); diff --git a/tests/link/link_display_component.test.ts b/tests/link/link_display_component.test.ts index 3184df862f..3341f18525 100644 --- a/tests/link/link_display_component.test.ts +++ b/tests/link/link_display_component.test.ts @@ -97,13 +97,15 @@ describe("link display component", () => { setCellContent(model, "A1", "HELLO"); await rightClickCell(model, "A1"); expect( - fixture.querySelector(".o-menu .o-menu-item[data-name='insert_link']")?.textContent + fixture.querySelector(".o-menu .o-menu-item[data-name='insert_link'] .o-menu-item-name") + ?.textContent ).toBe("Insert link"); setCellContent(model, "A1", "[label](url.com)"); await rightClickCell(model, "A1"); expect( - fixture.querySelector(".o-menu .o-menu-item[data-name='insert_link']")?.textContent + fixture.querySelector(".o-menu .o-menu-item[data-name='insert_link'] .o-menu-item-name") + ?.textContent ).toBe("Edit link"); }); diff --git a/tests/menus/__snapshots__/context_menu_component.test.ts.snap b/tests/menus/__snapshots__/context_menu_component.test.ts.snap index f3c253195c..72bcc14e42 100644 --- a/tests/menus/__snapshots__/context_menu_component.test.ts.snap +++ b/tests/menus/__snapshots__/context_menu_component.test.ts.snap @@ -435,6 +435,11 @@ exports[`Context MenuPopover integration tests context menu simple rendering 1`] > Insert link +
+ Ctrl+K +
diff --git a/tests/test_helpers/commands_helpers.ts b/tests/test_helpers/commands_helpers.ts index ed78fb5826..d5de8e0f56 100644 --- a/tests/test_helpers/commands_helpers.ts +++ b/tests/test_helpers/commands_helpers.ts @@ -544,6 +544,13 @@ export function copyPasteCellsOnLeft(model: Model): DispatchResult { return model.dispatch("COPY_PASTE_CELLS_ON_LEFT"); } +/** + * Copy cell and paste on zone + */ +export function copyPasteCellsOnZone(model: Model): DispatchResult { + return model.dispatch("COPY_PASTE_CELLS_ON_ZONE"); +} + /** * Clean clipboard highlight selection. */ diff --git a/tests/test_helpers/helpers.ts b/tests/test_helpers/helpers.ts index 1378e6c379..baba681675 100644 --- a/tests/test_helpers/helpers.ts +++ b/tests/test_helpers/helpers.ts @@ -202,7 +202,7 @@ export function makeTestEnv( const notificationStore = container.get(NotificationStore); notificationStore.updateNotificationCallbacks({ notifyUser: mockEnv.notifyUser || (() => {}), - raiseError: mockEnv.raiseError || (() => {}), + raiseError: mockEnv.raiseError || (jest.fn() as unknown as (message: string) => void), askConfirmation: mockEnv.askConfirmation || (() => {}), });