diff --git a/demo/data.js b/demo/data.js index 8d58e1d5d4..f6a1ca6bd7 100644 --- a/demo/data.js +++ b/demo/data.js @@ -48,8 +48,8 @@ export const demoData = { C5: "42", C7: "3", C9: "= SUM( C4:C5 )", - C10: "=SUM(C4:C7)", - C11: "=-(3 + C7 *SUM(C4:C7))", + C10: "=SUM(MyNamedRange)", + C11: "=-(3 + C7 *SUM(MyNamedRange))", C12: "=SUM(C9:C11)", C14: "=C14", C15: "=(+", @@ -3832,6 +3832,13 @@ export const demoData = { }, pivotNextId: 2, customTableStyles: {}, + namedRanges: [ + { + rangeName: "MyNamedRange", + zone: { left: 2, right: 2, top: 3, bottom: 6 }, + sheetId: "sh1", + }, + ], }; // Performance dataset diff --git a/packages/o-spreadsheet-engine/src/collaborative/ot/ot_specific.ts b/packages/o-spreadsheet-engine/src/collaborative/ot/ot_specific.ts index 36a7778f72..532725e654 100644 --- a/packages/o-spreadsheet-engine/src/collaborative/ot/ot_specific.ts +++ b/packages/o-spreadsheet-engine/src/collaborative/ot/ot_specific.ts @@ -15,6 +15,7 @@ import { CreateTableCommand, DeleteChartCommand, DeleteFigureCommand, + DeleteNamedRangeCommand, DeleteSheetCommand, DuplicatePivotCommand, FoldHeaderGroupCommand, @@ -33,6 +34,7 @@ import { UpdateCarouselCommand, UpdateChartCommand, UpdateFigureCommand, + UpdateNamedRangeCommand, UpdatePivotCommand, UpdateTableCommand, } from "../../types/commands"; @@ -107,6 +109,12 @@ otRegistry.addTransformation( pivotZoneTransformation ); +otRegistry.addTransformation( + "UPDATE_NAMED_RANGE", + ["UPDATE_NAMED_RANGE", "DELETE_NAMED_RANGE"], + updateNamedRangeTransformation +); + function pivotZoneTransformation( toTransform: AddPivotCommand | UpdatePivotCommand, executed: AddColumnsRowsCommand | RemoveColumnsRowsCommand @@ -348,3 +356,25 @@ function groupHeadersTransformation( return { ...toTransform, start: Math.min(...results), end: Math.max(...results) }; } + +function updateNamedRangeTransformation( + toTransform: UpdateNamedRangeCommand | DeleteNamedRangeCommand, + executed: UpdateNamedRangeCommand +): UpdateNamedRangeCommand | DeleteNamedRangeCommand | undefined { + if (executed.newRangeName === executed.oldRangeName) { + return toTransform; + } + if ( + toTransform.type === "DELETE_NAMED_RANGE" && + toTransform.rangeName === executed.oldRangeName + ) { + return { ...toTransform, rangeName: executed.newRangeName }; + } + if ( + toTransform.type === "UPDATE_NAMED_RANGE" && + toTransform.oldRangeName === executed.oldRangeName + ) { + return { ...toTransform, oldRangeName: executed.newRangeName }; + } + return executed; +} diff --git a/packages/o-spreadsheet-engine/src/formulas/compiler.ts b/packages/o-spreadsheet-engine/src/formulas/compiler.ts index 2db369ee93..e250e0a2cb 100644 --- a/packages/o-spreadsheet-engine/src/formulas/compiler.ts +++ b/packages/o-spreadsheet-engine/src/formulas/compiler.ts @@ -224,7 +224,9 @@ function compileTokensOrThrow(tokens: Token[]): CompiledFormula { } case "SYMBOL": const symbolIndex = symbols.indexOf(ast.value); - return code.return(`getSymbolValue(this.symbols[${symbolIndex}])`); + return code.return( + `getSymbolValue(this.symbols[${symbolIndex}], ${hasRange}, ${isMeta}, ref, range, ctx)` + ); case "EMPTY": return code.return("undefined"); } diff --git a/packages/o-spreadsheet-engine/src/formulas/parser.ts b/packages/o-spreadsheet-engine/src/formulas/parser.ts index b51723676e..4234833e0c 100644 --- a/packages/o-spreadsheet-engine/src/formulas/parser.ts +++ b/packages/o-spreadsheet-engine/src/formulas/parser.ts @@ -188,11 +188,7 @@ function parseOperand(tokens: TokenList): AST { case "SYMBOL": const value = current.value; const nextToken = tokens.current; - if ( - nextToken?.type === "LEFT_PAREN" && - functionRegex.test(current.value) && - value === unquote(value, "'") - ) { + if (isFuncallToken(current, nextToken)) { const { args, rightParen } = parseFunctionArgs(tokens); return { type: "FUNCALL", @@ -459,3 +455,11 @@ export function mapAst( return ast; } } + +export function isFuncallToken(currentToken: Token, nextToken: Token | undefined) { + return ( + nextToken?.type === "LEFT_PAREN" && + functionRegex.test(currentToken.value) && + currentToken.value === unquote(currentToken.value, "'") + ); +} diff --git a/packages/o-spreadsheet-engine/src/helpers/range.ts b/packages/o-spreadsheet-engine/src/helpers/range.ts index 1b73842e5c..d08fde6ac4 100644 --- a/packages/o-spreadsheet-engine/src/helpers/range.ts +++ b/packages/o-spreadsheet-engine/src/helpers/range.ts @@ -120,7 +120,7 @@ export function isFullRowRange(range: Range): boolean { export function getRangeString( range: Range, - forSheetId: UID, + forSheetId: UID | undefined, getSheetName: (sheetId: UID) => string, options: RangeStringOptions = { useBoundedReference: false, useFixedReference: false } ): string { @@ -133,7 +133,8 @@ export function getRangeString( if (range.zone.left < 0 || range.zone.top < 0) { return CellErrorType.InvalidReference; } - const prefixSheet = range.sheetId !== forSheetId || range.invalidSheetName || range.prefixSheet; + const prefixSheet = + !forSheetId || range.sheetId !== forSheetId || range.invalidSheetName || range.prefixSheet; let sheetName: string = ""; if (prefixSheet) { if (range.invalidSheetName) { diff --git a/packages/o-spreadsheet-engine/src/migrations/data.ts b/packages/o-spreadsheet-engine/src/migrations/data.ts index 914f68d403..c6177acf7a 100644 --- a/packages/o-spreadsheet-engine/src/migrations/data.ts +++ b/packages/o-spreadsheet-engine/src/migrations/data.ts @@ -445,6 +445,7 @@ export function createEmptyWorkbookData(sheetName = "Sheet1"): WorkbookData { pivots: {}, pivotNextId: 1, customTableStyles: {}, + namedRanges: [], }; } diff --git a/packages/o-spreadsheet-engine/src/plugins/core/named_range.ts b/packages/o-spreadsheet-engine/src/plugins/core/named_range.ts new file mode 100644 index 0000000000..c93179cadd --- /dev/null +++ b/packages/o-spreadsheet-engine/src/plugins/core/named_range.ts @@ -0,0 +1,210 @@ +import { isFuncallToken } from "../../formulas/parser"; +import { Token } from "../../formulas/tokenizer"; +import { deepEquals, isNumber, rangeReference } from "../../helpers"; +import { Command, CommandResult, CoreCommand } from "../../types/commands"; +import { DEFAULT_LOCALE } from "../../types/locale"; +import { + AdaptSheetName, + ApplyRangeChange, + NamedRange, + UID, + UnboundedZone, + Zone, +} from "../../types/misc"; +import { WorkbookData } from "../../types/workbook_data"; +import { CorePlugin } from "../core_plugin"; + +interface NamedRangeState { + readonly namedRanges: Array; +} + +const invalidNamedRangeCharacterRegex = /[^a-zA-Z0-9_.]/; + +export class NamedRangesPlugin extends CorePlugin implements NamedRangeState { + static getters = ["getNamedRange", "getNamedRangeFromZone", "getNamedRanges"] as const; + + readonly namedRanges: Array = []; + + adaptRanges(applyChange: ApplyRangeChange, sheetId: UID, adaptSheetName: AdaptSheetName) { + const newNamedRanges: Array = []; + let hasChanges = false; + for (const namedRange of this.namedRanges) { + const change = applyChange(namedRange.range); + switch (change.changeType) { + case "REMOVE": + hasChanges = true; + break; + case "RESIZE": + case "MOVE": + case "CHANGE": + hasChanges = true; + newNamedRanges.push({ ...namedRange, range: change.range }); + break; + case "NONE": + newNamedRanges.push(namedRange); + } + } + if (hasChanges) { + this.history.update("namedRanges", newNamedRanges); + } + } + + allowDispatch(cmd: Command) { + switch (cmd.type) { + case "CREATE_NAMED_RANGE": + return this.checkValidNewNamedRangeName(cmd.rangeName); + case "UPDATE_NAMED_RANGE": + return this.checkValidations( + cmd, + () => this.checkNamedRangeExists(cmd.oldRangeName), + () => + cmd.newRangeName !== cmd.oldRangeName + ? this.checkValidNewNamedRangeName(cmd.newRangeName) + : CommandResult.Success + ); + case "DELETE_NAMED_RANGE": + return this.checkNamedRangeExists(cmd.rangeName); + } + return CommandResult.Success; + } + + handle(cmd: CoreCommand) { + switch (cmd.type) { + case "CREATE_NAMED_RANGE": { + const range = this.getters.getRangeFromRangeData(cmd.ranges[0]); + const newNamedRanges = [...this.namedRanges, { rangeName: cmd.rangeName, range }]; + this.history.update("namedRanges", newNamedRanges); + break; + } + case "UPDATE_NAMED_RANGE": { + const index = this.getNamedRangeIndex(cmd.oldRangeName); + if (index !== -1) { + const range = this.getters.getRangeFromRangeData(cmd.ranges[0]); + const newNamedRanges = [...this.namedRanges]; + newNamedRanges[index] = { rangeName: cmd.newRangeName, range }; + this.history.update("namedRanges", newNamedRanges); + if (cmd.oldRangeName !== cmd.newRangeName) { + this.renameNamedRangeInFormulas(cmd.oldRangeName, cmd.newRangeName); + } + } + break; + } + case "DELETE_NAMED_RANGE": { + const index = this.getNamedRangeIndex(cmd.rangeName); + if (index !== -1) { + const newNamedRanges = [...this.namedRanges]; + newNamedRanges.splice(index, 1); + this.history.update("namedRanges", newNamedRanges); + } + break; + } + } + } + + getNamedRange(rangeName: UID): NamedRange | undefined { + return this.namedRanges[this.getNamedRangeIndex(rangeName)]; + } + + getNamedRangeFromZone(sheetId: UID, zone: Zone | UnboundedZone): NamedRange | undefined { + for (const namedRange of this.namedRanges) { + const range = namedRange.range; + if (range.sheetId === sheetId && deepEquals(range.unboundedZone, zone)) { + return namedRange; + } + } + return undefined; + } + + getNamedRanges(): NamedRange[] { + return this.namedRanges; + } + + private getNamedRangeIndex(rangeName: UID): number { + return this.namedRanges.findIndex( + (r) => r && r.rangeName.toLowerCase() === rangeName.toLowerCase() + ); + } + + private renameNamedRangeInFormulas(oldName: string, newName: string) { + const lowerCaseOldName = oldName.toLowerCase(); + const isOldNamedRangeToken = (token: Token, nextToken: Token | undefined) => { + return ( + token.type === "SYMBOL" && + token.value.toLowerCase() === lowerCaseOldName && + !isFuncallToken(token, nextToken) + ); + }; + + for (const sheetId of this.getters.getSheetIds()) { + const cells = this.getters.getCells(sheetId); + for (const cellId in cells) { + const cell = cells[cellId]; + if (!cell.isFormula) { + continue; + } + + const tokens = cell.compiledFormula.tokens; + let newContent = ""; + let hasChanged = false; + for (let i = 0; i < tokens.length; i++) { + if (isOldNamedRangeToken(tokens[i], tokens[i + 1])) { + hasChanged = true; + newContent += newName; + } else { + newContent += tokens[i].value; + } + } + + if (hasChanged) { + this.dispatch("UPDATE_CELL", { + ...this.getters.getCellPosition(cellId), + content: newContent, + }); + } + } + } + } + + import(data: WorkbookData) { + for (const namedRangeData of data.namedRanges || []) { + this.namedRanges.push({ + rangeName: namedRangeData.rangeName, + range: this.getters.getRangeFromZone(namedRangeData.sheetId, namedRangeData.zone), + }); + } + } + + export(data: WorkbookData) { + data.namedRanges = []; + for (const namedRange of this.namedRanges) { + data.namedRanges.push({ + rangeName: namedRange.rangeName, + zone: namedRange.range.unboundedZone, + sheetId: namedRange.range.sheetId, + }); + } + } + + private checkValidNewNamedRangeName(name: string): CommandResult { + if (this.getNamedRange(name)) { + return CommandResult.NamedRangeNameAlreadyExists; + } + + if (invalidNamedRangeCharacterRegex.test(name) || isNumber(name, DEFAULT_LOCALE)) { + return CommandResult.NamedRangeNameWithInvalidCharacter; + } + + if (rangeReference.test(name)) { + return CommandResult.NamedRangeNameLooksLikeCellReference; + } + + return CommandResult.Success; + } + + private checkNamedRangeExists(name: string): CommandResult { + if (!this.getNamedRange(name)) { + return CommandResult.NamedRangeNotFound; + } + return CommandResult.Success; + } +} diff --git a/packages/o-spreadsheet-engine/src/plugins/core/range.ts b/packages/o-spreadsheet-engine/src/plugins/core/range.ts index 8684f3c989..edeb99db1b 100644 --- a/packages/o-spreadsheet-engine/src/plugins/core/range.ts +++ b/packages/o-spreadsheet-engine/src/plugins/core/range.ts @@ -206,7 +206,7 @@ export class RangeAdapter implements CommandHandler { /** * Gets the string that represents the range as it is at the moment of the call. * The string will be prefixed with the sheet name if the call specified a sheet id in `forSheetId` - * different than the sheet on which the range has been created. + * different than the sheet on which the range has been created or if `forSheetId` is not specified. * * @param range the range (received from getRangeFromXC or getRangeFromZone) * @param forSheetId the id of the sheet where the range string is supposed to be used. @@ -216,7 +216,7 @@ export class RangeAdapter implements CommandHandler { */ getRangeString( range: Range, - forSheetId: UID, + forSheetId?: UID, options: RangeStringOptions = { useBoundedReference: false, useFixedReference: false } ): string { if (!range) { diff --git a/packages/o-spreadsheet-engine/src/plugins/index.ts b/packages/o-spreadsheet-engine/src/plugins/index.ts index 4f94866f6e..a2d27e8d76 100644 --- a/packages/o-spreadsheet-engine/src/plugins/index.ts +++ b/packages/o-spreadsheet-engine/src/plugins/index.ts @@ -11,6 +11,7 @@ import { HeaderSizePlugin } from "./core/header_size"; import { HeaderVisibilityPlugin } from "./core/header_visibility"; import { ImagePlugin } from "./core/image"; import { MergePlugin } from "./core/merge"; +import { NamedRangesPlugin } from "./core/named_range"; import { PivotCorePlugin } from "./core/pivot"; import { SettingsPlugin } from "./core/settings"; import { SheetPlugin } from "./core/sheet"; @@ -77,7 +78,8 @@ export const corePluginRegistry = new Registry() .add("image", ImagePlugin) .add("pivot_core", PivotCorePlugin) .add("spreadsheet_pivot_core", SpreadsheetPivotCorePlugin) - .add("tableStyle", TableStylePlugin); + .add("tableStyle", TableStylePlugin) + .add("named_ranges", NamedRangesPlugin); // Plugins which handle a specific feature, without handling any core commands export const featurePluginRegistry = new Registry() diff --git a/packages/o-spreadsheet-engine/src/plugins/ui_core_views/cell_evaluation/evaluator.ts b/packages/o-spreadsheet-engine/src/plugins/ui_core_views/cell_evaluation/evaluator.ts index 520822bf9f..f3027edf39 100644 --- a/packages/o-spreadsheet-engine/src/plugins/ui_core_views/cell_evaluation/evaluator.ts +++ b/packages/o-spreadsheet-engine/src/plugins/ui_core_views/cell_evaluation/evaluator.ts @@ -24,17 +24,20 @@ import { matrixMap } from "../../../functions/helpers"; import { PositionMap } from "../../../helpers/cells/position_map"; import { toXC } from "../../../helpers/coordinates"; import { lazy } from "../../../helpers/misc"; -import { excludeTopLeft, positionToZone, union } from "../../../helpers/zones"; +import { excludeTopLeft, getZoneArea, positionToZone, union } from "../../../helpers/zones"; import { onIterationEndEvaluationRegistry } from "../../../registries/evaluation_registry"; import { _t } from "../../../translation"; +import { EvalContext } from "../../../types/functions"; import { Getters } from "../../../types/getters"; import { CellPosition, + EnsureRange, FunctionResultObject, GetSymbolValue, isMatrix, Matrix, RangeCompiledFormula, + ReferenceDenormalizer, UID, Zone, } from "../../../types/misc"; @@ -115,6 +118,10 @@ export class Evaluator { private addDependencies(position: CellPosition, dependencies: Range[]) { this.formulaDependencies().addDependencies(position, dependencies); + this.computeDependencies(dependencies); + } + + private computeDependencies(dependencies: Range[]) { for (const range of dependencies) { const sheetId = range.sheetId; const { left, bottom, right, top } = range.zone; @@ -557,13 +564,38 @@ export class Evaluator { * and error handling. */ private buildSafeGetSymbolValue(getContextualSymbolValue?: GetSymbolValue): GetSymbolValue { - const getSymbolValue = (symbolName: string) => { + const getSymbolValue = ( + symbolName: string, + isRange: boolean, + isMeta: boolean, + refFn: ReferenceDenormalizer, + range: EnsureRange, + ctx: EvalContext + ) => { if (this.symbolsBeingComputed.has(symbolName)) { return ERROR_CYCLE_CELL; } this.symbolsBeingComputed.add(symbolName); try { - const symbolValue = getContextualSymbolValue?.(symbolName); + const namedRange = this.getters.getNamedRange(symbolName); + if (namedRange) { + ctx.__originCellPosition + ? this.addDependencies(ctx.__originCellPosition, [namedRange.range]) + : this.computeDependencies([namedRange.range]); + + const isMultiCellZone = getZoneArea(namedRange.range.zone) > 1; + return isMultiCellZone || isRange + ? range(namedRange.range, isMeta) + : refFn(namedRange.range, isMeta); + } + const symbolValue = getContextualSymbolValue?.( + symbolName, + isRange, + isMeta, + refFn, + range, + ctx + ); if (symbolValue) { return symbolValue; } diff --git a/packages/o-spreadsheet-engine/src/types/commands.ts b/packages/o-spreadsheet-engine/src/types/commands.ts index 59eaec01d1..a831f17493 100644 --- a/packages/o-spreadsheet-engine/src/types/commands.ts +++ b/packages/o-spreadsheet-engine/src/types/commands.ts @@ -143,6 +143,9 @@ export const invalidateEvaluationCommands = new Set([ "RENAME_PIVOT", "REMOVE_PIVOT", "DUPLICATE_PIVOT", + "CREATE_NAMED_RANGE", + "UPDATE_NAMED_RANGE", + "DELETE_NAMED_RANGE", ]); export const invalidateChartEvaluationCommands = new Set([ @@ -308,6 +311,9 @@ export const coreTypes = new Set([ /** MISC */ "UPDATE_LOCALE", + "CREATE_NAMED_RANGE", + "UPDATE_NAMED_RANGE", + "DELETE_NAMED_RANGE", /** PIVOT */ "ADD_PIVOT", @@ -840,6 +846,22 @@ export interface RemoveDataValidationCommand extends SheetDependentCommand { id: string; } +export interface CreateNamedRangeCommand extends RangesDependentCommand { + type: "CREATE_NAMED_RANGE"; + rangeName: string; +} + +export interface UpdateNamedRangeCommand extends RangesDependentCommand { + type: "UPDATE_NAMED_RANGE"; + oldRangeName: string; + newRangeName: string; +} + +export interface DeleteNamedRangeCommand { + type: "DELETE_NAMED_RANGE"; + rangeName: string; +} + //#endregion //#region Local Commands @@ -1224,6 +1246,9 @@ export type CoreCommand = /** MISC */ | UpdateLocaleCommand + | CreateNamedRangeCommand + | UpdateNamedRangeCommand + | DeleteNamedRangeCommand /** PIVOT */ | AddPivotCommand @@ -1469,6 +1494,10 @@ export const enum CommandResult { InvalidPivotCustomField = "InvalidPivotCustomField", MissingFigureArguments = "MissingFigureArguments", InvalidCarouselItem = "InvalidCarouselItem", + NamedRangeNameAlreadyExists = "NamedRangeNameAlreadyExists", + NamedRangeNameWithInvalidCharacter = "NamedRangeNameWithInvalidCharacter", + NamedRangeNameLooksLikeCellReference = "NamedRangeNameLooksLikeCellReference", + NamedRangeNotFound = "NamedRangeNotFound", } export interface CommandHandler { diff --git a/packages/o-spreadsheet-engine/src/types/core_getters.ts b/packages/o-spreadsheet-engine/src/types/core_getters.ts index 695b1dbf4d..09d8808204 100644 --- a/packages/o-spreadsheet-engine/src/types/core_getters.ts +++ b/packages/o-spreadsheet-engine/src/types/core_getters.ts @@ -10,6 +10,7 @@ import { HeaderSizePlugin } from "../plugins/core/header_size"; import { HeaderVisibilityPlugin } from "../plugins/core/header_visibility"; import { ImagePlugin } from "../plugins/core/image"; import { MergePlugin } from "../plugins/core/merge"; +import { NamedRangesPlugin } from "../plugins/core/named_range"; import { PivotCorePlugin } from "../plugins/core/pivot"; import { RangeAdapter } from "../plugins/core/range"; import { SettingsPlugin } from "../plugins/core/settings"; @@ -85,4 +86,5 @@ export type CoreGetters = PluginGetters & PluginGetters & PluginGetters & PluginGetters & + PluginGetters & PluginGetters; diff --git a/packages/o-spreadsheet-engine/src/types/misc.ts b/packages/o-spreadsheet-engine/src/types/misc.ts index 6923f4dfb4..e3d2350f65 100644 --- a/packages/o-spreadsheet-engine/src/types/misc.ts +++ b/packages/o-spreadsheet-engine/src/types/misc.ts @@ -6,6 +6,7 @@ import { CellValue, EvaluatedCell } from "./cells"; import { Token } from "../formulas/tokenizer"; import { CommandResult } from "./commands"; import { Format } from "./format"; +import { EvalContext } from "./functions"; import { Range } from "./range"; /** @@ -172,16 +173,18 @@ export interface Border { right?: BorderDescr; } -export type ReferenceDenormalizer = ( - range: Range, - isMeta: boolean, - functionName: string, - paramNumber: number -) => FunctionResultObject; +export type ReferenceDenormalizer = (range: Range, isMeta: boolean) => FunctionResultObject; export type EnsureRange = (range: Range, isMeta: boolean) => Matrix; -export type GetSymbolValue = (symbolName: string) => Arg; +export type GetSymbolValue = ( + symbolName: string, + isRange: boolean, + isMeta: boolean, + refFn: ReferenceDenormalizer, + range: EnsureRange, + ctx: EvalContext +) => Arg; export type FormulaToExecute = ( deps: Range[], @@ -419,3 +422,8 @@ export interface ValueAndLabel { value: T; label: string; } + +export interface NamedRange { + rangeName: string; + range: Range; +} diff --git a/packages/o-spreadsheet-engine/src/types/workbook_data.ts b/packages/o-spreadsheet-engine/src/types/workbook_data.ts index 2ded845457..8439581a7c 100644 --- a/packages/o-spreadsheet-engine/src/types/workbook_data.ts +++ b/packages/o-spreadsheet-engine/src/types/workbook_data.ts @@ -11,11 +11,13 @@ import { Dimension, HeaderGroup, HeaderIndex, + NamedRange, PaneDivision, Pixel, PixelPosition, Style, UID, + UnboundedZone, } from "./misc"; import { PivotCoreDefinition } from "./pivot"; import { CoreTableType, TableConfig, TableStyleTemplateName } from "./table"; @@ -84,6 +86,7 @@ export interface WorkbookData { uniqueFigureIds: boolean; settings: WorkbookSettings; customTableStyles: { [key: string]: TableStyleData }; + namedRanges: NamedRangeData[]; } export interface ExcelWorkbookData extends WorkbookData { @@ -115,6 +118,11 @@ export interface DataValidationRuleData extends Omit { + zone: UnboundedZone; + sheetId: UID; +} + export interface ExcelTableData { range: string; filters: ExcelFilterData[]; diff --git a/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.css b/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.css index 3ff1da5d34..775f0a8f3f 100644 --- a/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.css +++ b/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.css @@ -8,11 +8,18 @@ background-color: var(--os-composer-assistant-background); } & > div { - padding: 1px 5px 5px 5px; + padding: 2px 5px 2px 5px; .o-autocomplete-description { padding-left: 5px; font-size: 11px; } } + + .o-icon { + width: 13px; + height: 13px; + font-size: 13px; + color: var(--os-text-body-muted); + } } } diff --git a/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.ts b/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.ts index f4d28d26b3..bbeca8b552 100644 --- a/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.ts +++ b/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.ts @@ -6,7 +6,7 @@ import { HtmlContent } from "../composer/composer"; interface Props { proposals: AutoCompleteProposal[]; selectedIndex: number | undefined; - onValueSelected: (value: string) => void; + onValueSelected: (proposal: AutoCompleteProposal) => void; onValueHovered: (index: string) => void; } diff --git a/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.xml b/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.xml index 572365cda3..0b359501c1 100644 --- a/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.xml +++ b/src/components/composer/autocomplete_dropdown/autocomplete_dropdown.xml @@ -9,18 +9,21 @@
-
- - +
+
+
+ + +
= new AutoCompleteStore(this.get); + private hasSelectedAProposal: boolean = false; hoveredTokens: EnrichedToken[] = []; hoveredContentEvaluation: string = ""; @@ -454,6 +460,7 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { this.editionMode = "editing"; const { text, adjustedSelection } = this.getComposerContent({ sheetId, col, row }, selection); this.initialContent = text; + this.hasSelectedAProposal = false; this.setContent(str || this.initialContent, adjustedSelection ?? selection); this.colorIndexByRange = {}; const zone = positionToZone({ col: this.col, row: this.row }); @@ -657,9 +664,13 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { protected getTokenColor(token: EnrichedToken): string { if (token.type === "REFERENCE") { const { xc, sheetName } = splitReference(token.value); - return this.rangeColor(xc, sheetName) || DEFAULT_TOKEN_COLOR; + return this.rangeXCColor(xc, sheetName) || DEFAULT_TOKEN_COLOR; } if (token.type === "SYMBOL") { + const namedRange = this.getters.getNamedRange(token.value); + if (namedRange) { + return this.rangeColor(namedRange.range) || DEFAULT_TOKEN_COLOR; + } const upperCaseValue = token.value.toUpperCase(); if (upperCaseValue === "TRUE" || upperCaseValue === "FALSE") { return tokenColors.NUMBER; @@ -676,18 +687,20 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { return tokenColors[token.type] || DEFAULT_TOKEN_COLOR; } - private rangeColor(xc: string, sheetName?: string): Color | undefined { + private rangeXCColor(xc: string, sheetName?: string): Color | undefined { const refSheet = sheetName ? this.model.getters.getSheetIdByName(sheetName) : this.sheetId; + if (!refSheet) { + return undefined; + } + const range = this.model.getters.getRangeFromSheetXC(refSheet, xc); + return this.rangeColor(range); + } - const highlight = this.highlights.find((highlight) => { - if (highlight.range.sheetId !== refSheet) return false; - - const range = this.model.getters.getRangeFromSheetXC(refSheet, xc); - let zone = range.zone; - zone = getZoneArea(zone) === 1 ? this.model.getters.expandZone(refSheet, zone) : zone; - return isEqual(zone, highlight.range.zone); - }); - return highlight && highlight.color ? highlight.color : undefined; + private rangeColor(range: Range): Color | undefined { + return this.highlights.find((highlight) => { + if (highlight.range.sheetId !== range.sheetId) return false; + return isEqual(range.zone, highlight.range.zone); + })?.color; } /** @@ -775,7 +788,7 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { return; } const editionSheetId = this.sheetId; - const XCs = this.getReferencedRanges().map((range) => + const XCs = this.getReferencedRanges().map(({ range }) => this.getters.getRangeString(range, editionSheetId) ); const colorsToKeep = {}; @@ -810,7 +823,7 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { const colorIndex = this.colorIndexByRange[rangeString]; return colors[colorIndex % colors.length]; }; - return this.getReferencedRanges().map((range) => { + return this.getReferencedRanges().map(({ type, range }) => { const rangeString = this.getters.getRangeString(range, editionSheetId); const { numberOfRows, numberOfCols } = zoneToDimension(range.zone); const zone = @@ -820,7 +833,7 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { return { range: this.model.getters.getRangeFromZone(range.sheetId, zone), color: rangeColor(rangeString), - interactive: true, + interactive: type === "named_range" ? false : true, }; }); } @@ -828,12 +841,19 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { /** * Return ranges currently referenced in the composer */ - private getReferencedRanges(): Range[] { + private getReferencedRanges(): TypedRange[] { const editionSheetId = this.sheetId; - const referenceRanges = this.currentTokens - .filter((token) => token.type === "REFERENCE") - .map((token) => this.getters.getRangeFromSheetXC(editionSheetId, token.value)); - return referenceRanges.filter((range) => !range.invalidSheetName && !range.invalidXc); + return this.currentTokens + .map((token) => { + if (token.type === "REFERENCE") { + const range = this.getters.getRangeFromSheetXC(editionSheetId, token.value); + return { type: "reference", range }; + } else if (token.type === "SYMBOL") { + return { type: "named_range", range: this.getters.getNamedRange(token.value)?.range }; + } + return undefined; + }) + .filter((r) => r?.range && !r.range.invalidSheetName && !r.range.invalidXc) as TypedRange[]; } private async updateAutoCompleteProvider() { @@ -887,7 +907,10 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { canBeToggled: provider.canBeToggled, }; } - if (exactMatch && this._currentContent !== this.initialContent) { + if ( + exactMatch && + (this._currentContent !== this.initialContent || this.hasSelectedAProposal) + ) { // this means the user has chosen a proposal return; } @@ -933,9 +956,9 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { autoComplete.provider && autoComplete.selectedIndex !== undefined ) { - const autoCompleteValue = autoComplete.provider.proposals[autoComplete.selectedIndex]?.text; - if (autoCompleteValue) { - this.autoComplete.provider?.selectProposal(autoCompleteValue); + const proposal = autoComplete.provider.proposals[autoComplete.selectedIndex]; + if (proposal) { + this.insertAutoCompleteValue(proposal); return; } } @@ -943,8 +966,9 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { } } - insertAutoCompleteValue(value: string) { - this.autoComplete.provider?.selectProposal(value); + insertAutoCompleteValue(proposal: AutoCompleteProposal) { + this.autoComplete.provider?.selectProposal(proposal); + this.hasSelectedAProposal = true; } selectAutoCompleteIndex(index: number) { diff --git a/src/components/composer/composer/composer.ts b/src/components/composer/composer/composer.ts index e1178923e8..dd32640711 100644 --- a/src/components/composer/composer/composer.ts +++ b/src/components/composer/composer/composer.ts @@ -8,6 +8,7 @@ import { EnrichedToken } from "@odoo/o-spreadsheet-engine/formulas/composer_toke import { argTargeting } from "@odoo/o-spreadsheet-engine/functions/arguments"; import { functionRegistry } from "@odoo/o-spreadsheet-engine/functions/function_registry"; import { SpreadsheetChildEnv } from "@odoo/o-spreadsheet-engine/types/spreadsheet_env"; +import { AutoCompleteProposal } from "../../../registries/auto_completes"; import { Store, useStore } from "../../../store_engine"; import { DOMFocusableElementStore } from "../../../stores/DOM_focus_store"; import { @@ -843,11 +844,11 @@ export class Composer extends Component return [...new Set(argsToFocus)]; } - autoComplete(value: string) { - if (!value || (this.assistant.forcedClosed && this.props.composerStore.canBeToggled)) { + autoComplete(proposal: AutoCompleteProposal) { + if (!proposal || (this.assistant.forcedClosed && this.props.composerStore.canBeToggled)) { return; } - this.props.composerStore.insertAutoCompleteValue(value); + this.props.composerStore.insertAutoCompleteValue(proposal); this.processTokenAtCursor(); } diff --git a/src/components/generic_input/generic_input.ts b/src/components/generic_input/generic_input.ts index 0b78d0b55c..f1cc919596 100644 --- a/src/components/generic_input/generic_input.ts +++ b/src/components/generic_input/generic_input.ts @@ -28,6 +28,8 @@ export class GenericInput extends Component; + private lastOnChangeValue: string = this.props.value.toString(); + setup() { this.inputRef = useRef(this.refName); useExternalListener( @@ -47,6 +49,7 @@ export class GenericInput extends Component { if (this.inputRef.el) this.inputRef.el.value = this.props.value.toString(); @@ -71,12 +74,13 @@ export class GenericInput extends Component) { const state = useState({ hovered: false }); useRefListener(ref, "mouseenter", () => (state.hovered = true)); useRefListener(ref, "mouseleave", () => (state.hovered = false)); + // If a render changes the element size while the mouse is over it, + // the mouseleave event might not be triggered. Removing the hover state in case of a resize is not great, + // but it's better than having a stuck hover state. + const resizeObserver = new ResizeObserver(() => { + state.hovered = false; + }); + onMounted(() => { + resizeObserver.observe(ref.el!); + }); + onWillUnmount(() => { + resizeObserver.disconnect(); + }); + return state; } diff --git a/src/components/icons/icons.xml b/src/components/icons/icons.xml index 3bb39fd64f..378605fd70 100644 --- a/src/components/icons/icons.xml +++ b/src/components/icons/icons.xml @@ -1105,4 +1105,18 @@ /> + + + + + diff --git a/src/components/named_range_selector/named_range_selector.css b/src/components/named_range_selector/named_range_selector.css new file mode 100644 index 0000000000..55fd907b3e --- /dev/null +++ b/src/components/named_range_selector/named_range_selector.css @@ -0,0 +1,18 @@ +.o-spreadsheet { + .o-named-range-selector-container { + width: 100px; + + .o-named-range-selector { + font-size: 13px; + &.selected, + &:hover { + background-color: var(--os-button-hover-bg); + color: var(--os-button-hover-text-color); + + .o-icon { + color: var(--os-button-hover-text-color); + } + } + } + } +} diff --git a/src/components/named_range_selector/named_range_selector.ts b/src/components/named_range_selector/named_range_selector.ts new file mode 100644 index 0000000000..d0c69eb594 --- /dev/null +++ b/src/components/named_range_selector/named_range_selector.ts @@ -0,0 +1,146 @@ +import { _t, rangeReference } from "@odoo/o-spreadsheet-engine"; +import { HIGHLIGHT_COLOR } from "@odoo/o-spreadsheet-engine/constants"; +import { SpreadsheetChildEnv } from "@odoo/o-spreadsheet-engine/types/spreadsheet_env"; +import { Component, useRef, useState } from "@odoo/owl"; +import { Action, ActionSpec, createActions } from "../../actions/action"; +import { zoneToXc } from "../../helpers"; +import { + interactiveCreateNamedRange, + interactiveUpdateNamedRange, +} from "../../helpers/ui/named_range_interactive"; +import { Store, useStore } from "../../store_engine"; +import { DOMFocusableElementStore } from "../../stores/DOM_focus_store"; +import { HighlightStore } from "../../stores/highlight_store"; +import { CommandResult, Highlight, Range } from "../../types"; +import { getRefBoundingRect } from "../helpers/dom_helpers"; +import { ToolBarDropdownStore, useToolBarDropdownStore } from "../helpers/top_bar_tool_hook"; +import { MenuPopover, MenuState } from "../menu_popover/menu_popover"; +import { TextInput } from "../text_input/text_input"; + +interface Props {} + +type State = Omit; + +export class NamedRangeSelector extends Component { + static template = "o-spreadsheet-NamedRangeSelector"; + static props = {}; + static components = { TextInput, MenuPopover }; + + private DOMFocusableElementStore!: Store; + + topBarToolStore!: ToolBarDropdownStore; + menuState = useState({ anchorRect: null, menuItems: [] }); + + private namedRangeSelectorRef = useRef("namedRangeSelectorRef"); + + setup() { + this.topBarToolStore = useToolBarDropdownStore(); + this.DOMFocusableElementStore = useStore(DOMFocusableElementStore); + } + + changeInputValue(newValue: string) { + if (!newValue) { + return; + } + const sheetId = this.env.model.getters.getActiveSheetId(); + if (rangeReference.test(newValue)) { + const range = this.env.model.getters.getRangeFromSheetXC(sheetId, newValue); + this.navigateToRange(range); + this.stopEditingNamedRange(); + return; + } + + const namedRange = this.env.model.getters.getNamedRangeFromZone(sheetId, this.selectedZone); + if (!namedRange) { + interactiveCreateNamedRange(this.env, { + rangeName: newValue, + ranges: [this.env.model.getters.getRangeDataFromZone(sheetId, this.selectedZone)], + }); + } else { + interactiveUpdateNamedRange(this.env, { + newRangeName: newValue, + oldRangeName: namedRange.rangeName, + ranges: [this.env.model.getters.getRangeData(namedRange.range)], + }); + } + this.stopEditingNamedRange(); + } + + get inputValue() { + const sheetId = this.env.model.getters.getActiveSheetId(); + const namedRange = this.env.model.getters.getNamedRangeFromZone(sheetId, this.selectedZone); + return namedRange?.rangeName || zoneToXc(this.selectedZone); + } + + get selectedZone() { + return this.env.model.getters.getSelectedZone(); + } + + toggleDropdown() { + this.topBarToolStore.openDropdown(); + this.menuState.anchorRect = getRefBoundingRect(this.namedRangeSelectorRef); + this.menuState.menuItems = this.getNamedRangeMenuItems(); + } + + getNamedRangeMenuItems(): Action[] { + const actionsSpecs: ActionSpec[] = []; + for (const { rangeName, range } of this.env.model.getters.getNamedRanges()) { + const highlightProvider = { + get highlights(): Highlight[] { + return [{ range, color: HIGHLIGHT_COLOR, noFill: true }]; + }, + }; + actionsSpecs.push({ + name: rangeName, + execute: () => { + this.navigateToRange(range); + this.stopEditingNamedRange(); + }, + description: (env) => env.model.getters.getRangeString(range), + icon: "o-spreadsheet-Icon.NAMED_RANGE", + onStartHover: (env) => env.getStore(HighlightStore).register(highlightProvider), + onStopHover: (env) => env.getStore(HighlightStore).unRegister(highlightProvider), + }); + } + + if (actionsSpecs.length > 0) { + actionsSpecs.at(-1)!.separator = true; + } + + actionsSpecs.push({ + name: _t("Manage named ranges"), + execute: () => { + this.env.openSidePanel("NamedRangesPanel", {}); + this.stopEditingNamedRange(); + }, + icon: "o-spreadsheet-Icon.EDIT", + }); + + return createActions(actionsSpecs); + } + + private stopEditingNamedRange() { + this.topBarToolStore.closeDropdowns(); + this.DOMFocusableElementStore.focus(); + } + + private navigateToRange(range: Range) { + const { sheetId, zone } = range; + const doesRangeExist = this.env.model.getters.checkZonesExistInSheet(sheetId, [zone]); + if (doesRangeExist !== CommandResult.Success) { + this.env.raiseError(_t("The range you specified is outside of the sheet.")); + return; + } + const activeSheetId = this.env.model.getters.getActiveSheetId(); + if (activeSheetId !== sheetId) { + this.env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: activeSheetId, sheetIdTo: sheetId }); + } + + // First select the bottom-right cell to try to scroll the sheet so that the whole range is visible + this.env.model.selection.selectCell(zone.right, zone.bottom); + this.env.model.selection.selectZone({ + cell: { col: zone.left, row: zone.top }, + zone, + }); + } +} diff --git a/src/components/named_range_selector/named_range_selector.xml b/src/components/named_range_selector/named_range_selector.xml new file mode 100644 index 0000000000..398a20a889 --- /dev/null +++ b/src/components/named_range_selector/named_range_selector.xml @@ -0,0 +1,27 @@ + + +
+
+ +
+ +
+
+
+ + +
+
diff --git a/src/components/selection_input/selection_input.ts b/src/components/selection_input/selection_input.ts index a9c272f1fe..c946c80486 100644 --- a/src/components/selection_input/selection_input.ts +++ b/src/components/selection_input/selection_input.ts @@ -19,6 +19,7 @@ interface Props { onSelectionReordered?: (indexes: number[]) => void; onSelectionRemoved?: (index: number) => void; onSelectionConfirmed?: () => void; + onInputFocused?: () => void; colors?: Color[]; disabledRanges?: boolean[]; disabledRangeTitle?: string; @@ -58,6 +59,7 @@ export class SelectionInput extends Component { onSelectionConfirmed: { type: Function, optional: true }, onSelectionReordered: { type: Function, optional: true }, onSelectionRemoved: { type: Function, optional: true }, + onInputFocused: { type: Function, optional: true }, colors: { type: Array, optional: true, default: [] }, disabledRanges: { type: Array, optional: true, default: [] }, disabledRangeTitle: { type: String, optional: true }, @@ -109,6 +111,7 @@ export class SelectionInput extends Component { ); if (this.props.autofocus) { this.store.focusById(this.store.selectionInputs[0]?.id); + this.props.onInputFocused?.(); } onWillUpdateProps((nextProps) => { if (nextProps.ranges.join() !== this.store.selectionInputValues.join()) { @@ -211,6 +214,7 @@ export class SelectionInput extends Component { this.state.isMissing = false; this.state.mode = "select-range"; this.store.focusById(rangeId); + this.props.onInputFocused?.(); } addEmptyInput() { diff --git a/src/components/side_panel/named_ranges_panel/named_range_preview/named_range_preview.css b/src/components/side_panel/named_ranges_panel/named_range_preview/named_range_preview.css new file mode 100644 index 0000000000..46037bce8d --- /dev/null +++ b/src/components/side_panel/named_ranges_panel/named_range_preview/named_range_preview.css @@ -0,0 +1,17 @@ +.o-spreadsheet { + .o-sidePanel { + .o-named-range-preview { + input { + padding-left: 0px; + } + + .o-selection-input input:not(.o-focused):not(:hover) { + border-color: transparent; + } + + &:hover:not(:focus-within):not(.o-focused) .o-named-range-edit { + display: flex !important; + } + } + } +} diff --git a/src/components/side_panel/named_ranges_panel/named_range_preview/named_range_preview.ts b/src/components/side_panel/named_ranges_panel/named_range_preview/named_range_preview.ts new file mode 100644 index 0000000000..cbd1b3760b --- /dev/null +++ b/src/components/side_panel/named_ranges_panel/named_range_preview/named_range_preview.ts @@ -0,0 +1,88 @@ +import { Highlight, NamedRange } from "@odoo/o-spreadsheet-engine"; +import { HIGHLIGHT_COLOR } from "@odoo/o-spreadsheet-engine/constants"; +import { SpreadsheetChildEnv } from "@odoo/o-spreadsheet-engine/types/spreadsheet_env"; +import { Component, useRef, useState } from "@odoo/owl"; +import { interactiveUpdateNamedRange } from "../../../../helpers/ui/named_range_interactive"; +import { useHighlightsOnHover } from "../../../helpers/highlight_hook"; +import { SelectionInput } from "../../../selection_input/selection_input"; +import { TextInput } from "../../../text_input/text_input"; + +interface Props { + namedRange: NamedRange; +} + +interface State { + isSelectionInputFocused?: boolean; + currentRange?: string; +} + +export class NamedRangePreview extends Component { + static template = "o-spreadsheet-NamedRangePreview"; + static props = { + namedRange: Object, + }; + static components = { SelectionInput, TextInput }; + + state = useState({}); + + private ref = useRef("namedRangePreview"); + + setup() { + useHighlightsOnHover(this.ref, this); + } + + get highlights(): Highlight[] { + if (this.state.isSelectionInputFocused) { + return []; + } + return [{ range: this.props.namedRange.range, color: HIGHLIGHT_COLOR, noFill: true }]; + } + + deleteNamedRange() { + this.env.model.dispatch("DELETE_NAMED_RANGE", { + rangeName: this.props.namedRange.rangeName, + }); + } + + updateNamedRangeName(newName: string) { + interactiveUpdateNamedRange(this.env, { + oldRangeName: this.props.namedRange.rangeName, + newRangeName: newName, + ranges: [this.env.model.getters.getRangeData(this.props.namedRange.range)], + }); + } + + onSelectionInputChanged(ranges: string[]) { + this.state.currentRange = ranges[0]; + } + + onSelectionInputConfirmed() { + this.state.isSelectionInputFocused = false; + if (this.state.currentRange) { + const range = this.env.model.getters.getRangeFromSheetXC( + this.env.model.getters.getActiveSheetId(), + this.state.currentRange + ); + if (range.invalidSheetName || range.invalidXc) { + return; + } + + interactiveUpdateNamedRange(this.env, { + oldRangeName: this.props.namedRange.rangeName, + newRangeName: this.props.namedRange.rangeName, + ranges: [this.env.model.getters.getRangeData(range)], + }); + } + } + + onSelectionInputFocused() { + this.state.isSelectionInputFocused = true; + } + + get rangeString(): string { + return this.env.model.getters.getRangeString( + this.props.namedRange.range, + this.env.model.getters.getActiveSheetId() + ); + } +} diff --git a/src/components/side_panel/named_ranges_panel/named_range_preview/named_range_preview.xml b/src/components/side_panel/named_ranges_panel/named_range_preview/named_range_preview.xml new file mode 100644 index 0000000000..957d53fcbf --- /dev/null +++ b/src/components/side_panel/named_ranges_panel/named_range_preview/named_range_preview.xml @@ -0,0 +1,37 @@ + + + +
+
+
+ + +
+
+ +
+
+
+
+
+
diff --git a/src/components/side_panel/named_ranges_panel/named_ranges_panel.ts b/src/components/side_panel/named_ranges_panel/named_ranges_panel.ts new file mode 100644 index 0000000000..6c238db5e3 --- /dev/null +++ b/src/components/side_panel/named_ranges_panel/named_ranges_panel.ts @@ -0,0 +1,38 @@ +import { _t, getUniqueText } from "@odoo/o-spreadsheet-engine"; +import { SpreadsheetChildEnv } from "@odoo/o-spreadsheet-engine/types/spreadsheet_env"; +import { Component, useState } from "@odoo/owl"; +import { SelectionInput } from "../../selection_input/selection_input"; +import { TextInput } from "../../text_input/text_input"; +import { NamedRangePreview } from "./named_range_preview/named_range_preview"; + +interface Props { + onCloseSidePanel: () => void; +} + +interface State {} + +export class NamedRangesPanel extends Component { + static template = "o-spreadsheet-NamedRangesPanel"; + static props = { + onCloseSidePanel: Function, + }; + static components = { NamedRangePreview, SelectionInput, TextInput }; + + state = useState({}); + + get namedRanges() { + return this.env.model.getters.getNamedRanges(); + } + + addNewNamedRange() { + const existingNames = this.namedRanges.map((nr) => nr.rangeName); + const sheetId = this.env.model.getters.getActiveSheetId(); + const selection = this.env.model.getters.getSelectedZone(); + this.env.model.dispatch("CREATE_NAMED_RANGE", { + rangeName: getUniqueText(_t("NamedRange"), existingNames, { + compute: (text, index) => `${text}${index}`, + }), + ranges: [this.env.model.getters.getRangeDataFromZone(sheetId, selection)], + }); + } +} diff --git a/src/components/side_panel/named_ranges_panel/named_ranges_panel.xml b/src/components/side_panel/named_ranges_panel/named_ranges_panel.xml new file mode 100644 index 0000000000..0e618fb086 --- /dev/null +++ b/src/components/side_panel/named_ranges_panel/named_ranges_panel.xml @@ -0,0 +1,14 @@ + + +
+
+ + + +
+ +
+
+
diff --git a/src/components/side_panel/pivot/pivot_layout_configurator/add_dimension_button/add_dimension_button.ts b/src/components/side_panel/pivot/pivot_layout_configurator/add_dimension_button/add_dimension_button.ts index a6a38d4f37..9aacb0d9b0 100644 --- a/src/components/side_panel/pivot/pivot_layout_configurator/add_dimension_button/add_dimension_button.ts +++ b/src/components/side_panel/pivot/pivot_layout_configurator/add_dimension_button/add_dimension_button.ts @@ -49,8 +49,8 @@ export class AddDimensionButton extends Component { return { proposals: this.proposals, autoSelectFirstProposal: false, - selectProposal: (value) => { - const field = this.props.fields.find((field) => field.string === value); + selectProposal: (proposal) => { + const field = this.props.fields.find((field) => field.string === proposal.text); if (field) { this.pickField(field); } @@ -109,10 +109,12 @@ export class AddDimensionButton extends Component { case "Enter": const proposals = this.autoComplete.provider?.proposals; if (proposals?.length === 1) { - this.autoComplete.provider?.selectProposal(proposals[0].text || ""); + this.autoComplete.provider?.selectProposal(proposals[0]); } const proposal = this.autoComplete.selectedProposal; - this.autoComplete.provider?.selectProposal(proposal?.text || ""); + if (proposal) { + this.autoComplete.provider?.selectProposal(proposal); + } break; case "ArrowUp": case "ArrowDown": diff --git a/src/components/top_bar/top_bar.ts b/src/components/top_bar/top_bar.ts index 2162f41a69..1f484680fe 100644 --- a/src/components/top_bar/top_bar.ts +++ b/src/components/top_bar/top_bar.ts @@ -22,6 +22,7 @@ import { TopBarComposer } from "../composer/top_bar_composer/top_bar_composer"; import { getBoundingRectAsPOJO } from "../helpers/dom_helpers"; import { useSpreadsheetRect } from "../helpers/position_hook"; import { MenuPopover, MenuState } from "../menu_popover/menu_popover"; +import { NamedRangeSelector } from "../named_range_selector/named_range_selector"; import { Popover, PopoverProps } from "../popover"; import { TopBarToolStore } from "./top_bar_tool_store"; import { topBarToolBarRegistry } from "./top_bar_tools_registry"; @@ -51,6 +52,7 @@ export class TopBar extends Component { MenuPopover, TopBarComposer, Popover, + NamedRangeSelector, }; toolsCategories = topBarToolBarRegistry.getCategories(); @@ -110,11 +112,26 @@ export class TopBar extends Component { this.moreToolsContainerRef.el?.classList.remove("d-none"); const moreToolsWidth = this.moreToolsButtonRef.el?.getBoundingClientRect().width || 0; + // Named range + divider + let startingElementsWidth: number = 0; + for (const child of this.toolbarRef.el!.children) { + if (child.classList.contains("tool-container")) { + break; + } + startingElementsWidth += child.getBoundingClientRect().width; + } + startingElementsWidth = 0; + // The actual width in which we can place our tools so that they are visible. // Every tool container passed that width will be hidden. // We remove 16px to the width to account for a scrollbar that might appear. // Otherwise, we could end up in a loop of computation - const usableWidth = Math.round(this.spreadsheetRect.width) - moreToolsWidth - (toolsX - x) - 16; + const usableWidth = + Math.round(this.spreadsheetRect.width) - + moreToolsWidth - + (toolsX - x) - + 16 - + startingElementsWidth; const toolElements = document.querySelectorAll(".tool-container"); diff --git a/src/components/top_bar/top_bar.xml b/src/components/top_bar/top_bar.xml index 030f42eb4f..abfb4b67ea 100644 --- a/src/components/top_bar/top_bar.xml +++ b/src/components/top_bar/top_bar.xml @@ -41,7 +41,12 @@ Readonly Access
-
+
+ +
+) { + const result = env.model.dispatch("CREATE_NAMED_RANGE", payload); + handleResult(env, result); +} + +export function interactiveUpdateNamedRange( + env: SpreadsheetChildEnv, + payload: Omit +) { + const result = env.model.dispatch("UPDATE_NAMED_RANGE", payload); + handleResult(env, result); +} + +function handleResult(env: SpreadsheetChildEnv, result: DispatchResult) { + if (!result.isSuccessful) { + if (result.isCancelledBecause(CommandResult.NamedRangeNameAlreadyExists)) { + env.raiseError(_t("A named range with this name already exists.")); + } else if (result.isCancelledBecause(CommandResult.NamedRangeNameWithInvalidCharacter)) { + env.raiseError( + _t( + "The named range name contains invalid characters. Valid characters are letters, numbers, underscores, and periods." + ) + ); + } else if (result.isCancelledBecause(CommandResult.NamedRangeNameLooksLikeCellReference)) { + env.raiseError(_t("A named range name cannot resemble a cell reference.")); + } else if (result.isCancelledBecause(CommandResult.NamedRangeNotFound)) { + env.raiseError(_t("The named range to update was not found.")); + } + } +} diff --git a/src/registries/auto_completes/auto_complete_registry.ts b/src/registries/auto_completes/auto_complete_registry.ts index 95141db2b4..ae39aa5429 100644 --- a/src/registries/auto_completes/auto_complete_registry.ts +++ b/src/registries/auto_completes/auto_complete_registry.ts @@ -18,11 +18,13 @@ export interface AutoCompleteProposal { */ fuzzySearchKey?: string; alwaysExpanded?: boolean; + icon?: string; + staticDescription?: boolean; } export interface AutoCompleteProvider { proposals: AutoCompleteProposal[]; - selectProposal(text: string): void; + selectProposal(proposal: AutoCompleteProposal): void; autoSelectFirstProposal: boolean; canBeToggled?: boolean; } @@ -57,7 +59,7 @@ export interface AutoCompleteProviderDefinition { selectProposal( this: { composer: ComposerStoreInterface }, tokenAtCursor: EnrichedToken, - text: string + propsal: AutoCompleteProposal ): void; } diff --git a/src/registries/auto_completes/data_validation_auto_complete.ts b/src/registries/auto_completes/data_validation_auto_complete.ts index 3c8c00d352..9847167bfc 100644 --- a/src/registries/auto_completes/data_validation_auto_complete.ts +++ b/src/registries/auto_completes/data_validation_auto_complete.ts @@ -51,8 +51,8 @@ autoCompleteProviders.add("dataValidation", { }; }); }, - selectProposal(tokenAtCursor, value) { - this.composer.setCurrentContent(value); + selectProposal(tokenAtCursor, proposal) { + this.composer.setCurrentContent(proposal.text); this.composer.stopEdition(); }, }); diff --git a/src/registries/auto_completes/function_auto_complete.ts b/src/registries/auto_completes/function_auto_complete.ts index 3d535b4f19..9054697397 100644 --- a/src/registries/auto_completes/function_auto_complete.ts +++ b/src/registries/auto_completes/function_auto_complete.ts @@ -2,9 +2,11 @@ import { COMPOSER_ASSISTANT_COLOR } from "@odoo/o-spreadsheet-engine/constants"; import { functionRegistry } from "@odoo/o-spreadsheet-engine/functions/function_registry"; import { getHtmlContentFromPattern } from "../../components/helpers/html_content_helpers"; import { isFormula } from "../../helpers"; -import { autoCompleteProviders } from "./auto_complete_registry"; +import { AutoCompleteProposal, autoCompleteProviders } from "./auto_complete_registry"; -autoCompleteProviders.add("functions", { +type FunctionAutoCompleteProposal = AutoCompleteProposal & { type: "function" | "named_range" }; + +autoCompleteProviders.add("functions_and_named_ranges", { sequence: 100, autoSelectFirstProposal: true, maxDisplayedProposals: 10, @@ -16,10 +18,11 @@ autoCompleteProviders.add("functions", { if (!isFormula(this.composer.currentContent)) { return []; } - const values = Object.entries(functionRegistry.content) + const values: FunctionAutoCompleteProposal[] = Object.entries(functionRegistry.content) .filter(([_, { hidden }]) => !hidden) .map(([text, { description }]) => { return { + type: "function", text, description, htmlContent: getHtmlContentFromPattern( @@ -29,13 +32,28 @@ autoCompleteProviders.add("functions", { "o-semi-bold" ), }; - }) - .sort((a, b) => { - return a.text.length - b.text.length || a.text.localeCompare(b.text); }); + + values.push( + ...this.getters.getNamedRanges().map((namedRange) => ({ + type: "named_range" as const, + text: namedRange.rangeName, + description: this.getters.getRangeString(namedRange.range), + icon: "o-spreadsheet-Icon.NAMED_RANGE", + htmlContent: getHtmlContentFromPattern( + searchTerm, + namedRange.rangeName, + COMPOSER_ASSISTANT_COLOR, + "o-semi-bold" + ), + })) + ); + values.sort((a, b) => { + return a.text.length - b.text.length || a.text.localeCompare(b.text); + }); return values; }, - selectProposal(tokenAtCursor, value) { + selectProposal(tokenAtCursor, proposal: FunctionAutoCompleteProposal) { let start = tokenAtCursor.end; let end = tokenAtCursor.end; @@ -45,13 +63,16 @@ autoCompleteProviders.add("functions", { } const tokens = this.composer.currentTokens; - value += "("; + let value = proposal.text; + if (proposal.type === "function") { + value += "("; - const currentTokenIndex = tokens.map((token) => token.start).indexOf(tokenAtCursor.start); - if (currentTokenIndex + 1 < tokens.length) { - const nextToken = tokens[currentTokenIndex + 1]; - if (nextToken?.type === "LEFT_PAREN") { - end++; + const currentTokenIndex = tokens.map((token) => token.start).indexOf(tokenAtCursor.start); + if (currentTokenIndex + 1 < tokens.length) { + const nextToken = tokens[currentTokenIndex + 1]; + if (nextToken?.type === "LEFT_PAREN") { + end++; + } } } this.composer.changeComposerCursorSelection(start, end); diff --git a/src/registries/auto_completes/pivot_dimension_auto_complete.ts b/src/registries/auto_completes/pivot_dimension_auto_complete.ts index 692a9dbbee..3ea3ed13e7 100644 --- a/src/registries/auto_completes/pivot_dimension_auto_complete.ts +++ b/src/registries/auto_completes/pivot_dimension_auto_complete.ts @@ -34,14 +34,14 @@ export function createMeasureAutoComplete( }); return measureProposals.concat(dimensionsProposals); }, - selectProposal(tokenAtCursor, value) { + selectProposal(tokenAtCursor, proposal) { let start = tokenAtCursor.end; if (tokenAtCursor.type === "SYMBOL") { start = tokenAtCursor.start; } const end = tokenAtCursor.end; this.composer.changeComposerCursorSelection(start, end); - this.composer.replaceComposerCursorSelection(value); + this.composer.replaceComposerCursorSelection(proposal.text); }, }; } diff --git a/src/registries/auto_completes/sheet_name_auto_complete.ts b/src/registries/auto_completes/sheet_name_auto_complete.ts index 1da3685580..f9e0c3b2f4 100644 --- a/src/registries/auto_completes/sheet_name_auto_complete.ts +++ b/src/registries/auto_completes/sheet_name_auto_complete.ts @@ -19,10 +19,10 @@ autoCompleteProviders.add("sheet_names", { } return []; }, - selectProposal(tokenAtCursor, value) { + selectProposal(tokenAtCursor, proposal) { const start = tokenAtCursor.start; const end = tokenAtCursor.end; this.composer.changeComposerCursorSelection(start, end); - this.composer.replaceComposerCursorSelection(value + "!"); + this.composer.replaceComposerCursorSelection(proposal.text + "!"); }, }); diff --git a/src/registries/side_panel_registry.ts b/src/registries/side_panel_registry.ts index a81257177e..fad5dd1082 100644 --- a/src/registries/side_panel_registry.ts +++ b/src/registries/side_panel_registry.ts @@ -9,6 +9,7 @@ import { DataValidationPanel } from "../components/side_panel/data_validation/da 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 { MoreFormatsPanel } from "../components/side_panel/more_formats/more_formats"; +import { NamedRangesPanel } from "../components/side_panel/named_ranges_panel/named_ranges_panel"; import { PivotMeasureDisplayPanel } from "../components/side_panel/pivot/pivot_measure_display_panel/pivot_measure_display_panel"; import { PivotSidePanel } from "../components/side_panel/pivot/pivot_side_panel/pivot_side_panel"; import { RemoveDuplicatesPanel } from "../components/side_panel/remove_duplicates/remove_duplicates"; @@ -165,3 +166,8 @@ sidePanelRegistry.add("CarouselPanel", { return { isOpen: true, props: { figureId } }; }, }); + +sidePanelRegistry.add("NamedRangesPanel", { + title: _t("Named Ranges"), + Body: NamedRangesPanel, +}); diff --git a/tests/__snapshots__/top_bar_component.test.ts.snap b/tests/__snapshots__/top_bar_component.test.ts.snap index d3f4dbb6eb..d3b6f87035 100644 --- a/tests/__snapshots__/top_bar_component.test.ts.snap +++ b/tests/__snapshots__/top_bar_component.test.ts.snap @@ -770,6 +770,86 @@ exports[`TopBar component simple rendering 1`] = ` /> +<<<<<<< HEAD +======= +
+
+
+
+
+
+
+ + +
+ +
+
+ +
+
+
+
+ + +
+
+
+
+ +
+
+ + +
+ + + + + + +>>>>>>> 71acd8821 ([IMP] spreadsheet: add named ranges feature)
diff --git a/tests/collaborative/collaborative.test.ts b/tests/collaborative/collaborative.test.ts index 6001f89c5d..4a0d29a26d 100644 --- a/tests/collaborative/collaborative.test.ts +++ b/tests/collaborative/collaborative.test.ts @@ -20,10 +20,12 @@ import { copy, createChart, createFigure, + createNamedRange, createSheet, createTable, createTableStyle, createTableWithFilter, + deleteNamedRange, deleteRows, deleteSheet, duplicateSheet, @@ -39,6 +41,7 @@ import { unMerge, undo, ungroupHeaders, + updateNamedRange, updateTableConfig, } from "../test_helpers/commands_helpers"; import { @@ -1016,6 +1019,89 @@ describe("Multi users synchronisation", () => { ); }); + describe("Named ranges", () => { + test("Create two named ranges with the same name concurrently", () => { + network.concurrent(() => { + createNamedRange(alice, "MyRange", "A1:A5"); + createNamedRange(bob, "MyRange", "B1:B5"); + }); + + const expectedNamedRange = alice.getters.getNamedRange("MyRange"); + expect(expectedNamedRange).toMatchObject({ range: { zone: toZone("A1:A5") } }); + expect([alice, bob, charlie]).toHaveSynchronizedValue( + (user) => user.getters.getNamedRanges(), + [expectedNamedRange] + ); + }); + + test("Two concurrent named range updates", () => { + createNamedRange(alice, "MyRange", "A1:A5"); + network.concurrent(() => { + updateNamedRange(alice, "MyRange", "NewName", "B1:B5"); + updateNamedRange(bob, "MyRange", "OtherNewName", "C1:C5"); + }); + + const expectedNamedRange = bob.getters.getNamedRange("OtherNewName"); + expect(expectedNamedRange).toMatchObject({ + rangeName: "OtherNewName", + range: { zone: toZone("C1:C5") }, + }); + expect([alice, bob, charlie]).toHaveSynchronizedValue( + (user) => user.getters.getNamedRanges(), + [expectedNamedRange] + ); + }); + + test("Renaming a named range and deleting it concurrently", () => { + createNamedRange(alice, "MyRange", "A1:A5"); + network.concurrent(() => { + updateNamedRange(alice, "MyRange", "NewName", "B1:B5"); + deleteNamedRange(bob, "MyRange"); + }); + + expect([alice, bob, charlie]).toHaveSynchronizedValue( + (user) => user.getters.getNamedRanges(), + [] + ); + }); + + test("Renaming a named range and creating a new one with the same new name concurrently", () => { + createNamedRange(alice, "MyRange", "A1:A5"); + network.concurrent(() => { + updateNamedRange(alice, "MyRange", "NewName", "B1:B5"); + createNamedRange(bob, "NewName", "C1:C5"); + }); + + const expectedNamedRange = alice.getters.getNamedRange("NewName"); + expect(expectedNamedRange).toMatchObject({ + rangeName: "NewName", + range: { zone: toZone("B1:B5") }, + }); + expect([alice, bob, charlie]).toHaveSynchronizedValue( + (user) => user.getters.getNamedRanges(), + [expectedNamedRange] + ); + }); + + test("Renaming two named ranges to the same name concurrently", () => { + createNamedRange(alice, "MyRange", "A1:A5"); + createNamedRange(bob, "MyRange2", "C1:C5"); + network.concurrent(() => { + updateNamedRange(alice, "MyRange", "NewName", "A1:A5"); + updateNamedRange(bob, "MyRange2", "NewName", "C1:C5"); + }); + + expect(alice.getters.getNamedRanges()).toMatchObject([ + { rangeName: "NewName", range: { zone: toZone("A1:A5") } }, + { rangeName: "MyRange2", range: { zone: toZone("C1:C5") } }, + ]); + expect([alice, bob, charlie]).toHaveSynchronizedValue( + (user) => user.getters.getNamedRanges(), + alice.getters.getNamedRanges() + ); + }); + }); + test("do not send message while waiting an acknowledgement", () => { const spy = jest.spyOn(network, "sendMessage"); network.concurrent(() => { diff --git a/tests/collaborative/ot/ot_sheet_deleted.test.ts b/tests/collaborative/ot/ot_sheet_deleted.test.ts index 0349bc4b3a..0562743282 100644 --- a/tests/collaborative/ot/ot_sheet_deleted.test.ts +++ b/tests/collaborative/ot/ot_sheet_deleted.test.ts @@ -2,7 +2,6 @@ import { transform } from "@odoo/o-spreadsheet-engine/collaborative/ot/ot"; import { toZone } from "../../../src/helpers"; import { AddColumnsRowsCommand, - AddConditionalFormatCommand, DeleteSheetCommand, DuplicateSheetCommand, MoveRangeCommand, @@ -57,7 +56,6 @@ describe("OT with DELETE_SHEET", () => { describe.each([ ...OT_TESTS_SINGLE_CELL_COMMANDS, ...OT_TESTS_TARGET_DEPENDANT_COMMANDS, - ...OT_TESTS_RANGE_DEPENDANT_COMMANDS, ...OT_TESTS_ZONE_DEPENDANT_COMMANDS, addColumns, addRows, @@ -145,29 +143,41 @@ describe("OT with DELETE_SHEET", () => { }); describe("Delete sheet with range dependant command", () => { - const addCF: AddConditionalFormatCommand = { ...TEST_COMMANDS.ADD_CONDITIONAL_FORMAT }; - - test("Delete the sheet of the command", () => { - const cmd = { ...addCF, sheetId: deletedSheetId, ranges: toRangesData(sheetId, "A1:B1") }; - const result = transform(cmd, deleteSheet); - expect(result).toBeUndefined(); - }); - - test("Delete the sheet of the ranges", () => { - const cmd = { ...addCF, sheetId: sheetId, ranges: toRangesData(deletedSheetId, "A1:B1") }; - const result = transform(cmd, deleteSheet); - expect(result).toBeUndefined(); - }); + test.each(OT_TESTS_RANGE_DEPENDANT_COMMANDS.filter((cmd) => "sheetId" in cmd))( + "Delete the sheet of the command", + (cmd) => { + const cmdToTransform = { + ...cmd, + sheetId: deletedSheetId, + ranges: toRangesData(sheetId, "A1:B1"), + }; + const result = transform(cmdToTransform, deleteSheet); + expect(result).toBeUndefined(); + } + ); - test("Delete the sheet of some of the ranges", () => { - const cmd = { - ...addCF, + test.each(OT_TESTS_RANGE_DEPENDANT_COMMANDS)("Delete the sheet of the ranges", (cmd) => { + const cmdToTransform = { + ...cmd, sheetId: sheetId, - ranges: [...toRangesData(deletedSheetId, "A1:B1"), ...toRangesData(sheetId, "A1:B1")], + ranges: toRangesData(deletedSheetId, "A1:B1"), }; - const result = transform(cmd, deleteSheet); - expect(result).toEqual({ ...cmd, ranges: toRangesData(sheetId, "A1:B1") }); + const result = transform(cmdToTransform, deleteSheet); + expect(result).toBeUndefined(); }); + + test.each(OT_TESTS_RANGE_DEPENDANT_COMMANDS)( + "Delete the sheet of some of the ranges", + (cmd) => { + const cmdToTransform = { + ...cmd, + sheetId: sheetId, + ranges: [...toRangesData(deletedSheetId, "A1:B1"), ...toRangesData(sheetId, "A1:B1")], + }; + const result = transform(cmdToTransform, deleteSheet); + expect(result).toEqual({ ...cmdToTransform, ranges: toRangesData(sheetId, "A1:B1") }); + } + ); }); describe("Delete sheed with string formula dependant command", () => { diff --git a/tests/components/text_input.test.ts b/tests/components/text_input.test.ts index 85a1e28410..9a8a199731 100644 --- a/tests/components/text_input.test.ts +++ b/tests/components/text_input.test.ts @@ -60,12 +60,13 @@ describe("TextInput", () => { expect(onChange).not.toHaveBeenCalled(); }); - test("can save the value with enter key", async () => { + test("can save the value with enter key, and the onChange is only called once", async () => { const onChange = jest.fn(); await mountTextInput({ value: "hello", onChange }); fixture.querySelector("input")!.focus(); setInputValueAndTrigger(fixture.querySelector("input")!, "world"); await keyDown({ key: "Enter" }); + expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith("world"); }); diff --git a/tests/composer/__snapshots__/autocomplete_dropdown_component.test.ts.snap b/tests/composer/__snapshots__/autocomplete_dropdown_component.test.ts.snap index 7e5079034a..c70cd5a87b 100644 --- a/tests/composer/__snapshots__/autocomplete_dropdown_component.test.ts.snap +++ b/tests/composer/__snapshots__/autocomplete_dropdown_component.test.ts.snap @@ -8,20 +8,25 @@ exports[`Functions autocomplete autocomplete simple snapshot with =S 1`] = ` class="d-flex flex-column text-start o-autocomplete-value-focus" >
- - S - - - UM - +
+ + S + + + UM + + +
- - S - - - ZZ - +
+ + S + + + ZZ + + +
@@ -69,20 +79,25 @@ exports[`composer Assistant render above the cell when not enough place below 1` class="d-flex flex-column text-start o-autocomplete-value-focus" >
- - S - - - UM - +
+ + S + + + UM + + +
- - S - - - ZZ - +
+ + S + + + ZZ + + +
@@ -132,20 +152,25 @@ exports[`composer Assistant render below the cell by default 1`] = ` class="d-flex flex-column text-start o-autocomplete-value-focus" >
- - S - - - UM - +
+ + S + + + UM + + +
- - S - - - ZZ - +
+ + S + + + ZZ + + +
diff --git a/tests/composer/auto_complete/function_auto_complete_store.test.ts b/tests/composer/auto_complete/function_auto_complete_store.test.ts index 8293c1ec27..643157e772 100644 --- a/tests/composer/auto_complete/function_auto_complete_store.test.ts +++ b/tests/composer/auto_complete/function_auto_complete_store.test.ts @@ -1,5 +1,5 @@ import { CellComposerStore } from "../../../src/components/composer/composer/cell_composer_store"; -import { selectCell, setCellContent } from "../../test_helpers/commands_helpers"; +import { createNamedRange, selectCell, setCellContent } from "../../test_helpers/commands_helpers"; import { nextTick } from "../../test_helpers/helpers"; import { makeStore } from "../../test_helpers/stores"; @@ -13,7 +13,7 @@ describe("Function auto complete", () => { expect(proposals).toHaveLength(10); expect(proposals?.[0].text).toEqual("SUM"); expect(proposals?.[1].text).toEqual("SUMIF"); - composer.insertAutoCompleteValue(proposals![0].text); + composer.insertAutoCompleteValue(proposals![0]); expect(composer.currentContent).toEqual("=SUM("); }); @@ -25,7 +25,7 @@ describe("Function auto complete", () => { const proposals = composer.autoCompleteProposals; expect(proposals).toHaveLength(1); expect(proposals?.[0].text).toBe("VLOOKUP"); - composer.insertAutoCompleteValue(proposals![0].text); + composer.insertAutoCompleteValue(proposals![0]); expect(composer.currentContent).toEqual("=VLOOKUP("); }); @@ -38,7 +38,80 @@ describe("Function auto complete", () => { const proposals = composer.autoCompleteProposals; expect(proposals).toHaveLength(1); expect(proposals?.[0].text).toBe("VLOOKUP"); - composer.insertAutoCompleteValue(proposals![0].text); + composer.insertAutoCompleteValue(proposals![0]); expect(composer.currentContent).toEqual("=VLOOKUP("); }); + + test("Function autocomplete also shows named ranges", async () => { + const { store: composer, model } = makeStore(CellComposerStore); + createNamedRange(model, "VLookupNamedRange", "B1:B10"); + setCellContent(model, "A1", "=VLOOKUP"); + composer.startEdition(); + await nextTick(); + + const proposals = composer.autoCompleteProposals; + expect(proposals).toHaveLength(2); + expect(proposals[0].text).toBe("VLOOKUP"); + expect(proposals[1].text).toBe("VLookupNamedRange"); + + composer.insertAutoCompleteValue(proposals[1]); + expect(composer.currentContent).toBe("=VLookupNamedRange"); // No parentheses for named ranges + }); + + test("Autocomplete works if the named range has the same name as a formula", async () => { + const { store: composer, model } = makeStore(CellComposerStore); + createNamedRange(model, "VLOOKUP", "B1:B10"); + setCellContent(model, "A1", "=VLOOK"); + composer.startEdition(); + await nextTick(); + + const proposals = composer.autoCompleteProposals; + expect(proposals).toHaveLength(2); + expect(proposals?.[0].text).toBe("VLOOKUP"); + expect(proposals?.[1].text).toBe("VLOOKUP"); + + // Insert the formula + composer.insertAutoCompleteValue(proposals[0]); + expect(composer.currentContent).toBe("=VLOOKUP("); + + // Restart edition + setCellContent(model, "A1", "=VLOOK"); + composer.startEdition(); + await nextTick(); + + // Insert the named range + composer.insertAutoCompleteValue(composer.autoCompleteProposals[1]); + expect(composer.currentContent).toBe("=VLOOKUP"); // No parentheses for named ranges + }); + + test("Autocomplete is closed after selecting a named range from an empty content", async () => { + const { store: composer, model } = makeStore(CellComposerStore); + createNamedRange(model, "MyRange", "B1:B10"); + + composer.startEdition("=MyRa"); + await nextTick(); + expect(composer.autoCompleteProposals).toHaveLength(1); + + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); + await nextTick(); + expect(composer.currentContent).toBe("=MyRange"); + expect(composer.autoCompleteProposals).toHaveLength(0); + expect(composer.editionMode).toBe("editing"); + }); + + test("Autocomplete is closed after selecting a named range from a content that already has the named range", async () => { + const { store: composer, model } = makeStore(CellComposerStore); + createNamedRange(model, "MyRange", "B1:B10"); + setCellContent(model, "A1", "=MyRange"); + + composer.startEdition(); + await nextTick(); + expect(composer.autoCompleteProposals).toHaveLength(1); + + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); + await nextTick(); + expect(composer.currentContent).toBe("=MyRange"); + expect(composer.autoCompleteProposals).toHaveLength(0); + expect(composer.editionMode).toBe("editing"); + }); }); diff --git a/tests/composer/auto_complete/pivot_auto_complete_store.test.ts b/tests/composer/auto_complete/pivot_auto_complete_store.test.ts index 9e4e9eaf6c..7455ef8937 100644 --- a/tests/composer/auto_complete/pivot_auto_complete_store.test.ts +++ b/tests/composer/auto_complete/pivot_auto_complete_store.test.ts @@ -43,7 +43,7 @@ describe("spreadsheet pivot auto complete", () => { alwaysExpanded: true, }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe(`=${func}(1`); // range selection stops @@ -103,7 +103,7 @@ describe("spreadsheet pivot auto complete", () => { text: '"__count"', }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe('=PIVOT.VALUE(1,"Expected Revenue:sum"'); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -156,7 +156,7 @@ describe("spreadsheet pivot auto complete", () => { text: '"Stage"', }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe('=PIVOT.VALUE(1,"Expected Revenue","Stage"'); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -193,7 +193,7 @@ describe("spreadsheet pivot auto complete", () => { text: '"Stage"', }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe('=PIVOT.VALUE(1,"Expected Revenue","Stage"'); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -217,7 +217,7 @@ describe("spreadsheet pivot auto complete", () => { text: '"Created on:day"', }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe('=PIVOT.VALUE(1,"Expected Revenue","Created on:day"'); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -324,7 +324,7 @@ describe("spreadsheet pivot auto complete", () => { text: '"Won"', }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe('=PIVOT.VALUE(1,"Expected Revenue","Stage","New"'); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -434,7 +434,7 @@ describe("spreadsheet pivot auto complete", () => { text: "12", }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe( '=PIVOT.VALUE(1,"Expected Revenue","Created on:month_number",1' @@ -478,7 +478,7 @@ describe("spreadsheet pivot auto complete", () => { text: "4", }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe( '=PIVOT.VALUE(1,"Expected Revenue","Created on:quarter_number",1' @@ -509,7 +509,7 @@ describe("spreadsheet pivot auto complete", () => { htmlContent: [{ color: "#02c39a", value: "31" }], text: "31", }); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe( '=PIVOT.VALUE(1,"Expected Revenue","Created on:day_of_month",1' @@ -540,7 +540,7 @@ describe("spreadsheet pivot auto complete", () => { htmlContent: [{ color: "#02c39a", value: "53" }], text: "53", }); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe( '=PIVOT.VALUE(1,"Expected Revenue","Created on:iso_week_number",0' @@ -571,7 +571,7 @@ describe("spreadsheet pivot auto complete", () => { htmlContent: [{ color: "#02c39a", value: "7" }], text: "7", }); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe( '=PIVOT.VALUE(1,"Expected Revenue","Created on:day_of_week",1' @@ -602,7 +602,7 @@ describe("spreadsheet pivot auto complete", () => { htmlContent: [{ color: "#02c39a", value: "23" }], text: "23", }); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe( '=PIVOT.VALUE(1,"Expected Revenue","Created on:hour_number",0' @@ -633,7 +633,7 @@ describe("spreadsheet pivot auto complete", () => { htmlContent: [{ color: "#02c39a", value: "59" }], text: "59", }); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe( '=PIVOT.VALUE(1,"Expected Revenue","Created on:minute_number",0' @@ -664,7 +664,7 @@ describe("spreadsheet pivot auto complete", () => { htmlContent: [{ color: "#02c39a", value: "59" }], text: "59", }); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe( '=PIVOT.VALUE(1,"Expected Revenue","Created on:second_number",0' @@ -709,7 +709,7 @@ describe("spreadsheet pivot auto complete", () => { composer.startEdition("=PIVOT.HEADER(1,"); await nextTick(); expect(composer.autoCompleteProposals.map((p) => p.text)).toEqual(['"Stage"']); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe('=PIVOT.HEADER(1,"Stage"'); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -726,7 +726,7 @@ describe("spreadsheet pivot auto complete", () => { composer.startEdition('=PIVOT.HEADER(1,"sta'); await nextTick(); expect(composer.autoCompleteProposals.map((p) => p.text)).toEqual(['"Stage"']); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe('=PIVOT.HEADER(1,"Stage"'); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -743,7 +743,7 @@ describe("spreadsheet pivot auto complete", () => { composer.startEdition('=PIVOT.HEADER(1,"Stage",'); await nextTick(); expect(composer.autoCompleteProposals.map((p) => p.text)).toEqual(['"New"', '"Won"']); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe('=PIVOT.HEADER(1,"Stage","New"'); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -784,7 +784,7 @@ describe("spreadsheet pivot auto complete", () => { htmlContent: [{ color: PIVOT_TOKEN_COLOR, value: "'Expected Revenue:sum'" }], }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe("=1+'Expected Revenue:sum'"); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -817,7 +817,7 @@ describe("spreadsheet pivot auto complete", () => { htmlContent: [{ color: PIVOT_TOKEN_COLOR, value: "Stage" }], }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe("=1+Stage"); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -850,7 +850,7 @@ describe("spreadsheet pivot auto complete", () => { htmlContent: [{ color: PIVOT_TOKEN_COLOR, value: "Stage" }], }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe("=Stage"); expect(composer.isAutoCompleteDisplayed).toBe(false); @@ -884,7 +884,7 @@ describe("spreadsheet pivot auto complete", () => { htmlContent: [{ color: PIVOT_TOKEN_COLOR, value: "Stage" }], }, ]); - composer.insertAutoCompleteValue(composer.autoCompleteProposals[0].text); + composer.insertAutoCompleteValue(composer.autoCompleteProposals[0]); await nextTick(); expect(composer.currentContent).toBe("=Stage"); expect(composer.isAutoCompleteDisplayed).toBe(false); diff --git a/tests/composer/auto_complete/sheet_name_auto_complete_store.test.ts b/tests/composer/auto_complete/sheet_name_auto_complete_store.test.ts index 52a8c102cc..03a5811f93 100644 --- a/tests/composer/auto_complete/sheet_name_auto_complete_store.test.ts +++ b/tests/composer/auto_complete/sheet_name_auto_complete_store.test.ts @@ -16,7 +16,7 @@ describe("Sheet name auto complete", () => { fuzzySearchKey: "'MySheet", }, ]); - composer.insertAutoCompleteValue(proposals![0].text); + composer.insertAutoCompleteValue(proposals![0]); expect(composer.currentContent).toEqual("=MySheet!"); }); @@ -32,7 +32,7 @@ describe("Sheet name auto complete", () => { fuzzySearchKey: "'My awesome sheet'", }, ]); - composer.insertAutoCompleteValue(proposals![0].text); + composer.insertAutoCompleteValue(proposals![0]); expect(composer.currentContent).toEqual("='My awesome sheet'!"); }); @@ -55,7 +55,7 @@ describe("Sheet name auto complete", () => { await nextTick(); const proposals = composer.autoCompleteProposals; expect(proposals![0].text).toEqual("Hello"); - composer.insertAutoCompleteValue(proposals![0].text); + composer.insertAutoCompleteValue(proposals![0]); expect(composer.currentContent).toEqual("=Hello!"); }); diff --git a/tests/composer/autocomplete_dropdown_component.test.ts b/tests/composer/autocomplete_dropdown_component.test.ts index 1a04677d04..4c99860e2d 100644 --- a/tests/composer/autocomplete_dropdown_component.test.ts +++ b/tests/composer/autocomplete_dropdown_component.test.ts @@ -280,7 +280,7 @@ describe("Functions autocomplete", () => { }); await typeInComposer("=SUM("); const proposals = [...fixture.querySelectorAll(".o-autocomplete-value")].map( - (el) => el.parentElement + (el) => el.parentElement?.parentElement ); expect(composerStore.autoCompleteProposals).toHaveLength(2); @@ -390,10 +390,28 @@ describe("Functions autocomplete", () => { }); await typeInComposer("=SUM("); const proposals = [...fixture.querySelectorAll(".o-autocomplete-value")].map( - (el) => el.parentElement?.textContent + (el) => el.parentElement?.parentElement?.textContent ); expect(proposals).toEqual(["option 1 descr1", "option 2 descr1", "option 3"]); }); + + test("can add icons to autocomplete proposals", async () => { + addToRegistry(registries.autoCompleteProviders, "test", { + getProposals: () => [ + { text: "option 1", icon: "o-spreadsheet-Icon.ARROW_DOWN" }, + { text: "option 2", icon: "o-spreadsheet-Icon.ARROW_UP" }, + { text: "option 3", icon: "o-spreadsheet-Icon.ARROW_RIGHT" }, + ], + selectProposal() {}, + }); + await typeInComposer("=SUM("); + const icons = [...fixture.querySelectorAll(".o-autocomplete-value")].map((el) => + el.parentElement?.querySelector(".o-icon") + ); + expect(icons[0]).toHaveClass("arrow-down"); + expect(icons[1]).toHaveClass("arrow-up"); + expect(icons[2]).toHaveClass("arrow-right"); + }); }); describe("autocomplete functions SUM IF", () => { diff --git a/tests/composer/composer_component.test.ts b/tests/composer/composer_component.test.ts index 8bca1f2cdb..e714be4dce 100644 --- a/tests/composer/composer_component.test.ts +++ b/tests/composer/composer_component.test.ts @@ -5,6 +5,7 @@ import { colors, toCartesian, toZone } from "../../src/helpers/index"; import { Store } from "../../src/store_engine"; import { MockClipboardData, getClipboardEvent } from "../test_helpers/clipboard"; import { + createNamedRange, createSheet, createSheetWithName, merge, @@ -1623,6 +1624,19 @@ describe("composer highlights color", () => { expect(highlights[1].range.zone).toEqual({ left: 0, right: 0, top: 0, bottom: 0 }); }); + test("named ranges are highlighted but not interactive", async () => { + createNamedRange(model, "MyRange", "A1:B2"); + createNamedRange(model, "MyRange2", "A1:A2"); + setCellContent(model, "A1", "=MyRange + B2 + MyRange2"); + await startComposition(); + expect(composerStore.highlights.length).toBe(3); + expect(composerStore.highlights).toMatchObject([ + { range: { zone: toZone("A1:B2") }, color: colors[0], interactive: false }, + { range: { zone: toZone("B2") }, color: colors[1], interactive: true }, + { range: { zone: toZone("A1:A2") }, color: colors[2], interactive: false }, + ]); + }); + test.skip("grid composer is resized when top bar composer grows", async () => {}); }); diff --git a/tests/evaluation/__snapshots__/compiler.test.ts.snap b/tests/evaluation/__snapshots__/compiler.test.ts.snap index 82878a0da8..41d1f06660 100644 --- a/tests/evaluation/__snapshots__/compiler.test.ts.snap +++ b/tests/evaluation/__snapshots__/compiler.test.ts.snap @@ -4,8 +4,8 @@ exports[`compile functions same symbol twice 1`] = ` "function anonymous(deps,ref,range,getSymbolValue,ctx ) { // =Hello+Hello -const _1 = getSymbolValue(this.symbols[0]); -const _2 = getSymbolValue(this.symbols[0]); +const _1 = getSymbolValue(this.symbols[0], false, false, ref, range, ctx); +const _2 = getSymbolValue(this.symbols[0], false, false, ref, range, ctx); return ctx['ADD'](_1, _2); }" `; @@ -14,7 +14,7 @@ exports[`compile functions simple in a function 1`] = ` "function anonymous(deps,ref,range,getSymbolValue,ctx ) { // =SUM(Hello) -const _1 = getSymbolValue(this.symbols[1]); +const _1 = getSymbolValue(this.symbols[1], true, false, ref, range, ctx); return ctx['SUM'](_1); }" `; @@ -23,7 +23,7 @@ exports[`compile functions simple symbol 1`] = ` "function anonymous(deps,ref,range,getSymbolValue,ctx ) { // =Hello -return getSymbolValue(this.symbols[0]); +return getSymbolValue(this.symbols[0], false, false, ref, range, ctx); }" `; @@ -31,7 +31,7 @@ exports[`compile functions symbol with optional single quotes 1`] = ` "function anonymous(deps,ref,range,getSymbolValue,ctx ) { // ='Hello' -return getSymbolValue(this.symbols[0]); +return getSymbolValue(this.symbols[0], false, false, ref, range, ctx); }" `; @@ -39,7 +39,7 @@ exports[`compile functions symbol with space and with single quotes 1`] = ` "function anonymous(deps,ref,range,getSymbolValue,ctx ) { // ='Hello world' -return getSymbolValue(this.symbols[0]); +return getSymbolValue(this.symbols[0], false, false, ref, range, ctx); }" `; @@ -47,8 +47,8 @@ exports[`compile functions two different symbols 1`] = ` "function anonymous(deps,ref,range,getSymbolValue,ctx ) { // =Hello+world -const _1 = getSymbolValue(this.symbols[0]); -const _2 = getSymbolValue(this.symbols[1]); +const _1 = getSymbolValue(this.symbols[0], false, false, ref, range, ctx); +const _2 = getSymbolValue(this.symbols[1], false, false, ref, range, ctx); return ctx['ADD'](_1, _2); }" `; diff --git a/tests/model/model_import_export.test.ts b/tests/model/model_import_export.test.ts index b4ae127bf2..c3655b6502 100644 --- a/tests/model/model_import_export.test.ts +++ b/tests/model/model_import_export.test.ts @@ -1043,6 +1043,7 @@ test("complete import, then export", () => { }, }, uniqueFigureIds: true, + namedRanges: [], }; const model = new Model(modelData); expect(model).toExport(modelData); @@ -1131,6 +1132,7 @@ test("import then export (figures)", () => { uniqueFigureIds: true, settings: { locale: DEFAULT_LOCALE }, customTableStyles: {}, + namedRanges: [], }; const model = new Model(modelData); expect(model).toExport(modelData); diff --git a/tests/named_ranges/named_ranges_panel_component.test.ts b/tests/named_ranges/named_ranges_panel_component.test.ts new file mode 100644 index 0000000000..44d1235ff3 --- /dev/null +++ b/tests/named_ranges/named_ranges_panel_component.test.ts @@ -0,0 +1,118 @@ +import { Model } from "@odoo/o-spreadsheet-engine"; +import { toZone } from "@odoo/o-spreadsheet-engine/helpers/zones"; +import { SpreadsheetChildEnv } from "@odoo/o-spreadsheet-engine/types/spreadsheet_env"; +import { NamedRangesPanel } from "../../src/components/side_panel/named_ranges_panel/named_ranges_panel"; +import { + createNamedRange, + setInputValueAndTrigger, + setSelection, + simulateClick, + triggerMouseEvent, +} from "../test_helpers"; +import { getHighlightsFromStore, mountComponent, nextTick } from "../test_helpers/helpers"; + +let model: Model; +let raiseError: jest.Mock; +let env: SpreadsheetChildEnv; + +beforeEach(() => { + model = new Model(); +}); + +async function mountNamedRangesPanel() { + raiseError = jest.fn(); + ({ model, env } = await mountComponent(NamedRangesPanel, { + props: { onCloseSidePanel: () => {} }, + model, + env: { raiseError }, + })); +} + +describe("Named ranges side panel", () => { + test("Can create a named range from the side panel", async () => { + await mountNamedRangesPanel(); + setSelection(model, ["A1:B2"]); + + expect(".o-named-range-preview").toHaveCount(0); + expect(model.getters.getNamedRanges()).toHaveLength(0); + await simulateClick(".o-named-range-add"); + + expect(".o-named-range-preview").toHaveCount(1); + expect(model.getters.getNamedRanges()).toHaveLength(1); + + expect(model.getters.getNamedRanges()[0]).toMatchObject({ + rangeName: "NamedRange", + range: { zone: toZone("A1:B2") }, + }); + expect(".o-named-range-preview .os-input").toHaveValue("NamedRange"); + expect(".o-named-range-preview .o-selection-input input").toHaveValue("A1:B2"); + }); + + test("Can edit a named range from the side panel", async () => { + createNamedRange(model, "MyRange", "A1"); + await mountNamedRangesPanel(); + + await setInputValueAndTrigger(".o-named-range-preview .os-input", "RenamedRange"); + await setInputValueAndTrigger(".o-named-range-preview .o-selection-input input", "C3:D4"); + await simulateClick(".o-selection-ok"); + + expect(model.getters.getNamedRanges()[0]).toMatchObject({ + rangeName: "RenamedRange", + range: { zone: toZone("C3:D4") }, + }); + }); + + test("Cannot edit a named range to an invalid name", async () => { + createNamedRange(model, "MyRange", "A1"); + await mountNamedRangesPanel(); + + await setInputValueAndTrigger(".o-named-range-preview .os-input", "Invalid Name!"); + await nextTick(); + + expect(raiseError).toHaveBeenCalledTimes(1); + expect(raiseError).toHaveBeenCalledWith( + "The named range name contains invalid characters. Valid characters are letters, numbers, underscores, and periods." + ); + expect(model.getters.getNamedRanges()[0].rangeName).toBe("MyRange"); + expect(".o-named-range-preview .os-input").toHaveValue("MyRange"); + }); + + test("New named ranges are created with unique names", async () => { + createNamedRange(model, "NamedRange", "A1"); + await mountNamedRangesPanel(); + + await simulateClick(".o-named-range-add"); + await simulateClick(".o-named-range-add"); + + expect(model.getters.getNamedRanges()).toMatchObject([ + { rangeName: "NamedRange" }, + { rangeName: "NamedRange1" }, + { rangeName: "NamedRange2" }, + ]); + }); + + test("Hovering a named range highlights it", async () => { + createNamedRange(model, "NamedRange", "A1"); + await mountNamedRangesPanel(); + + triggerMouseEvent(".o-named-range-preview", "mouseenter"); + await nextTick(); + expect(getHighlightsFromStore(env)).toMatchObject([{ range: { zone: toZone("A1") } }]); + }); + + test("Named range highlight is disabled when using the selection input", async () => { + createNamedRange(model, "NamedRange", "A1"); + await mountNamedRangesPanel(); + + triggerMouseEvent(".o-named-range-preview", "mouseenter"); + await nextTick(); + expect(".o-named-range-preview").not.toHaveClass("o-focused"); + expect(getHighlightsFromStore(env)).toMatchObject([{ range: { zone: toZone("A1") } }]); + + await simulateClick(".o-named-range-preview .o-selection-input input"); + await setInputValueAndTrigger(".o-named-range-preview .o-selection-input input", "C3:D4"); + expect(".o-named-range-preview").toHaveClass("o-focused"); + expect(getHighlightsFromStore(env)).toHaveLength(1); + expect(getHighlightsFromStore(env)).toMatchObject([{ range: { zone: toZone("C3:D4") } }]); + }); +}); diff --git a/tests/named_ranges/named_ranges_plugin.test.ts b/tests/named_ranges/named_ranges_plugin.test.ts new file mode 100644 index 0000000000..7d04efc85e --- /dev/null +++ b/tests/named_ranges/named_ranges_plugin.test.ts @@ -0,0 +1,285 @@ +import { CellErrorType, Model } from "@odoo/o-spreadsheet-engine"; +import { toZone } from "@odoo/o-spreadsheet-engine/helpers/zones"; +import { CommandResult } from "../../src"; +import { + createNamedRange, + createSheet, + deleteNamedRange, + getCell, + getEvaluatedCell, + redo, + setCellContent, + undo, + updateNamedRange, +} from "../test_helpers"; + +let model: Model; + +beforeEach(() => { + model = new Model(); +}); + +describe("Named range plugin", () => { + describe("Command results", () => { + test("Cannot create a named range with an already existing name", () => { + expect(createNamedRange(model, "MyRange", "A1")).toBeSuccessfullyDispatched(); + expect(createNamedRange(model, "MyRange", "A1")).toBeCancelledBecause( + CommandResult.NamedRangeNameAlreadyExists + ); + }); + + test("Cannot create a named range with an invalid name", () => { + expect(createNamedRange(model, "AB12", "A1")).toBeCancelledBecause( + CommandResult.NamedRangeNameLooksLikeCellReference + ); + expect(createNamedRange(model, "Invalid Name", "A1")).toBeCancelledBecause( + CommandResult.NamedRangeNameWithInvalidCharacter + ); + expect(createNamedRange(model, "InvalidName!", "A1")).toBeCancelledBecause( + CommandResult.NamedRangeNameWithInvalidCharacter + ); + expect(createNamedRange(model, "122", "A1")).toBeCancelledBecause( + CommandResult.NamedRangeNameWithInvalidCharacter + ); + }); + + test("Cannot update a named range that does not exist", () => { + expect(updateNamedRange(model, "NonExistentRange", "NewName", "C3:D4")).toBeCancelledBecause( + CommandResult.NamedRangeNotFound + ); + }); + + test("Cannot update a named range to an already existing or invalid name", () => { + createNamedRange(model, "RangeOne", "A1"); + createNamedRange(model, "RangeTwo", "A1"); + + expect(updateNamedRange(model, "RangeTwo", "RangeOne", "C3:D4")).toBeCancelledBecause( + CommandResult.NamedRangeNameAlreadyExists + ); + expect(updateNamedRange(model, "RangeTwo", "Invalid Name", "C3:D4")).toBeCancelledBecause( + CommandResult.NamedRangeNameWithInvalidCharacter + ); + }); + + test("Cannot delete a named range that does not exist", () => { + createNamedRange(model, "RangeOne", "A1"); + expect(deleteNamedRange(model, "RangeOne")).toBeSuccessfullyDispatched(); + expect(deleteNamedRange(model, "RangeOne")).toBeCancelledBecause( + CommandResult.NamedRangeNotFound + ); + }); + }); + + test("Can create, update, and delete a named range", () => { + createNamedRange(model, "MyRange", "A1:B2"); + expect(model.getters.getNamedRanges()[0]).toMatchObject({ + rangeName: "MyRange", + range: { zone: toZone("A1:B2") }, + }); + + updateNamedRange(model, "MyRange", "RenamedRange", "C3:D4"); + expect(model.getters.getNamedRanges()[0]).toMatchObject({ + rangeName: "RenamedRange", + range: { zone: toZone("C3:D4") }, + }); + + deleteNamedRange(model, "RenamedRange"); + expect(model.getters.getNamedRanges()).toHaveLength(0); + }); + + test("Updating a named range keep the order of the ranges", () => { + createNamedRange(model, "FirstRange", "A1"); + createNamedRange(model, "SecondRange", "B2"); + createNamedRange(model, "ThirdRange", "C3"); + + updateNamedRange(model, "SecondRange", "UpdatedSecondRange", "D4"); + + expect(model.getters.getNamedRanges()).toMatchObject([ + { rangeName: "FirstRange" }, + { rangeName: "UpdatedSecondRange" }, + { rangeName: "ThirdRange" }, + ]); + }); + + test("Renaming a named range changes the formulas with this ranges", () => { + createNamedRange(model, "FirstRange", "A1"); + createNamedRange(model, "SecondRange", "B2"); + createNamedRange(model, "SUM", "C3"); + + setCellContent(model, "A1", "=FirstRange + SecondRange"); + setCellContent(model, "A2", "=SECONDRange * 2"); + setCellContent(model, "A3", "SecondRange"); + setCellContent(model, "A4", '="SecondRange"'); + + updateNamedRange(model, "SecondRange", "HelloThere", "A1"); + expect(getCell(model, "A1")?.content).toEqual("=FirstRange + HelloThere"); + expect(getCell(model, "A2")?.content).toEqual("=HelloThere * 2"); + expect(getCell(model, "A3")?.content).toEqual("SecondRange"); + expect(getCell(model, "A4")?.content).toEqual('="SecondRange"'); + + createNamedRange(model, "SUM", "C3"); + setCellContent(model, "A5", "=SUM(25)"); + setCellContent(model, "A6", "=SUM + 10"); + + updateNamedRange(model, "SUM", "SUMMIT", "A1"); + expect(getCell(model, "A5")?.content).toEqual("=SUM(25)"); // SUM functions should not be changed + expect(getCell(model, "A6")?.content).toEqual("=SUMMIT + 10"); + }); + + test("Named ranges are case insensitive", () => { + createNamedRange(model, "FirstRange", "A1"); + expect(createNamedRange(model, "firstrange", "B2")).toBeCancelledBecause( + CommandResult.NamedRangeNameAlreadyExists + ); + + expect(model.getters.getNamedRange("FIRSTRANGE")).toMatchObject({ rangeName: "FirstRange" }); + + setCellContent(model, "A1", "10"); + setCellContent(model, "B1", "=FiRstRaNgE"); + expect(getEvaluatedCell(model, "B1").value).toBe(10); + }); + + test("Can undo/redo named ranges commands", () => { + createNamedRange(model, "MyRange", "A1:B2"); + expect(model.getters.getNamedRanges()[0].rangeName).toBe("MyRange"); + + undo(model); + expect(model.getters.getNamedRanges()).toHaveLength(0); + + redo(model); + expect(model.getters.getNamedRanges()[0].rangeName).toBe("MyRange"); + + updateNamedRange(model, "MyRange", "RenamedRange", "C3:D4"); + expect(model.getters.getNamedRanges()[0].rangeName).toBe("RenamedRange"); + + undo(model); + expect(model.getters.getNamedRanges()[0].rangeName).toBe("MyRange"); + + redo(model); + expect(model.getters.getNamedRanges()[0].rangeName).toBe("RenamedRange"); + + deleteNamedRange(model, "RenamedRange"); + expect(model.getters.getNamedRanges()).toHaveLength(0); + + undo(model); + expect(model.getters.getNamedRanges()[0].rangeName).toBe("RenamedRange"); + + redo(model); + expect(model.getters.getNamedRanges()).toHaveLength(0); + }); + + test("Can export/import named ranges", () => { + createNamedRange(model, "MyRange", "A1:B2"); + createSheet(model, { sheetId: "sh2" }); + createNamedRange(model, "AnotherRange", "C3:D4", "sh2"); + + const exportedData = model.exportData(); + expect(exportedData.namedRanges).toEqual([ + { + rangeName: "MyRange", + zone: toZone("A1:B2"), + sheetId: model.getters.getActiveSheetId(), + }, + { + rangeName: "AnotherRange", + zone: toZone("C3:D4"), + sheetId: "sh2", + }, + ]); + + const newModel = new Model(exportedData); + expect(newModel.getters.getNamedRanges()).toHaveLength(2); + expect(newModel.getters.getNamedRanges()[0]).toMatchObject({ + rangeName: "MyRange", + range: { zone: toZone("A1:B2"), sheetId: newModel.getters.getActiveSheetId() }, + }); + expect(newModel.getters.getNamedRanges()[1]).toMatchObject({ + rangeName: "AnotherRange", + range: { zone: toZone("C3:D4"), sheetId: "sh2" }, + }); + }); + + test("Can use named ranges in formulas", () => { + createNamedRange(model, "MyRange", "A1:A3"); + setCellContent(model, "B1", "=SUM(MyRange)"); + setCellContent(model, "A1", "10"); + setCellContent(model, "A2", "20"); + + expect(getEvaluatedCell(model, "B1").value).toBe(30); + + setCellContent(model, "C1", "=MyRange + 5"); + expect(getEvaluatedCell(model, "C1").value).toBe(15); + expect(getEvaluatedCell(model, "C2").value).toBe(25); + expect(getEvaluatedCell(model, "C3").value).toBe(5); + }); + + test("Can use unbounded zones in named ranges", () => { + createSheet(model, { sheetId: "sh2", rows: 3 }); + createNamedRange(model, "MyRange", "A:A", "sh2"); + + const namedRange = model.getters.getNamedRanges()[0]; + expect(model.getters.getRangeString(namedRange.range)).toEqual("Sheet2!A:A"); + + setCellContent(model, "A1", "5", "sh2"); + setCellContent(model, "A1", "=MyRange + 10"); + + expect(getEvaluatedCell(model, "A1").value).toBe(15); + expect(getEvaluatedCell(model, "A2").value).toBe(10); + expect(getEvaluatedCell(model, "A3").value).toBe(10); + expect(getEvaluatedCell(model, "A4").value).toBe(null); + }); + + test("Creating/updating/deleting a named range re-evaluate cells", () => { + setCellContent(model, "A1", "10"); + setCellContent(model, "B1", "=SUM(MyRange)"); + setCellContent(model, "C1", "=SUM(MyRange2)"); + expect(getEvaluatedCell(model, "B1").value).toBe(CellErrorType.BadExpression); + + createNamedRange(model, "MyRange", "A1:A2"); + expect(getEvaluatedCell(model, "B1").value).toBe(10); + expect(getEvaluatedCell(model, "C1").value).toBe(CellErrorType.BadExpression); + + updateNamedRange(model, "MyRange", "MyRange2", "A1:A3"); + expect(getEvaluatedCell(model, "B1").value).toBe(10); + expect(getEvaluatedCell(model, "C1").value).toBe(10); + + deleteNamedRange(model, "MyRange2"); + expect(getEvaluatedCell(model, "B1").value).toBe(CellErrorType.BadExpression); + expect(getEvaluatedCell(model, "C1").value).toBe(CellErrorType.BadExpression); + }); + + test("Updating the values in a named range re-evaluates formulas with this named range", () => { + setCellContent(model, "A1", "10"); + createNamedRange(model, "MyRange", "A1:A2"); + setCellContent(model, "B1", "=SUM(MyRange)"); + + expect(getEvaluatedCell(model, "B1").value).toBe(10); + + setCellContent(model, "A2", "20"); + expect(getEvaluatedCell(model, "B1").value).toBe(30); + }); + + test("Evaluation cycles are detected in named ranges", () => { + createNamedRange(model, "MyRange", "A1:A2"); + setCellContent(model, "A1", "=SUM(MyRange)"); + expect(getEvaluatedCell(model, "A1").value).toBe(CellErrorType.CircularDependency); + }); + + test("Can use named range in getter evaluateFormula", () => { + createNamedRange(model, "MyRange", "A1:A2"); + setCellContent(model, "A1", "15"); + const sheetId = model.getters.getActiveSheetId(); + expect(model.getters.evaluateFormula(sheetId, "=SUM(MyRange) + 5")).toBe(20); + }); + + test("Named ranges works both for single cell and multiple cell ranges", () => { + const sheetId = model.getters.getActiveSheetId(); + setCellContent(model, "A1", "42"); + + createNamedRange(model, "SingleCellRange", "A1"); + expect(model.getters.evaluateFormula(sheetId, "=SingleCellRange")).toEqual(42); + + createNamedRange(model, "MultiCellRange", "A1:A2"); + expect(model.getters.evaluateFormula(sheetId, "=MultiCellRange")).toEqual([[42, 0]]); + }); +}); diff --git a/tests/named_ranges/named_ranges_selector_component.test.ts b/tests/named_ranges/named_ranges_selector_component.test.ts new file mode 100644 index 0000000000..8d5ec20987 --- /dev/null +++ b/tests/named_ranges/named_ranges_selector_component.test.ts @@ -0,0 +1,151 @@ +import { Model } from "@odoo/o-spreadsheet-engine"; +import { HIGHLIGHT_COLOR } from "@odoo/o-spreadsheet-engine/constants"; +import { toZone } from "@odoo/o-spreadsheet-engine/helpers/zones"; +import { SpreadsheetChildEnv } from "@odoo/o-spreadsheet-engine/types/spreadsheet_env"; +import { NamedRangeSelector } from "../../src/components/named_range_selector/named_range_selector"; +import { HighlightStore } from "../../src/stores/highlight_store"; +import { + createNamedRange, + createSheet, + setInputValueAndTrigger, + setSelection, + simulateClick, + triggerMouseEvent, +} from "../test_helpers"; +import { mountComponentWithPortalTarget, nextTick } from "../test_helpers/helpers"; + +let model: Model; +let env: SpreadsheetChildEnv; +let raiseError: jest.Mock; +let openSidePanel: jest.Mock; + +beforeEach(() => { + model = new Model(); +}); + +async function mountRangeSelector() { + raiseError = jest.fn(); + openSidePanel = jest.fn(); + ({ model, env } = await mountComponentWithPortalTarget(NamedRangeSelector, { + model, + env: { raiseError, openSidePanel }, + })); +} + +describe("Named ranges topbar selector", () => { + test("Can create a named range from range selector", async () => { + await mountRangeSelector(); + expect(".o-named-range-selector input").toHaveValue("A1"); + + setSelection(model, ["A1:B2"]); + await nextTick(); + expect(".o-named-range-selector input").toHaveValue("A1:B2"); + + await setInputValueAndTrigger(".o-named-range-selector input", "MyRange"); + expect(model.getters.getNamedRanges()).toHaveLength(1); + expect(model.getters.getNamedRanges()[0]).toMatchObject({ + rangeName: "MyRange", + range: { zone: toZone("A1:B2") }, + }); + }); + + test("Can edit a named range from range selector", async () => { + createNamedRange(model, "MyRange", "A1:B2"); + await mountRangeSelector(); + setSelection(model, ["A1:B2"]); + await setInputValueAndTrigger(".o-named-range-selector input", "MyRange"); + + await setInputValueAndTrigger(".o-named-range-selector input", "RenamedRange"); + expect(model.getters.getNamedRanges()[0]).toMatchObject({ + rangeName: "RenamedRange", + range: { zone: toZone("A1:B2") }, + }); + }); + + test("Cannot create a named range with an invalid name", async () => { + await mountRangeSelector(); + await setInputValueAndTrigger(".o-named-range-selector input", "Wrong name !"); + expect(raiseError).toHaveBeenCalledWith( + "The named range name contains invalid characters. Valid characters are letters, numbers, underscores, and periods." + ); + expect(model.getters.getNamedRanges()).toHaveLength(0); + }); + + test("Entering a reference in the named range selector will select this reference", async () => { + createSheet(model, { name: "Sheet2", sheetId: "sh2" }); + await mountRangeSelector(); + await setInputValueAndTrigger(".o-named-range-selector input", "Sheet2!C3:D4"); + expect(model.getters.getActiveSheetId()).toEqual("sh2"); + expect(model.getters.getSelectedZone()).toEqual(toZone("C3:D4")); + }); + + test("Can open the dropdown to select a named range", async () => { + createSheet(model, { name: "Sheet2", sheetId: "sh2" }); + createNamedRange(model, "MyRange", "A1:B2"); + createNamedRange(model, "AnotherRange", "C3:D4", "sh2"); + await mountRangeSelector(); + + await simulateClick(".o-named-range-selector .o-caret-down"); + const menuItems = [...document.querySelectorAll(".o-menu-item")]; + const getMenuItemText = (item: HTMLElement) => { + const name = item.querySelector(".o-menu-item-name")?.textContent?.trim() || ""; + const description = item.querySelector(".o-menu-item-description")?.textContent?.trim() || ""; + return name + " " + description; + }; + + expect(menuItems.map(getMenuItemText)).toEqual([ + "MyRange Sheet1!A1:B2", + "AnotherRange Sheet2!C3:D4", + "Manage named ranges ", + ]); + await simulateClick(menuItems[1]); + expect(model.getters.getActiveSheetId()).toEqual("sh2"); + expect(model.getters.getSelectedZone()).toEqual(toZone("C3:D4")); + }); + + test("The sheet is scrolled so the whole named range is visible when selecting a named range", async () => { + createNamedRange(model, "MyRange", "Y60:Z70"); + await mountRangeSelector(); + const viewport = model.getters.getActiveMainViewport(); + const viewportWidth = viewport.right - viewport.left; + const viewportHeight = viewport.bottom - viewport.top; + + await simulateClick(".o-named-range-selector .o-caret-down"); + await simulateClick(".o-menu-item"); + + expect(model.getters.getSelectedZone()).toEqual(toZone("Y60:Z70")); + expect(model.getters.getActiveMainViewport()).toMatchObject({ + bottom: 69, // Row 70 + top: 69 - viewportHeight, + right: 25, // Column Z + left: 25 - viewportWidth, + }); + }); + + test("Named range is highlighted when hovering it in the dropdown", async () => { + createNamedRange(model, "MyRange", "A1:B3"); + await mountRangeSelector(); + + await simulateClick(".o-named-range-selector .o-caret-down"); + triggerMouseEvent(".o-menu-item", "mouseenter"); + await nextTick(); + + const highlightStore = env.getStore(HighlightStore); + expect(highlightStore.highlights).toMatchObject([ + { range: { zone: toZone("A1:B3") }, color: HIGHLIGHT_COLOR, noFill: true }, + ]); + + triggerMouseEvent(".o-menu-item", "mouseleave"); + await nextTick(); + expect(highlightStore.highlights).toHaveLength(0); + }); + + test("Can open the named range side panel from the dropdown", async () => { + await mountRangeSelector(); + await simulateClick(".o-named-range-selector .o-caret-down"); + + expect(".o-menu-item").toHaveCount(1); + await simulateClick(".o-menu-item"); + expect(openSidePanel).toHaveBeenCalledWith("NamedRangesPanel", {}); + }); +}); diff --git a/tests/setup/jest_extend.ts b/tests/setup/jest_extend.ts index a693444b50..31895789eb 100644 --- a/tests/setup/jest_extend.ts +++ b/tests/setup/jest_extend.ts @@ -288,7 +288,7 @@ CancelledReasons: ${this.utils.printReceived(dispatchResult.reasons)} }, toHaveClass(target: DOMTarget, expectedClass: string) { const element = getTarget(target); - if (!(element instanceof HTMLElement)) { + if (!(element instanceof HTMLElement || element instanceof SVGSVGElement)) { const message = element ? "Target is not an HTML element" : "Target not found"; return { pass: false, message: () => message }; } diff --git a/tests/sheet/sheet_manipulation_plugin.test.ts b/tests/sheet/sheet_manipulation_plugin.test.ts index 21f8a4e0a8..37922d75ec 100644 --- a/tests/sheet/sheet_manipulation_plugin.test.ts +++ b/tests/sheet/sheet_manipulation_plugin.test.ts @@ -6,6 +6,7 @@ import { activateSheet, addColumns, addRows, + createNamedRange, createSheet, deleteCells, deleteColumns, @@ -653,6 +654,17 @@ describe("Columns", () => { expect(model.getters.getSelectedZone()).toEqual(toZone("B1:G3")); }); }); + + test("Named ranges are updated", () => { + model = new Model(); + createNamedRange(model, "Hey", "B2:C3"); + + addColumns(model, "before", "B", 2); + expect(model.getters.getNamedRange("Hey")).toMatchObject({ range: { zone: toZone("D2:E3") } }); + + deleteColumns(model, ["D"]); + expect(model.getters.getNamedRange("Hey")).toMatchObject({ range: { zone: toZone("D2:D3") } }); + }); }); //------------------------------------------------------------------------------ @@ -1258,6 +1270,17 @@ describe("Rows", () => { expect(model.getters.getNumberRows("2")).toBe(5); }); }); + + test("Named ranges are updated", () => { + model = new Model(); + createNamedRange(model, "Hey", "B2:C3"); + + addRows(model, "before", 1, 2); + expect(model.getters.getNamedRange("Hey")).toMatchObject({ range: { zone: toZone("B4:C5") } }); + + deleteRows(model, [3, 4]); + expect(model.getters.getNamedRange("Hey")).toBeUndefined(); + }); }); describe("Delete cell", () => { diff --git a/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap b/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap index 52afd36838..706d100145 100644 --- a/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap +++ b/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap @@ -778,6 +778,86 @@ exports[`Simple Spreadsheet Component simple rendering snapshot 1`] = ` /> +<<<<<<< HEAD +======= +
+
+
+
+
+
+
+ + +
+ +
+
+ +
+
+
+
+ + +
+
+
+
+ +
+
+ + +
+ + + + + + +>>>>>>> 71acd8821 ([IMP] spreadsheet: add named ranges feature)
@@ -1793,6 +1873,46 @@ exports[`components take the small screen into account 1`] = `
+<<<<<<< HEAD +======= +
+
+
+
+ + +
+ +
+
+ +
+
+
+
+ + + +
+ +>>>>>>> 71acd8821 ([IMP] spreadsheet: add named ranges feature)
diff --git a/tests/test_helpers/commands_helpers.ts b/tests/test_helpers/commands_helpers.ts index f8565a690c..f8fc9cfe56 100644 --- a/tests/test_helpers/commands_helpers.ts +++ b/tests/test_helpers/commands_helpers.ts @@ -1704,3 +1704,35 @@ export function selectCarouselItem( sheetId, }); } + +export function createNamedRange( + model: Model, + name: string, + range: string, + sheetId = model.getters.getActiveSheetId() +): DispatchResult { + return model.dispatch("CREATE_NAMED_RANGE", { + rangeName: name, + ranges: toRangesData(sheetId, range), + }); +} + +export function updateNamedRange( + model: Model, + oldName: string, + newName: string, + range: string, + sheetId = model.getters.getActiveSheetId() +): DispatchResult { + return model.dispatch("UPDATE_NAMED_RANGE", { + oldRangeName: oldName, + newRangeName: newName, + ranges: toRangesData(sheetId, range), + }); +} + +export function deleteNamedRange(model: Model, name: string): DispatchResult { + return model.dispatch("DELETE_NAMED_RANGE", { + rangeName: name, + }); +} diff --git a/tests/test_helpers/constants.ts b/tests/test_helpers/constants.ts index fd3886b6da..941baa144b 100644 --- a/tests/test_helpers/constants.ts +++ b/tests/test_helpers/constants.ts @@ -519,6 +519,21 @@ export const TEST_COMMANDS: CommandMapping = { sheetId: "Sheet1", chartId: "chartId", }, + CREATE_NAMED_RANGE: { + type: "CREATE_NAMED_RANGE", + rangeName: "MyNamedRange", + ranges: toRangesData("sheetId", "A1"), + }, + UPDATE_NAMED_RANGE: { + type: "UPDATE_NAMED_RANGE", + oldRangeName: "MyNamedRange", + newRangeName: "MyNewNamedRange", + ranges: toRangesData("sheetId", "A1"), + }, + DELETE_NAMED_RANGE: { + type: "DELETE_NAMED_RANGE", + rangeName: "MyNamedRange", + }, }; export const OT_TESTS_SINGLE_CELL_COMMANDS = [ @@ -554,6 +569,8 @@ export const OT_TESTS_RANGE_DEPENDANT_COMMANDS = [ TEST_COMMANDS.ADD_CONDITIONAL_FORMAT, TEST_COMMANDS.ADD_DATA_VALIDATION_RULE, TEST_COMMANDS.CREATE_TABLE, + TEST_COMMANDS.CREATE_NAMED_RANGE, + TEST_COMMANDS.UPDATE_NAMED_RANGE, ]; export const EN_LOCALE = DEFAULT_LOCALE;