From b86ab422a1c6ca7273d01d32e696fbd757ddec8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Lef=C3=A8vre=20=28lul=29?= Date: Tue, 3 Oct 2023 15:36:43 +0200 Subject: [PATCH] [IMP] spreadsheet: update o_spreadsheet to latest version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Contains the following commits: https://github.com/odoo/o-spreadsheet/commit/c46de914 [REL] 16.5.0-alpha.9 https://github.com/odoo/o-spreadsheet/commit/1438e657 [FIX] side panels: fix link button style Task: / https://github.com/odoo/o-spreadsheet/commit/f66c9ab4 [FIX] components: fix some button styles Task: / https://github.com/odoo/o-spreadsheet/commit/62718580 [IMP] CF: reorder preview list with drag and drop Task: 3504521 https://github.com/odoo/o-spreadsheet/commit/d214df23 [REF] CF: split side panel into multiple components Task: 3504521 https://github.com/odoo/o-spreadsheet/commit/f954de50 [IMP] PieChart: percentage visualization for tooltip https://github.com/odoo/o-spreadsheet/commit/6bc519e0 [IMP] SidePanel: remove unused css rule https://github.com/odoo/o-spreadsheet/commit/6b9927b6 [MOV] side panel: rename `SidePanelErrors` to `ValidationMessages` https://github.com/odoo/o-spreadsheet/commit/adcda849 [IMP] grid: add specific # of rows at the end of sheet https://github.com/odoo/o-spreadsheet/commit/c8289169 [FIX] sheetview: performance issue for too many rows/cols Task: / https://github.com/odoo/o-spreadsheet/commit/36d3a47e [IMP] spreadsheet: Add Data Validation feature Task: 3422042 https://github.com/odoo/o-spreadsheet/commit/d701460b [REF] filter: create GridCellIcon component Task: 3422042 https://github.com/odoo/o-spreadsheet/commit/953ad1d2 [MOV] error_tooltip: add dedicated test file Task: 3422042 https://github.com/odoo/o-spreadsheet/commit/d61cbee2 [IMP] edition: add CANCEL_EDITION command Task: 3422042 https://github.com/odoo/o-spreadsheet/commit/aad55661 [FIX] Find & Replace: Clear search debounce at unmount Task: / https://github.com/odoo/o-spreadsheet/commit/f91bf8b6 [IMP] SelectionInput: remove useless ternary expression Task: / https://github.com/odoo/o-spreadsheet/commit/a5be6827 [IMP] SelectionInput: add missing release on selectionProcessor Task: / https://github.com/odoo/o-spreadsheet/commit/f94eed58 [FIX] filters: hide sort buttons in readonly mode Task: 3475613 https://github.com/odoo/o-spreadsheet/commit/02436b16 [FIX] locale: string dates in CF aren't (un)localized Task: 3481670 https://github.com/odoo/o-spreadsheet/commit/f7b9b9a5 [FIX] clipboard: cut/paste filter table in another sheet Task: 3498592 https://github.com/odoo/o-spreadsheet/commit/0c8782bd [IMP] EventStream: Observers register only once Task: 3142006 https://github.com/odoo/o-spreadsheet/commit/e008fe2b [FIX] test: undeterministic test in spreadsheet.test.ts https://github.com/odoo/o-spreadsheet/commit/f2cd5abf [REV] find & replace: shorten the debounce on find search Task: 3510813 https://github.com/odoo/o-spreadsheet/commit/549142d4 [FIX] Find&Replace: purge duplicated handler Task: / closes odoo/odoo#137440 Signed-off-by: Rémi Rahir (rar) --- .../static/src/o_spreadsheet/o_spreadsheet.js | 5717 ++++++++++++----- .../src/o_spreadsheet/o_spreadsheet.xml | 735 ++- 2 files changed, 4493 insertions(+), 1959 deletions(-) diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js index f5a7c5eda73..66e9fa96ea2 100644 --- a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js @@ -2,9 +2,9 @@ /** * This file is generated by o-spreadsheet build tools. Do not edit it. * @see https://github.com/odoo/o-spreadsheet - * @version 16.5.0-alpha.8 - * @date 2023-09-22T07:50:23.463Z - * @hash 0b7527d5 + * @version 16.5.0-alpha.9 + * @date 2023-10-03T13:36:38.605Z + * @hash c46de914 */ (function (exports, owl) { @@ -75,6 +75,9 @@ this.errorType = errorType; this.logLevel = logLevel; } + get isVerbose() { + return this.logLevel > CellErrorLevel.silent; + } } class BadExpressionError extends EvaluationError { constructor(errorMessage) { @@ -230,9 +233,10 @@ const CF_ICON_EDGE_LENGTH = 15; const PADDING_AUTORESIZE_VERTICAL = 3; const PADDING_AUTORESIZE_HORIZONTAL = MIN_CELL_TEXT_MARGIN; - const FILTER_ICON_MARGIN = 2; - const FILTER_ICON_EDGE_LENGTH = 17; const GROUP_LAYER_WIDTH = 21; + const GRID_ICON_MARGIN = 2; + const GRID_ICON_EDGE_LENGTH = 17; + const FOOTER_HEIGHT = 2 * DEFAULT_CELL_HEIGHT; // Menus const MENU_WIDTH = 250; const MENU_VERTICAL_PADDING = 6; @@ -537,6 +541,9 @@ function isDefined$1(argument) { return argument !== undefined; } + function isNotNull(argument) { + return argument !== null; + } /** * Check if all the values of an object, and all the values of the objects inside of it, are undefined. */ @@ -768,6 +775,12 @@ const contentLines = content.split("\n"); return contentLines.map((line) => line.replace(/\s+/g, " ").trim()).join("\n"); } + function isNumberBetween(value, min, max) { + if (min > max) { + return isNumberBetween(value, max, min); + } + return value >= min && value <= max; + } const RBA_REGEX = /rgba?\(|\s+|\)/gi; const HEX_MATCH = /^#([A-F\d]{2}){3,4}$/; @@ -1212,6 +1225,20 @@ const dateSeparatorsRegex = /\/|-|\s/; const dateRegexp = /^(\d{1,4})[\/-\s](\d{1,4})([\/-\s](\d{1,4}))?$/; const timeRegexp = /((\d+(:\d+)?(:\d+)?\s*(AM|PM))|(\d+:\d+(:\d+)?))$/; + /** Convert a value number representing a date, or return undefined if it isn't possible */ + function valueToDateNumber(value, locale) { + switch (typeof value) { + case "number": + return value; + case "string": + if (isDateTime(value, locale)) { + return parseDateTime(value, locale)?.value; + } + return !value || isNaN(Number(value)) ? undefined : Number(value); + default: + return undefined; + } + } function isDateTime(str, locale) { return parseDateTime(str, locale) !== null; } @@ -1441,8 +1468,11 @@ return date; } function jsDateToRoundNumber(date) { + return Math.round(jsDateToNumber(date)); + } + function jsDateToNumber(date) { const delta = date.getTime() - INITIAL_1900_DAY.getTime(); - return Math.round(delta / MS_PER_DAY); + return delta / MS_PER_DAY; } /** Return the number of days in the current month of the given date */ function getDaysInMonth(date) { @@ -1622,42 +1652,34 @@ function areTwoDatesWithinOneYear(startDate, endDate) { return getYearFrac(startDate, endDate, 1) < 1; } - - const MAX_DELAY = 140; - const MIN_DELAY = 20; - const ACCELERATION = 0.035; - /** - * Decreasing exponential function used to determine the "speed" of edge-scrolling - * as the timeout delay. - * - * Returns a timeout delay in milliseconds. - */ - function scrollDelay(value) { - // decreasing exponential from MAX_DELAY to MIN_DELAY - return MIN_DELAY + (MAX_DELAY - MIN_DELAY) * Math.exp(-ACCELERATION * (value - 1)); + function areDatesSameDay(startDate, endDate) { + return Math.trunc(startDate) === Math.trunc(endDate); + } + function isDateBetween(date, startDate, endDate) { + if (startDate > endDate) { + return isDateBetween(date, endDate, startDate); + } + date = Math.trunc(date); + startDate = Math.trunc(startDate); + endDate = Math.trunc(endDate); + return date >= startDate && date <= endDate; + } + /** Check if the first date is strictly before the second date */ + function isDateStrictlyBefore(date, dateBefore) { + return Math.trunc(date) < Math.trunc(dateBefore); + } + /** Check if the first date is before or equal to the second date */ + function isDateBefore(date, dateBefore) { + return Math.trunc(date) <= Math.trunc(dateBefore); + } + /** Check if the first date is strictly after the second date */ + function isDateStrictlyAfter(date, dateAfter) { + return Math.trunc(date) > Math.trunc(dateAfter); + } + /** Check if the first date is after or equal to the second date */ + function isDateAfter(date, dateAfter) { + return Math.trunc(date) >= Math.trunc(dateAfter); } - - const DEFAULT_LOCALES = [ - { - name: "English (US)", - code: "en_US", - thousandsSeparator: ",", - decimalSeparator: ".", - dateFormat: "m/d/yyyy", - timeFormat: "hh:mm:ss a", - formulaArgSeparator: ",", - }, - { - name: "French", - code: "fr_FR", - thousandsSeparator: " ", - decimalSeparator: ",", - dateFormat: "dd/mm/yyyy", - timeFormat: "hh:mm:ss", - formulaArgSeparator: ";", - }, - ]; - const DEFAULT_LOCALE = DEFAULT_LOCALES[0]; /** * This function returns a regexp that is supposed to be as close as possible as the numberRegexp, @@ -1739,6 +1761,1001 @@ sortedValues[indexLow] * (indexSup - percentIndex)); } + var CellValueType; + (function (CellValueType) { + CellValueType["boolean"] = "boolean"; + CellValueType["number"] = "number"; + CellValueType["text"] = "text"; + CellValueType["empty"] = "empty"; + CellValueType["error"] = "error"; + })(CellValueType || (CellValueType = {})); + + var ClipboardMIMEType; + (function (ClipboardMIMEType) { + ClipboardMIMEType["PlainText"] = "text/plain"; + ClipboardMIMEType["Html"] = "text/html"; + })(ClipboardMIMEType || (ClipboardMIMEType = {})); + + function isSheetDependent(cmd) { + return "sheetId" in cmd; + } + function isHeadersDependant(cmd) { + return "dimension" in cmd && "sheetId" in cmd && "elements" in cmd; + } + function isTargetDependent(cmd) { + return "target" in cmd && "sheetId" in cmd; + } + function isRangeDependant(cmd) { + return "ranges" in cmd; + } + function isZoneDependent(cmd) { + return "zone" in cmd; + } + function isPositionDependent(cmd) { + return "col" in cmd && "row" in cmd && "sheetId" in cmd; + } + const invalidateEvaluationCommands = new Set([ + "RENAME_SHEET", + "DELETE_SHEET", + "CREATE_SHEET", + "ADD_COLUMNS_ROWS", + "REMOVE_COLUMNS_ROWS", + "UNDO", + "REDO", + "ADD_MERGE", + "UPDATE_LOCALE", + ]); + const invalidateDependenciesCommands = new Set([ + ...invalidateEvaluationCommands, + "MOVE_RANGES", + ]); + const invalidateCFEvaluationCommands = new Set([ + ...invalidateEvaluationCommands, + "DUPLICATE_SHEET", + "EVALUATE_CELLS", + "ADD_CONDITIONAL_FORMAT", + "REMOVE_CONDITIONAL_FORMAT", + "CHANGE_CONDITIONAL_FORMAT_PRIORITY", + ]); + const readonlyAllowedCommands = new Set([ + "START", + "ACTIVATE_SHEET", + "COPY", + "RESIZE_SHEETVIEW", + "SET_VIEWPORT_OFFSET", + "SELECT_SEARCH_NEXT_MATCH", + "SELECT_SEARCH_PREVIOUS_MATCH", + "UPDATE_SEARCH", + "CLEAR_SEARCH", + "EVALUATE_CELLS", + "SET_CURRENT_CONTENT", + "SET_FORMULA_VISIBILITY", + "OPEN_CELL_POPOVER", + "CLOSE_CELL_POPOVER", + "UPDATE_FILTER", + ]); + const coreTypes = new Set([ + /** CELLS */ + "UPDATE_CELL", + "UPDATE_CELL_POSITION", + "CLEAR_CELL", + "DELETE_CONTENT", + /** GRID SHAPE */ + "ADD_COLUMNS_ROWS", + "REMOVE_COLUMNS_ROWS", + "RESIZE_COLUMNS_ROWS", + "HIDE_COLUMNS_ROWS", + "UNHIDE_COLUMNS_ROWS", + "SET_GRID_LINES_VISIBILITY", + "UNFREEZE_COLUMNS", + "UNFREEZE_ROWS", + "FREEZE_COLUMNS", + "FREEZE_ROWS", + "UNFREEZE_COLUMNS_ROWS", + /** MERGE */ + "ADD_MERGE", + "REMOVE_MERGE", + /** SHEETS MANIPULATION */ + "CREATE_SHEET", + "DELETE_SHEET", + "DUPLICATE_SHEET", + "MOVE_SHEET", + "RENAME_SHEET", + "HIDE_SHEET", + "SHOW_SHEET", + /** RANGES MANIPULATION */ + "MOVE_RANGES", + /** CONDITIONAL FORMAT */ + "ADD_CONDITIONAL_FORMAT", + "REMOVE_CONDITIONAL_FORMAT", + "CHANGE_CONDITIONAL_FORMAT_PRIORITY", + /** FIGURES */ + "CREATE_FIGURE", + "DELETE_FIGURE", + "UPDATE_FIGURE", + /** FORMATTING */ + "SET_FORMATTING", + "CLEAR_FORMATTING", + "SET_BORDER", + "SET_ZONE_BORDERS", + /** CHART */ + "CREATE_CHART", + "UPDATE_CHART", + /** FILTERS */ + "CREATE_FILTER_TABLE", + "REMOVE_FILTER_TABLE", + /** IMAGE */ + "CREATE_IMAGE", + /** HEADER GROUP */ + "GROUP_HEADERS", + "UNGROUP_HEADERS", + "UNFOLD_HEADER_GROUP", + "FOLD_HEADER_GROUP", + "FOLD_ALL_HEADER_GROUPS", + "UNFOLD_ALL_HEADER_GROUPS", + "UNFOLD_HEADER_GROUPS_IN_ZONE", + "FOLD_HEADER_GROUPS_IN_ZONE", + /** DATA VALIDATION */ + "ADD_DATA_VALIDATION_RULE", + "REMOVE_DATA_VALIDATION_RULE", + /** MISC */ + "UPDATE_LOCALE", + ]); + function isCoreCommand(cmd) { + return coreTypes.has(cmd.type); + } + function canExecuteInReadonly(cmd) { + return readonlyAllowedCommands.has(cmd.type); + } + /** + * Holds the result of a command dispatch. + * The command may have been successfully dispatched or cancelled + * for one or more reasons. + */ + class DispatchResult { + reasons; + constructor(results = []) { + if (!Array.isArray(results)) { + results = [results]; + } + results = [...new Set(results)]; + this.reasons = results.filter((result) => result !== "Success" /* CommandResult.Success */); + } + /** + * Static helper which returns a successful DispatchResult + */ + static get Success() { + return new DispatchResult(); + } + get isSuccessful() { + return this.reasons.length === 0; + } + /** + * Check if the dispatch has been cancelled because of + * the given reason. + */ + isCancelledBecause(reason) { + return this.reasons.includes(reason); + } + } + exports.CommandResult = void 0; + (function (CommandResult) { + CommandResult["Success"] = "Success"; + CommandResult["CancelledForUnknownReason"] = "CancelledForUnknownReason"; + CommandResult["WillRemoveExistingMerge"] = "WillRemoveExistingMerge"; + CommandResult["MergeIsDestructive"] = "MergeIsDestructive"; + CommandResult["CellIsMerged"] = "CellIsMerged"; + CommandResult["InvalidTarget"] = "InvalidTarget"; + CommandResult["EmptyUndoStack"] = "EmptyUndoStack"; + CommandResult["EmptyRedoStack"] = "EmptyRedoStack"; + CommandResult["NotEnoughElements"] = "NotEnoughElements"; + CommandResult["NotEnoughSheets"] = "NotEnoughSheets"; + CommandResult["MissingSheetName"] = "MissingSheetName"; + CommandResult["UnchangedSheetName"] = "UnchangedSheetName"; + CommandResult["DuplicatedSheetName"] = "DuplicatedSheetName"; + CommandResult["DuplicatedSheetId"] = "DuplicatedSheetId"; + CommandResult["ForbiddenCharactersInSheetName"] = "ForbiddenCharactersInSheetName"; + CommandResult["WrongSheetMove"] = "WrongSheetMove"; + CommandResult["WrongSheetPosition"] = "WrongSheetPosition"; + CommandResult["InvalidAnchorZone"] = "InvalidAnchorZone"; + CommandResult["SelectionOutOfBound"] = "SelectionOutOfBound"; + CommandResult["TargetOutOfSheet"] = "TargetOutOfSheet"; + CommandResult["WrongCutSelection"] = "WrongCutSelection"; + CommandResult["WrongPasteSelection"] = "WrongPasteSelection"; + CommandResult["WrongPasteOption"] = "WrongPasteOption"; + CommandResult["WrongFigurePasteOption"] = "WrongFigurePasteOption"; + CommandResult["EmptyClipboard"] = "EmptyClipboard"; + CommandResult["EmptyRange"] = "EmptyRange"; + CommandResult["InvalidRange"] = "InvalidRange"; + CommandResult["InvalidZones"] = "InvalidZones"; + CommandResult["InvalidSheetId"] = "InvalidSheetId"; + CommandResult["InvalidFigureId"] = "InvalidFigureId"; + CommandResult["InputAlreadyFocused"] = "InputAlreadyFocused"; + CommandResult["MaximumRangesReached"] = "MaximumRangesReached"; + CommandResult["InvalidChartDefinition"] = "InvalidChartDefinition"; + CommandResult["InvalidDataSet"] = "InvalidDataSet"; + CommandResult["InvalidLabelRange"] = "InvalidLabelRange"; + CommandResult["InvalidScorecardKeyValue"] = "InvalidScorecardKeyValue"; + CommandResult["InvalidScorecardBaseline"] = "InvalidScorecardBaseline"; + CommandResult["InvalidGaugeDataRange"] = "InvalidGaugeDataRange"; + CommandResult["EmptyGaugeRangeMin"] = "EmptyGaugeRangeMin"; + CommandResult["GaugeRangeMinNaN"] = "GaugeRangeMinNaN"; + CommandResult["EmptyGaugeRangeMax"] = "EmptyGaugeRangeMax"; + CommandResult["GaugeRangeMaxNaN"] = "GaugeRangeMaxNaN"; + CommandResult["GaugeRangeMinBiggerThanRangeMax"] = "GaugeRangeMinBiggerThanRangeMax"; + CommandResult["GaugeLowerInflectionPointNaN"] = "GaugeLowerInflectionPointNaN"; + CommandResult["GaugeUpperInflectionPointNaN"] = "GaugeUpperInflectionPointNaN"; + CommandResult["GaugeLowerBiggerThanUpper"] = "GaugeLowerBiggerThanUpper"; + CommandResult["InvalidAutofillSelection"] = "InvalidAutofillSelection"; + CommandResult["WrongComposerSelection"] = "WrongComposerSelection"; + CommandResult["MinBiggerThanMax"] = "MinBiggerThanMax"; + CommandResult["LowerBiggerThanUpper"] = "LowerBiggerThanUpper"; + CommandResult["MidBiggerThanMax"] = "MidBiggerThanMax"; + CommandResult["MinBiggerThanMid"] = "MinBiggerThanMid"; + CommandResult["FirstArgMissing"] = "FirstArgMissing"; + CommandResult["SecondArgMissing"] = "SecondArgMissing"; + CommandResult["MinNaN"] = "MinNaN"; + CommandResult["MidNaN"] = "MidNaN"; + CommandResult["MaxNaN"] = "MaxNaN"; + CommandResult["ValueUpperInflectionNaN"] = "ValueUpperInflectionNaN"; + CommandResult["ValueLowerInflectionNaN"] = "ValueLowerInflectionNaN"; + CommandResult["MinInvalidFormula"] = "MinInvalidFormula"; + CommandResult["MidInvalidFormula"] = "MidInvalidFormula"; + CommandResult["MaxInvalidFormula"] = "MaxInvalidFormula"; + CommandResult["ValueUpperInvalidFormula"] = "ValueUpperInvalidFormula"; + CommandResult["ValueLowerInvalidFormula"] = "ValueLowerInvalidFormula"; + CommandResult["InvalidSortZone"] = "InvalidSortZone"; + CommandResult["WaitingSessionConfirmation"] = "WaitingSessionConfirmation"; + CommandResult["MergeOverlap"] = "MergeOverlap"; + CommandResult["TooManyHiddenElements"] = "TooManyHiddenElements"; + CommandResult["Readonly"] = "Readonly"; + CommandResult["InvalidViewportSize"] = "InvalidViewportSize"; + CommandResult["InvalidScrollingDirection"] = "InvalidScrollingDirection"; + CommandResult["FigureDoesNotExist"] = "FigureDoesNotExist"; + CommandResult["InvalidConditionalFormatId"] = "InvalidConditionalFormatId"; + CommandResult["InvalidCellPopover"] = "InvalidCellPopover"; + CommandResult["EmptyTarget"] = "EmptyTarget"; + CommandResult["InvalidFreezeQuantity"] = "InvalidFreezeQuantity"; + CommandResult["FrozenPaneOverlap"] = "FrozenPaneOverlap"; + CommandResult["ValuesNotChanged"] = "ValuesNotChanged"; + CommandResult["InvalidFilterZone"] = "InvalidFilterZone"; + CommandResult["FilterOverlap"] = "FilterOverlap"; + CommandResult["FilterNotFound"] = "FilterNotFound"; + CommandResult["MergeInFilter"] = "MergeInFilter"; + CommandResult["NonContinuousTargets"] = "NonContinuousTargets"; + CommandResult["DuplicatedFigureId"] = "DuplicatedFigureId"; + CommandResult["InvalidSelectionStep"] = "InvalidSelectionStep"; + CommandResult["DuplicatedChartId"] = "DuplicatedChartId"; + CommandResult["ChartDoesNotExist"] = "ChartDoesNotExist"; + CommandResult["InvalidHeaderIndex"] = "InvalidHeaderIndex"; + CommandResult["InvalidQuantity"] = "InvalidQuantity"; + CommandResult["MoreThanOneColumnSelected"] = "MoreThanOneColumnSelected"; + CommandResult["EmptySplitSeparator"] = "EmptySplitSeparator"; + CommandResult["SplitWillOverwriteContent"] = "SplitWillOverwriteContent"; + CommandResult["NoSplitSeparatorInSelection"] = "NoSplitSeparatorInSelection"; + CommandResult["NoActiveSheet"] = "NoActiveSheet"; + CommandResult["InvalidLocale"] = "InvalidLocale"; + CommandResult["AlreadyInPaintingFormatMode"] = "AlreadyInPaintingFormatMode"; + CommandResult["MoreThanOneRangeSelected"] = "MoreThanOneRangeSelected"; + CommandResult["NoColumnsProvided"] = "NoColumnsProvided"; + CommandResult["ColumnsNotIncludedInZone"] = "ColumnsNotIncludedInZone"; + CommandResult["DuplicatesColumnsSelected"] = "DuplicatesColumnsSelected"; + CommandResult["InvalidHeaderGroupStartEnd"] = "InvalidHeaderGroupStartEnd"; + CommandResult["HeaderGroupAlreadyExists"] = "HeaderGroupAlreadyExists"; + CommandResult["UnknownHeaderGroup"] = "UnknownHeaderGroup"; + CommandResult["UnknownDataValidationRule"] = "UnknownDataValidationRule"; + CommandResult["UnknownDataValidationCriterionType"] = "UnknownDataValidationCriterionType"; + CommandResult["InvalidDataValidationCriterionValue"] = "InvalidDataValidationCriterionValue"; + CommandResult["InvalidNumberOfCriterionValues"] = "InvalidNumberOfCriterionValues"; + CommandResult["BlockingValidationRule"] = "BlockingValidationRule"; + })(exports.CommandResult || (exports.CommandResult = {})); + + const DEFAULT_LOCALES = [ + { + name: "English (US)", + code: "en_US", + thousandsSeparator: ",", + decimalSeparator: ".", + dateFormat: "m/d/yyyy", + timeFormat: "hh:mm:ss a", + formulaArgSeparator: ",", + }, + { + name: "French", + code: "fr_FR", + thousandsSeparator: " ", + decimalSeparator: ",", + dateFormat: "dd/mm/yyyy", + timeFormat: "hh:mm:ss", + formulaArgSeparator: ";", + }, + ]; + const DEFAULT_LOCALE = DEFAULT_LOCALES[0]; + + const borderStyles = ["thin", "medium", "thick", "dashed", "dotted"]; + function isMatrix(x) { + return Array.isArray(x) && Array.isArray(x[0]); + } + var DIRECTION; + (function (DIRECTION) { + DIRECTION["UP"] = "up"; + DIRECTION["DOWN"] = "down"; + DIRECTION["LEFT"] = "left"; + DIRECTION["RIGHT"] = "right"; + })(DIRECTION || (DIRECTION = {})); + + var LAYERS; + (function (LAYERS) { + LAYERS[LAYERS["Background"] = 0] = "Background"; + LAYERS[LAYERS["Highlights"] = 1] = "Highlights"; + LAYERS[LAYERS["Clipboard"] = 2] = "Clipboard"; + LAYERS[LAYERS["Search"] = 3] = "Search"; + LAYERS[LAYERS["Chart"] = 4] = "Chart"; + LAYERS[LAYERS["Autofill"] = 5] = "Autofill"; + LAYERS[LAYERS["Selection"] = 6] = "Selection"; + LAYERS[LAYERS["Headers"] = 7] = "Headers"; + })(LAYERS || (LAYERS = {})); + + // HELPERS + const SORT_TYPES_ORDER = ["number", "string", "boolean", "undefined"]; + function assert(condition, message) { + if (!condition()) { + throw new Error(message); + } + } + // ----------------------------------------------------------------------------- + // FORMAT FUNCTIONS + // ----------------------------------------------------------------------------- + const expectNumberValueError = (value) => _t("The function [[FUNCTION_NAME]] expects a number value, but '%s' is a string, and cannot be coerced to a number.", value); + const expectNumberRangeError = (lowerBound, upperBound, value) => _t("The function [[FUNCTION_NAME]] expects a number value between %s and %s inclusive, but receives %s.", lowerBound.toString(), upperBound.toString(), value.toString()); + const expectStringSetError = (stringSet, value) => { + const stringSetString = stringSet.map((str) => `'${str}'`).join(", "); + return _t("The function [[FUNCTION_NAME]] has an argument with value '%s'. It should be one of: %s.", value, stringSetString); + }; + function toNumber(value, locale) { + switch (typeof value) { + case "number": + return value; + case "boolean": + return value ? 1 : 0; + case "string": + if (isNumber(value, locale) || value === "") { + return parseNumber(value, locale); + } + const internalDate = parseDateTime(value, locale); + if (internalDate) { + return internalDate.value; + } + throw new Error(expectNumberValueError(value)); + default: + return 0; + } + } + function tryToNumber(value, locale) { + try { + return toNumber(value, locale); + } + catch (e) { + return undefined; + } + } + function tryCastAsNumberMatrix(data, argName) { + data.forEach((row) => { + row.forEach((cell) => { + if (typeof cell !== "number") { + throw new Error(_t("Function [[FUNCTION_NAME]] expects number values for %s, but got a %s.", typeof cell, argName)); + } + }); + }); + return data; + } + function strictToNumber(value, locale) { + if (value === "") { + throw new Error(expectNumberValueError(value)); + } + return toNumber(value, locale); + } + function toInteger(value, locale) { + return Math.trunc(toNumber(value, locale)); + } + function strictToInteger(value, locale) { + return Math.trunc(strictToNumber(value, locale)); + } + function assertNumberGreaterThanOrEqualToOne(value) { + assert(() => value >= 1, _t("The function [[FUNCTION_NAME]] expects a number value to be greater than or equal to 1, but receives %s.", value.toString())); + } + function toString(value) { + switch (typeof value) { + case "string": + return value; + case "number": + return value.toString(); + case "boolean": + return value ? "TRUE" : "FALSE"; + default: + return ""; + } + } + /** Normalize string by setting it to lowercase and replacing accent letters with plain letters */ + function normalizeString(str) { + return str + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, ""); + } + const expectBooleanValueError = (value) => _t("The function [[FUNCTION_NAME]] expects a boolean value, but '%s' is a text, and cannot be coerced to a number.", value); + function toBoolean(value) { + switch (typeof value) { + case "boolean": + return value; + case "string": + if (value) { + let uppercaseVal = value.toUpperCase(); + if (uppercaseVal === "TRUE") { + return true; + } + if (uppercaseVal === "FALSE") { + return false; + } + throw new Error(expectBooleanValueError(value)); + } + else { + return false; + } + case "number": + return value ? true : false; + default: + return false; + } + } + function strictToBoolean(value) { + if (value === "") { + throw new Error(expectBooleanValueError(value)); + } + return toBoolean(value); + } + function toJsDate(value, locale) { + return numberToJsDate(toNumber(value, locale)); + } + // ----------------------------------------------------------------------------- + // VISIT FUNCTIONS + // ----------------------------------------------------------------------------- + function visitArgs(args, cellCb, dataCb) { + for (let arg of args) { + if (isMatrix(arg)) { + // arg is ref to a Cell/Range + const lenRow = arg.length; + const lenCol = arg[0].length; + for (let y = 0; y < lenCol; y++) { + for (let x = 0; x < lenRow; x++) { + cellCb(arg[x][y]); + } + } + } + else { + // arg is set directly in the formula function + dataCb(arg); + } + } + } + function visitAny(args, cb) { + visitArgs(args, cb, cb); + } + function visitNumbers(args, cb, locale) { + visitArgs(args, (cellValue) => { + if (typeof cellValue === "number") { + cb(cellValue); + } + }, (argValue) => { + cb(strictToNumber(argValue, locale)); + }); + } + // ----------------------------------------------------------------------------- + // REDUCE FUNCTIONS + // ----------------------------------------------------------------------------- + function reduceArgs(args, cellCb, dataCb, initialValue, dir = "rowFirst") { + let val = initialValue; + for (let arg of args) { + if (isMatrix(arg)) { + // arg is ref to a Cell/Range + const numberOfCols = arg.length; + const numberOfRows = arg[0].length; + if (dir === "rowFirst") { + for (let row = 0; row < numberOfRows; row++) { + for (let col = 0; col < numberOfCols; col++) { + val = cellCb(val, arg[col][row]); + } + } + } + else { + for (let col = 0; col < numberOfCols; col++) { + for (let row = 0; row < numberOfRows; row++) { + val = cellCb(val, arg[col][row]); + } + } + } + } + else { + // arg is set directly in the formula function + val = dataCb(val, arg); + } + } + return val; + } + function reduceAny(args, cb, initialValue, dir = "rowFirst") { + return reduceArgs(args, cb, cb, initialValue, dir); + } + function reduceNumbers(args, cb, initialValue, locale) { + return reduceArgs(args, (acc, ArgValue) => { + if (typeof ArgValue === "number") { + return cb(acc, ArgValue); + } + return acc; + }, (acc, argValue) => { + return cb(acc, strictToNumber(argValue, locale)); + }, initialValue); + } + function reduceNumbersTextAs0(args, cb, initialValue, locale) { + return reduceArgs(args, (acc, ArgValue) => { + if (ArgValue !== undefined && ArgValue !== null) { + if (typeof ArgValue === "number") { + return cb(acc, ArgValue); + } + else if (typeof ArgValue === "boolean") { + return cb(acc, toNumber(ArgValue, locale)); + } + else { + return cb(acc, 0); + } + } + return acc; + }, (acc, argValue) => { + return cb(acc, toNumber(argValue, locale)); + }, initialValue); + } + // ----------------------------------------------------------------------------- + // MATRIX FUNCTIONS + // ----------------------------------------------------------------------------- + /** + * Generate a matrix of size nColumns x nRows and apply a callback on each position + */ + function generateMatrix(nColumns, nRows, callback) { + const returned = Array(nColumns); + for (let col = 0; col < nColumns; col++) { + returned[col] = Array(nRows); + for (let row = 0; row < nRows; row++) { + returned[col][row] = callback(col, row); + } + } + return returned; + } + function matrixMap(matrix, fn) { + if (matrix.length === 0) { + return []; + } + return generateMatrix(matrix.length, matrix[0].length, (col, row) => fn(matrix[col][row])); + } + function transposeMatrix(matrix) { + if (!matrix.length) { + return []; + } + return generateMatrix(matrix[0].length, matrix.length, (i, j) => matrix[j][i]); + } + // ----------------------------------------------------------------------------- + // CONDITIONAL EXPLORE FUNCTIONS + // ----------------------------------------------------------------------------- + /** + * This function allows to visit arguments and stop the visit if necessary. + * It is mainly used to bypass argument evaluation for functions like OR or AND. + */ + function conditionalVisitArgs(args, cellCb, dataCb) { + for (let arg of args) { + if (isMatrix(arg)) { + // arg is ref to a Cell/Range + const lenRow = arg.length; + const lenCol = arg[0].length; + for (let y = 0; y < lenCol; y++) { + for (let x = 0; x < lenRow; x++) { + if (!cellCb(arg[x][y] ?? undefined)) + return; + } + } + } + else { + // arg is set directly in the formula function + if (!dataCb(arg)) + return; + } + } + } + function conditionalVisitBoolean(args, cb) { + return conditionalVisitArgs(args, (ArgValue) => { + if (typeof ArgValue === "boolean") { + return cb(ArgValue); + } + if (typeof ArgValue === "number") { + return cb(ArgValue ? true : false); + } + return true; + }, (argValue) => { + if (argValue !== undefined && argValue !== null) { + return cb(strictToBoolean(argValue)); + } + return true; + }); + } + function getPredicate(descr, isQuery, locale) { + let operator; + let operand; + let subString = descr.substring(0, 2); + if (subString === "<=" || subString === ">=" || subString === "<>") { + operator = subString; + operand = descr.substring(2); + } + else { + subString = descr.substring(0, 1); + if (subString === "<" || subString === ">" || subString === "=") { + operator = subString; + operand = descr.substring(1); + } + else { + operator = "="; + operand = descr; + } + } + if (isNumber(operand, locale)) { + operand = toNumber(operand, locale); + } + else if (operand === "TRUE" || operand === "FALSE") { + operand = toBoolean(operand); + } + const result = { operator, operand }; + if (typeof operand === "string") { + if (isQuery) { + operand += "*"; + } + result.regexp = operandToRegExp(operand); + } + return result; + } + function operandToRegExp(operand) { + let exp = ""; + let predecessor = ""; + for (let char of operand) { + if (char === "?" && predecessor !== "~") { + exp += "."; + } + else if (char === "*" && predecessor !== "~") { + exp += ".*"; + } + else { + if (char === "*" || char === "?") { + //remove "~" + exp = exp.slice(0, -1); + } + if (["^", ".", "[", "]", "$", "(", ")", "*", "+", "?", "|", "{", "}", "\\"].includes(char)) { + exp += "\\"; + } + exp += char; + } + predecessor = char; + } + return new RegExp("^" + exp + "$", "i"); + } + function evaluatePredicate(value, criterion) { + const { operator, operand } = criterion; + if (value === undefined || operand === undefined || value === null || operand === null) { + return false; + } + if (typeof operand === "number" && operator === "=") { + return toString(value) === toString(operand); + } + if (operator === "<>" || operator === "=") { + let result; + if (typeof value === typeof operand) { + if (typeof value === "string" && criterion.regexp) { + result = criterion.regexp.test(value); + } + else { + result = value === operand; + } + } + else { + result = false; + } + return operator === "=" ? result : !result; + } + if (typeof value === typeof operand) { + switch (operator) { + case "<": + return value < operand; + case ">": + return value > operand; + case "<=": + return value <= operand; + case ">=": + return value >= operand; + } + } + return false; + } + /** + * Functions used especially for predicate evaluation on ranges. + * + * Take ranges with same dimensions and take predicates, one for each range. + * For (i, j) coordinates, if all elements with coordinates (i, j) of each + * range correspond to the associated predicate, then the function uses a callback + * function with the parameters "i" and "j". + * + * Syntax: + * visitMatchingRanges([range1, predicate1, range2, predicate2, ...], cb(i,j), likeSelection) + * + * - range1 (range): The range to check against predicate1. + * - predicate1 (string): The pattern or test to apply to range1. + * - range2: (range, repeatable) ranges to check. + * - predicate2 (string, repeatable): Additional pattern or test to apply to range2. + * + * - cb(i: number, j: number) => void: the callback function. + * + * - isQuery (boolean) indicates if the comparison with a string should be done as a SQL-like query. + * (Ex1 isQuery = true, predicate = "abc", element = "abcde": predicate match the element), + * (Ex2 isQuery = false, predicate = "abc", element = "abcde": predicate not match the element). + * (Ex3 isQuery = true, predicate = "abc", element = "abc": predicate match the element), + * (Ex4 isQuery = false, predicate = "abc", element = "abc": predicate match the element). + */ + function visitMatchingRanges(args, cb, locale, isQuery = false) { + const countArg = args.length; + if (countArg % 2 === 1) { + throw new Error(_t(`Function [[FUNCTION_NAME]] expects criteria_range and criterion to be in pairs.`)); + } + const dimRow = args[0].length; + const dimCol = args[0][0].length; + let predicates = []; + for (let i = 0; i < countArg - 1; i += 2) { + const criteriaRange = args[i]; + if (!isMatrix(criteriaRange) || + criteriaRange.length !== dimRow || + criteriaRange[0].length !== dimCol) { + throw new Error(_t(`Function [[FUNCTION_NAME]] expects criteria_range to have the same dimension`)); + } + const description = toString(args[i + 1]); + predicates.push(getPredicate(description, isQuery, locale)); + } + for (let i = 0; i < dimRow; i++) { + for (let j = 0; j < dimCol; j++) { + let validatedPredicates = true; + for (let k = 0; k < countArg - 1; k += 2) { + const criteriaValue = args[k][i][j]; + const criterion = predicates[k / 2]; + validatedPredicates = evaluatePredicate(criteriaValue ?? undefined, criterion); + if (!validatedPredicates) { + break; + } + } + if (validatedPredicates) { + cb(i, j); + } + } + } + } + // ----------------------------------------------------------------------------- + // COMMON FUNCTIONS + // ----------------------------------------------------------------------------- + /** + * Perform a dichotomic search on an array and return the index of the nearest match. + * + * The array should be sorted, if not an incorrect value might be returned. In the case where multiple + * element of the array match the target, the method will return the first match if the array is sorted + * in descending order, and the last match if the array is in ascending order. + * + * + * @param data the array in which to search. + * @param target the value to search. + * @param mode "nextGreater/nextSmaller" : return next greater/smaller value if no exact match is found. + * @param sortOrder whether the array is sorted in ascending or descending order. + * @param rangeLength the number of elements to consider in the search array. + * @param getValueInData function returning the element at index i in the search array. + */ + function dichotomicSearch(data, target, mode, sortOrder, rangeLength, getValueInData) { + if (target === null || target === undefined) { + return -1; + } + const _target = normalizeValue(target); + const targetType = typeof _target; + let matchVal = undefined; + let matchValIndex = undefined; + let indexLeft = 0; + let indexRight = rangeLength - 1; + let indexMedian; + let currentIndex; + let currentVal; + let currentType; + while (indexRight - indexLeft >= 0) { + indexMedian = Math.floor((indexLeft + indexRight) / 2); + currentIndex = indexMedian; + currentVal = normalizeValue(getValueInData(data, currentIndex)); + currentType = typeof currentVal; + // 1 - linear search to find value with the same type + while (indexLeft < currentIndex && targetType !== currentType) { + currentIndex--; + currentVal = normalizeValue(getValueInData(data, currentIndex)); + currentType = typeof currentVal; + } + if (currentType !== targetType || currentVal === undefined || currentVal === null) { + indexLeft = indexMedian + 1; + continue; + } + // 2 - check if value match + if (mode === "strict" && currentVal === _target) { + matchVal = currentVal; + matchValIndex = currentIndex; + } + else if (mode === "nextSmaller" && currentVal <= _target) { + if (matchVal === undefined || + matchVal === null || + matchVal < currentVal || + (matchVal === currentVal && sortOrder === "asc" && matchValIndex < currentIndex) || + (matchVal === currentVal && sortOrder === "desc" && matchValIndex > currentIndex)) { + matchVal = currentVal; + matchValIndex = currentIndex; + } + } + else if (mode === "nextGreater" && currentVal >= _target) { + if (matchVal === undefined || + matchVal > currentVal || + (matchVal === currentVal && sortOrder === "asc" && matchValIndex < currentIndex) || + (matchVal === currentVal && sortOrder === "desc" && matchValIndex > currentIndex)) { + matchVal = currentVal; + matchValIndex = currentIndex; + } + } + // 3 - give new indexes for the Binary search + if ((sortOrder === "asc" && currentVal > _target) || + (sortOrder === "desc" && currentVal <= _target)) { + indexRight = currentIndex - 1; + } + else { + indexLeft = indexMedian + 1; + } + } + // note that valMinIndex could be 0 + return matchValIndex !== undefined ? matchValIndex : -1; + } + /** + * Perform a linear search and return the index of the match. + * -1 is returned if no value is found. + * + * Example: + * - [3, 6, 10], 3 => 0 + * - [3, 6, 10], 6 => 1 + * - [3, 6, 10], 9 => -1 + * - [3, 6, 10], 2 => -1 + * + * @param data the array to search in. + * @param target the value to search in the array. + * @param mode if "strict" return exact match index. "nextGreater" returns the next greater + * element from the target and "nextSmaller" the next smaller + * @param numberOfValues the number of elements to consider in the search array. + * @param getValueInData function returning the element at index i in the search array. + * @param reverseSearch if true, search in the array starting from the end. + + */ + function linearSearch(data, target, mode, numberOfValues, getValueInData, reverseSearch = false) { + if (target === null || target === undefined) + return -1; + const _target = normalizeValue(target); + const getValue = reverseSearch + ? (data, i) => getValueInData(data, numberOfValues - i - 1) + : getValueInData; + let closestMatch = undefined; + let closestMatchIndex = -1; + for (let i = 0; i < numberOfValues; i++) { + const value = normalizeValue(getValue(data, i)); + if (value === _target) { + return reverseSearch ? numberOfValues - i - 1 : i; + } + if (mode === "nextSmaller") { + if ((!closestMatch && compareCellValues(_target, value) >= 0) || + (compareCellValues(_target, value) >= 0 && compareCellValues(value, closestMatch) > 0)) { + closestMatch = value; + closestMatchIndex = i; + } + } + else if (mode === "nextGreater") { + if ((!closestMatch && compareCellValues(_target, value) <= 0) || + (compareCellValues(_target, value) <= 0 && compareCellValues(value, closestMatch) < 0)) { + closestMatch = value; + closestMatchIndex = i; + } + } + } + return reverseSearch ? numberOfValues - closestMatchIndex - 1 : closestMatchIndex; + } + /** + * Normalize a value. + * If the cell value is a string, this will set it to lowercase and replacing accent letters with plain letters + */ + function normalizeValue(value) { + return typeof value === "string" ? normalizeString(value) : value; + } + function compareCellValues(left, right) { + let typeOrder = SORT_TYPES_ORDER.indexOf(typeof left) - SORT_TYPES_ORDER.indexOf(typeof right); + if (typeOrder === 0) { + if (typeof left === "string" && typeof right === "string") { + typeOrder = left.localeCompare(right); + } + else if (typeof left === "number" && typeof right === "number") { + typeOrder = left - right; + } + else if (typeof left === "boolean" && typeof right === "boolean") { + typeOrder = Number(left) - Number(right); + } + } + return typeOrder; + } + function toMatrix(data) { + if (data === undefined) { + return [[]]; + } + return isMatrix(data) ? data : [[data]]; + } + /** + * Flatten an array of items, where each item can be a single value or a 2D array, and apply the + * callback to each element. + * + * The 2D array are flattened row first. + */ + function flattenRowFirst(items, callback) { + /**/ + return reduceAny(items, (array, val) => { + array.push(callback(val)); + return array; + }, [], "rowFirst"); + } + + function toCriterionDateNumber(dateValue) { + const today = new Date(); + switch (dateValue) { + case "today": + return jsDateToNumber(today); + case "yesterday": + return jsDateToNumber(new Date(today.setDate(today.getDate() - 1))); + case "tomorrow": + return jsDateToNumber(new Date(today.setDate(today.getDate() + 1))); + case "lastWeek": + return jsDateToNumber(new Date(today.setDate(today.getDate() - 7))); + case "lastMonth": + return jsDateToNumber(new Date(today.setMonth(today.getMonth() - 1))); + case "lastYear": + return jsDateToNumber(new Date(today.setFullYear(today.getFullYear() - 1))); + } + } + /** Get all the dates values of a criterion converted to numbers, converting date values such as "today" to actual dates */ + function getDateNumberCriterionValues(criterion, locale) { + if ("dateValue" in criterion && criterion.dateValue !== "exactDate") { + return [toCriterionDateNumber(criterion.dateValue)]; + } + return criterion.values.map((value) => valueToDateNumber(value, locale)); + } + /** Convert the criterion values to numbers. Return undefined values if they cannot be converted to numbers. */ + function getCriterionValuesAsNumber(criterion, locale) { + return criterion.values.map((value) => tryToNumber(value, locale)); + } + + const MAX_DELAY = 140; + const MIN_DELAY = 20; + const ACCELERATION = 0.035; + /** + * Decreasing exponential function used to determine the "speed" of edge-scrolling + * as the timeout delay. + * + * Returns a timeout delay in milliseconds. + */ + function scrollDelay(value) { + // decreasing exponential from MAX_DELAY to MIN_DELAY + return MIN_DELAY + (MAX_DELAY - MIN_DELAY) * Math.exp(-ACCELERATION * (value - 1)); + } + /** * Constant used to indicate the maximum of digits that is possible to display * in a cell with standard size. @@ -2716,7 +3733,7 @@ */ function recomputeZones(zonesXc, toRemoveZonesXc) { const zones = zonesXc.map(toUnboundedZone); - const zonesToRemove = toRemoveZonesXc.map(toZone); + const zonesToRemove = toRemoveZonesXc.map(toUnboundedZone); // Compute the max to replace the bottom of full columns and right of full rows by something // bigger than any other col/row to be able to apply the algorithm while keeping tracks of what // zones are full cols/rows @@ -3212,6 +4229,18 @@ } return postProcessedRanges; } + /** + * Get all the cell positions in the given ranges. If a cell is in multiple ranges, it will be returned multiple times. + */ + function getCellPositionsInRanges(ranges) { + const cellPositions = []; + for (const range of ranges) { + for (const position of positions(range.zone)) { + cellPositions.push({ ...position, sheetId: range.sheetId }); + } + } + return cellPositions; + } /** Methods from Odoo Web Utils */ /** @@ -4096,918 +5125,6 @@ return undefined; } - var CellValueType; - (function (CellValueType) { - CellValueType["boolean"] = "boolean"; - CellValueType["number"] = "number"; - CellValueType["text"] = "text"; - CellValueType["empty"] = "empty"; - CellValueType["error"] = "error"; - })(CellValueType || (CellValueType = {})); - - var ClipboardMIMEType; - (function (ClipboardMIMEType) { - ClipboardMIMEType["PlainText"] = "text/plain"; - ClipboardMIMEType["Html"] = "text/html"; - })(ClipboardMIMEType || (ClipboardMIMEType = {})); - - function isSheetDependent(cmd) { - return "sheetId" in cmd; - } - function isHeadersDependant(cmd) { - return "dimension" in cmd && "sheetId" in cmd && "elements" in cmd; - } - function isTargetDependent(cmd) { - return "target" in cmd && "sheetId" in cmd; - } - function isRangeDependant(cmd) { - return "ranges" in cmd; - } - function isZoneDependent(cmd) { - return "zone" in cmd; - } - function isPositionDependent(cmd) { - return "col" in cmd && "row" in cmd && "sheetId" in cmd; - } - const invalidateEvaluationCommands = new Set([ - "RENAME_SHEET", - "DELETE_SHEET", - "CREATE_SHEET", - "ADD_COLUMNS_ROWS", - "REMOVE_COLUMNS_ROWS", - "UNDO", - "REDO", - "ADD_MERGE", - "UPDATE_LOCALE", - ]); - const invalidateDependenciesCommands = new Set([ - ...invalidateEvaluationCommands, - "MOVE_RANGES", - ]); - const invalidateCFEvaluationCommands = new Set([ - ...invalidateEvaluationCommands, - "DUPLICATE_SHEET", - "EVALUATE_CELLS", - "ADD_CONDITIONAL_FORMAT", - "REMOVE_CONDITIONAL_FORMAT", - "MOVE_CONDITIONAL_FORMAT", - ]); - const readonlyAllowedCommands = new Set([ - "START", - "ACTIVATE_SHEET", - "COPY", - "RESIZE_SHEETVIEW", - "SET_VIEWPORT_OFFSET", - "SELECT_SEARCH_NEXT_MATCH", - "SELECT_SEARCH_PREVIOUS_MATCH", - "UPDATE_SEARCH", - "CLEAR_SEARCH", - "EVALUATE_CELLS", - "SET_CURRENT_CONTENT", - "SET_FORMULA_VISIBILITY", - "OPEN_CELL_POPOVER", - "CLOSE_CELL_POPOVER", - "UPDATE_FILTER", - ]); - const coreTypes = new Set([ - /** CELLS */ - "UPDATE_CELL", - "UPDATE_CELL_POSITION", - "CLEAR_CELL", - "DELETE_CONTENT", - /** GRID SHAPE */ - "ADD_COLUMNS_ROWS", - "REMOVE_COLUMNS_ROWS", - "RESIZE_COLUMNS_ROWS", - "HIDE_COLUMNS_ROWS", - "UNHIDE_COLUMNS_ROWS", - "SET_GRID_LINES_VISIBILITY", - "UNFREEZE_COLUMNS", - "UNFREEZE_ROWS", - "FREEZE_COLUMNS", - "FREEZE_ROWS", - "UNFREEZE_COLUMNS_ROWS", - /** MERGE */ - "ADD_MERGE", - "REMOVE_MERGE", - /** SHEETS MANIPULATION */ - "CREATE_SHEET", - "DELETE_SHEET", - "DUPLICATE_SHEET", - "MOVE_SHEET", - "RENAME_SHEET", - "HIDE_SHEET", - "SHOW_SHEET", - /** RANGES MANIPULATION */ - "MOVE_RANGES", - /** CONDITIONAL FORMAT */ - "ADD_CONDITIONAL_FORMAT", - "REMOVE_CONDITIONAL_FORMAT", - "MOVE_CONDITIONAL_FORMAT", - /** FIGURES */ - "CREATE_FIGURE", - "DELETE_FIGURE", - "UPDATE_FIGURE", - /** FORMATTING */ - "SET_FORMATTING", - "CLEAR_FORMATTING", - "SET_BORDER", - "SET_ZONE_BORDERS", - /** CHART */ - "CREATE_CHART", - "UPDATE_CHART", - /** FILTERS */ - "CREATE_FILTER_TABLE", - "REMOVE_FILTER_TABLE", - /** IMAGE */ - "CREATE_IMAGE", - "UPDATE_LOCALE", - "GROUP_HEADERS", - "UNGROUP_HEADERS", - "UNFOLD_HEADER_GROUP", - "FOLD_HEADER_GROUP", - "FOLD_ALL_HEADER_GROUPS", - "UNFOLD_ALL_HEADER_GROUPS", - "UNFOLD_HEADER_GROUPS_IN_ZONE", - "FOLD_HEADER_GROUPS_IN_ZONE", - ]); - function isCoreCommand(cmd) { - return coreTypes.has(cmd.type); - } - function canExecuteInReadonly(cmd) { - return readonlyAllowedCommands.has(cmd.type); - } - /** - * Holds the result of a command dispatch. - * The command may have been successfully dispatched or cancelled - * for one or more reasons. - */ - class DispatchResult { - reasons; - constructor(results = []) { - if (!Array.isArray(results)) { - results = [results]; - } - results = [...new Set(results)]; - this.reasons = results.filter((result) => result !== "Success" /* CommandResult.Success */); - } - /** - * Static helper which returns a successful DispatchResult - */ - static get Success() { - return new DispatchResult(); - } - get isSuccessful() { - return this.reasons.length === 0; - } - /** - * Check if the dispatch has been cancelled because of - * the given reason. - */ - isCancelledBecause(reason) { - return this.reasons.includes(reason); - } - } - exports.CommandResult = void 0; - (function (CommandResult) { - CommandResult["Success"] = "Success"; - CommandResult["CancelledForUnknownReason"] = "CancelledForUnknownReason"; - CommandResult["WillRemoveExistingMerge"] = "WillRemoveExistingMerge"; - CommandResult["MergeIsDestructive"] = "MergeIsDestructive"; - CommandResult["CellIsMerged"] = "CellIsMerged"; - CommandResult["InvalidTarget"] = "InvalidTarget"; - CommandResult["EmptyUndoStack"] = "EmptyUndoStack"; - CommandResult["EmptyRedoStack"] = "EmptyRedoStack"; - CommandResult["NotEnoughElements"] = "NotEnoughElements"; - CommandResult["NotEnoughSheets"] = "NotEnoughSheets"; - CommandResult["MissingSheetName"] = "MissingSheetName"; - CommandResult["UnchangedSheetName"] = "UnchangedSheetName"; - CommandResult["DuplicatedSheetName"] = "DuplicatedSheetName"; - CommandResult["DuplicatedSheetId"] = "DuplicatedSheetId"; - CommandResult["ForbiddenCharactersInSheetName"] = "ForbiddenCharactersInSheetName"; - CommandResult["WrongSheetMove"] = "WrongSheetMove"; - CommandResult["WrongSheetPosition"] = "WrongSheetPosition"; - CommandResult["InvalidAnchorZone"] = "InvalidAnchorZone"; - CommandResult["SelectionOutOfBound"] = "SelectionOutOfBound"; - CommandResult["TargetOutOfSheet"] = "TargetOutOfSheet"; - CommandResult["WrongCutSelection"] = "WrongCutSelection"; - CommandResult["WrongPasteSelection"] = "WrongPasteSelection"; - CommandResult["WrongPasteOption"] = "WrongPasteOption"; - CommandResult["WrongFigurePasteOption"] = "WrongFigurePasteOption"; - CommandResult["EmptyClipboard"] = "EmptyClipboard"; - CommandResult["EmptyRange"] = "EmptyRange"; - CommandResult["InvalidRange"] = "InvalidRange"; - CommandResult["InvalidZones"] = "InvalidZones"; - CommandResult["InvalidSheetId"] = "InvalidSheetId"; - CommandResult["InvalidFigureId"] = "InvalidFigureId"; - CommandResult["InputAlreadyFocused"] = "InputAlreadyFocused"; - CommandResult["MaximumRangesReached"] = "MaximumRangesReached"; - CommandResult["InvalidChartDefinition"] = "InvalidChartDefinition"; - CommandResult["InvalidDataSet"] = "InvalidDataSet"; - CommandResult["InvalidLabelRange"] = "InvalidLabelRange"; - CommandResult["InvalidScorecardKeyValue"] = "InvalidScorecardKeyValue"; - CommandResult["InvalidScorecardBaseline"] = "InvalidScorecardBaseline"; - CommandResult["InvalidGaugeDataRange"] = "InvalidGaugeDataRange"; - CommandResult["EmptyGaugeRangeMin"] = "EmptyGaugeRangeMin"; - CommandResult["GaugeRangeMinNaN"] = "GaugeRangeMinNaN"; - CommandResult["EmptyGaugeRangeMax"] = "EmptyGaugeRangeMax"; - CommandResult["GaugeRangeMaxNaN"] = "GaugeRangeMaxNaN"; - CommandResult["GaugeRangeMinBiggerThanRangeMax"] = "GaugeRangeMinBiggerThanRangeMax"; - CommandResult["GaugeLowerInflectionPointNaN"] = "GaugeLowerInflectionPointNaN"; - CommandResult["GaugeUpperInflectionPointNaN"] = "GaugeUpperInflectionPointNaN"; - CommandResult["GaugeLowerBiggerThanUpper"] = "GaugeLowerBiggerThanUpper"; - CommandResult["InvalidAutofillSelection"] = "InvalidAutofillSelection"; - CommandResult["WrongComposerSelection"] = "WrongComposerSelection"; - CommandResult["MinBiggerThanMax"] = "MinBiggerThanMax"; - CommandResult["LowerBiggerThanUpper"] = "LowerBiggerThanUpper"; - CommandResult["MidBiggerThanMax"] = "MidBiggerThanMax"; - CommandResult["MinBiggerThanMid"] = "MinBiggerThanMid"; - CommandResult["FirstArgMissing"] = "FirstArgMissing"; - CommandResult["SecondArgMissing"] = "SecondArgMissing"; - CommandResult["MinNaN"] = "MinNaN"; - CommandResult["MidNaN"] = "MidNaN"; - CommandResult["MaxNaN"] = "MaxNaN"; - CommandResult["ValueUpperInflectionNaN"] = "ValueUpperInflectionNaN"; - CommandResult["ValueLowerInflectionNaN"] = "ValueLowerInflectionNaN"; - CommandResult["MinInvalidFormula"] = "MinInvalidFormula"; - CommandResult["MidInvalidFormula"] = "MidInvalidFormula"; - CommandResult["MaxInvalidFormula"] = "MaxInvalidFormula"; - CommandResult["ValueUpperInvalidFormula"] = "ValueUpperInvalidFormula"; - CommandResult["ValueLowerInvalidFormula"] = "ValueLowerInvalidFormula"; - CommandResult["InvalidSortZone"] = "InvalidSortZone"; - CommandResult["WaitingSessionConfirmation"] = "WaitingSessionConfirmation"; - CommandResult["MergeOverlap"] = "MergeOverlap"; - CommandResult["TooManyHiddenElements"] = "TooManyHiddenElements"; - CommandResult["Readonly"] = "Readonly"; - CommandResult["InvalidViewportSize"] = "InvalidViewportSize"; - CommandResult["InvalidScrollingDirection"] = "InvalidScrollingDirection"; - CommandResult["FigureDoesNotExist"] = "FigureDoesNotExist"; - CommandResult["InvalidConditionalFormatId"] = "InvalidConditionalFormatId"; - CommandResult["InvalidCellPopover"] = "InvalidCellPopover"; - CommandResult["EmptyTarget"] = "EmptyTarget"; - CommandResult["InvalidFreezeQuantity"] = "InvalidFreezeQuantity"; - CommandResult["FrozenPaneOverlap"] = "FrozenPaneOverlap"; - CommandResult["ValuesNotChanged"] = "ValuesNotChanged"; - CommandResult["InvalidFilterZone"] = "InvalidFilterZone"; - CommandResult["FilterOverlap"] = "FilterOverlap"; - CommandResult["FilterNotFound"] = "FilterNotFound"; - CommandResult["MergeInFilter"] = "MergeInFilter"; - CommandResult["NonContinuousTargets"] = "NonContinuousTargets"; - CommandResult["DuplicatedFigureId"] = "DuplicatedFigureId"; - CommandResult["InvalidSelectionStep"] = "InvalidSelectionStep"; - CommandResult["DuplicatedChartId"] = "DuplicatedChartId"; - CommandResult["ChartDoesNotExist"] = "ChartDoesNotExist"; - CommandResult["InvalidHeaderIndex"] = "InvalidHeaderIndex"; - CommandResult["InvalidQuantity"] = "InvalidQuantity"; - CommandResult["MoreThanOneColumnSelected"] = "MoreThanOneColumnSelected"; - CommandResult["EmptySplitSeparator"] = "EmptySplitSeparator"; - CommandResult["SplitWillOverwriteContent"] = "SplitWillOverwriteContent"; - CommandResult["NoSplitSeparatorInSelection"] = "NoSplitSeparatorInSelection"; - CommandResult["NoActiveSheet"] = "NoActiveSheet"; - CommandResult["InvalidLocale"] = "InvalidLocale"; - CommandResult["AlreadyInPaintingFormatMode"] = "AlreadyInPaintingFormatMode"; - CommandResult["MoreThanOneRangeSelected"] = "MoreThanOneRangeSelected"; - CommandResult["NoColumnsProvided"] = "NoColumnsProvided"; - CommandResult["ColumnsNotIncludedInZone"] = "ColumnsNotIncludedInZone"; - CommandResult["DuplicatesColumnsSelected"] = "DuplicatesColumnsSelected"; - CommandResult["InvalidHeaderGroupStartEnd"] = "InvalidHeaderGroupStartEnd"; - CommandResult["HeaderGroupAlreadyExists"] = "HeaderGroupAlreadyExists"; - CommandResult["UnknownHeaderGroup"] = "UnknownHeaderGroup"; - })(exports.CommandResult || (exports.CommandResult = {})); - - const borderStyles = ["thin", "medium", "thick", "dashed", "dotted"]; - function isMatrix(x) { - return Array.isArray(x) && Array.isArray(x[0]); - } - var DIRECTION; - (function (DIRECTION) { - DIRECTION["UP"] = "up"; - DIRECTION["DOWN"] = "down"; - DIRECTION["LEFT"] = "left"; - DIRECTION["RIGHT"] = "right"; - })(DIRECTION || (DIRECTION = {})); - - var LAYERS; - (function (LAYERS) { - LAYERS[LAYERS["Background"] = 0] = "Background"; - LAYERS[LAYERS["Highlights"] = 1] = "Highlights"; - LAYERS[LAYERS["Clipboard"] = 2] = "Clipboard"; - LAYERS[LAYERS["Search"] = 3] = "Search"; - LAYERS[LAYERS["Chart"] = 4] = "Chart"; - LAYERS[LAYERS["Autofill"] = 5] = "Autofill"; - LAYERS[LAYERS["Selection"] = 6] = "Selection"; - LAYERS[LAYERS["Headers"] = 7] = "Headers"; - })(LAYERS || (LAYERS = {})); - - // HELPERS - const SORT_TYPES_ORDER = ["number", "string", "boolean", "undefined"]; - function assert(condition, message) { - if (!condition()) { - throw new Error(message); - } - } - // ----------------------------------------------------------------------------- - // FORMAT FUNCTIONS - // ----------------------------------------------------------------------------- - const expectNumberValueError = (value) => _t("The function [[FUNCTION_NAME]] expects a number value, but '%s' is a string, and cannot be coerced to a number.", value); - const expectNumberRangeError = (lowerBound, upperBound, value) => _t("The function [[FUNCTION_NAME]] expects a number value between %s and %s inclusive, but receives %s.", lowerBound.toString(), upperBound.toString(), value.toString()); - const expectStringSetError = (stringSet, value) => { - const stringSetString = stringSet.map((str) => `'${str}'`).join(", "); - return _t("The function [[FUNCTION_NAME]] has an argument with value '%s'. It should be one of: %s.", value, stringSetString); - }; - function toNumber(value, locale) { - switch (typeof value) { - case "number": - return value; - case "boolean": - return value ? 1 : 0; - case "string": - if (isNumber(value, locale) || value === "") { - return parseNumber(value, locale); - } - const internalDate = parseDateTime(value, locale); - if (internalDate) { - return internalDate.value; - } - throw new Error(expectNumberValueError(value)); - default: - return 0; - } - } - function tryCastAsNumberMatrix(data, argName) { - data.forEach((row) => { - row.forEach((cell) => { - if (typeof cell !== "number") { - throw new Error(_t("Function [[FUNCTION_NAME]] expects number values for %s, but got a %s.", typeof cell, argName)); - } - }); - }); - return data; - } - function strictToNumber(value, locale) { - if (value === "") { - throw new Error(expectNumberValueError(value)); - } - return toNumber(value, locale); - } - function toInteger(value, locale) { - return Math.trunc(toNumber(value, locale)); - } - function strictToInteger(value, locale) { - return Math.trunc(strictToNumber(value, locale)); - } - function assertNumberGreaterThanOrEqualToOne(value) { - assert(() => value >= 1, _t("The function [[FUNCTION_NAME]] expects a number value to be greater than or equal to 1, but receives %s.", value.toString())); - } - function toString(value) { - switch (typeof value) { - case "string": - return value; - case "number": - return value.toString(); - case "boolean": - return value ? "TRUE" : "FALSE"; - default: - return ""; - } - } - /** Normalize string by setting it to lowercase and replacing accent letters with plain letters */ - function normalizeString(str) { - return str - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, ""); - } - const expectBooleanValueError = (value) => _t("The function [[FUNCTION_NAME]] expects a boolean value, but '%s' is a text, and cannot be coerced to a number.", value); - function toBoolean(value) { - switch (typeof value) { - case "boolean": - return value; - case "string": - if (value) { - let uppercaseVal = value.toUpperCase(); - if (uppercaseVal === "TRUE") { - return true; - } - if (uppercaseVal === "FALSE") { - return false; - } - throw new Error(expectBooleanValueError(value)); - } - else { - return false; - } - case "number": - return value ? true : false; - default: - return false; - } - } - function strictToBoolean(value) { - if (value === "") { - throw new Error(expectBooleanValueError(value)); - } - return toBoolean(value); - } - function toJsDate(value, locale) { - return numberToJsDate(toNumber(value, locale)); - } - // ----------------------------------------------------------------------------- - // VISIT FUNCTIONS - // ----------------------------------------------------------------------------- - function visitArgs(args, cellCb, dataCb) { - for (let arg of args) { - if (isMatrix(arg)) { - // arg is ref to a Cell/Range - const lenRow = arg.length; - const lenCol = arg[0].length; - for (let y = 0; y < lenCol; y++) { - for (let x = 0; x < lenRow; x++) { - cellCb(arg[x][y]); - } - } - } - else { - // arg is set directly in the formula function - dataCb(arg); - } - } - } - function visitAny(args, cb) { - visitArgs(args, cb, cb); - } - function visitNumbers(args, cb, locale) { - visitArgs(args, (cellValue) => { - if (typeof cellValue === "number") { - cb(cellValue); - } - }, (argValue) => { - cb(strictToNumber(argValue, locale)); - }); - } - // ----------------------------------------------------------------------------- - // REDUCE FUNCTIONS - // ----------------------------------------------------------------------------- - function reduceArgs(args, cellCb, dataCb, initialValue, dir = "rowFirst") { - let val = initialValue; - for (let arg of args) { - if (isMatrix(arg)) { - // arg is ref to a Cell/Range - const numberOfCols = arg.length; - const numberOfRows = arg[0].length; - if (dir === "rowFirst") { - for (let row = 0; row < numberOfRows; row++) { - for (let col = 0; col < numberOfCols; col++) { - val = cellCb(val, arg[col][row]); - } - } - } - else { - for (let col = 0; col < numberOfCols; col++) { - for (let row = 0; row < numberOfRows; row++) { - val = cellCb(val, arg[col][row]); - } - } - } - } - else { - // arg is set directly in the formula function - val = dataCb(val, arg); - } - } - return val; - } - function reduceAny(args, cb, initialValue, dir = "rowFirst") { - return reduceArgs(args, cb, cb, initialValue, dir); - } - function reduceNumbers(args, cb, initialValue, locale) { - return reduceArgs(args, (acc, ArgValue) => { - if (typeof ArgValue === "number") { - return cb(acc, ArgValue); - } - return acc; - }, (acc, argValue) => { - return cb(acc, strictToNumber(argValue, locale)); - }, initialValue); - } - function reduceNumbersTextAs0(args, cb, initialValue, locale) { - return reduceArgs(args, (acc, ArgValue) => { - if (ArgValue !== undefined && ArgValue !== null) { - if (typeof ArgValue === "number") { - return cb(acc, ArgValue); - } - else if (typeof ArgValue === "boolean") { - return cb(acc, toNumber(ArgValue, locale)); - } - else { - return cb(acc, 0); - } - } - return acc; - }, (acc, argValue) => { - return cb(acc, toNumber(argValue, locale)); - }, initialValue); - } - // ----------------------------------------------------------------------------- - // MATRIX FUNCTIONS - // ----------------------------------------------------------------------------- - /** - * Generate a matrix of size nColumns x nRows and apply a callback on each position - */ - function generateMatrix(nColumns, nRows, callback) { - const returned = Array(nColumns); - for (let col = 0; col < nColumns; col++) { - returned[col] = Array(nRows); - for (let row = 0; row < nRows; row++) { - returned[col][row] = callback(col, row); - } - } - return returned; - } - function matrixMap(matrix, fn) { - if (matrix.length === 0) { - return []; - } - return generateMatrix(matrix.length, matrix[0].length, (col, row) => fn(matrix[col][row])); - } - function transposeMatrix(matrix) { - if (!matrix.length) { - return []; - } - return generateMatrix(matrix[0].length, matrix.length, (i, j) => matrix[j][i]); - } - // ----------------------------------------------------------------------------- - // CONDITIONAL EXPLORE FUNCTIONS - // ----------------------------------------------------------------------------- - /** - * This function allows to visit arguments and stop the visit if necessary. - * It is mainly used to bypass argument evaluation for functions like OR or AND. - */ - function conditionalVisitArgs(args, cellCb, dataCb) { - for (let arg of args) { - if (isMatrix(arg)) { - // arg is ref to a Cell/Range - const lenRow = arg.length; - const lenCol = arg[0].length; - for (let y = 0; y < lenCol; y++) { - for (let x = 0; x < lenRow; x++) { - if (!cellCb(arg[x][y] ?? undefined)) - return; - } - } - } - else { - // arg is set directly in the formula function - if (!dataCb(arg)) - return; - } - } - } - function conditionalVisitBoolean(args, cb) { - return conditionalVisitArgs(args, (ArgValue) => { - if (typeof ArgValue === "boolean") { - return cb(ArgValue); - } - if (typeof ArgValue === "number") { - return cb(ArgValue ? true : false); - } - return true; - }, (argValue) => { - if (argValue !== undefined && argValue !== null) { - return cb(strictToBoolean(argValue)); - } - return true; - }); - } - function getPredicate(descr, isQuery, locale) { - let operator; - let operand; - let subString = descr.substring(0, 2); - if (subString === "<=" || subString === ">=" || subString === "<>") { - operator = subString; - operand = descr.substring(2); - } - else { - subString = descr.substring(0, 1); - if (subString === "<" || subString === ">" || subString === "=") { - operator = subString; - operand = descr.substring(1); - } - else { - operator = "="; - operand = descr; - } - } - if (isNumber(operand, locale)) { - operand = toNumber(operand, locale); - } - else if (operand === "TRUE" || operand === "FALSE") { - operand = toBoolean(operand); - } - const result = { operator, operand }; - if (typeof operand === "string") { - if (isQuery) { - operand += "*"; - } - result.regexp = operandToRegExp(operand); - } - return result; - } - function operandToRegExp(operand) { - let exp = ""; - let predecessor = ""; - for (let char of operand) { - if (char === "?" && predecessor !== "~") { - exp += "."; - } - else if (char === "*" && predecessor !== "~") { - exp += ".*"; - } - else { - if (char === "*" || char === "?") { - //remove "~" - exp = exp.slice(0, -1); - } - if (["^", ".", "[", "]", "$", "(", ")", "*", "+", "?", "|", "{", "}", "\\"].includes(char)) { - exp += "\\"; - } - exp += char; - } - predecessor = char; - } - return new RegExp("^" + exp + "$", "i"); - } - function evaluatePredicate(value, criterion) { - const { operator, operand } = criterion; - if (value === undefined || operand === undefined || value === null || operand === null) { - return false; - } - if (typeof operand === "number" && operator === "=") { - return toString(value) === toString(operand); - } - if (operator === "<>" || operator === "=") { - let result; - if (typeof value === typeof operand) { - if (typeof value === "string" && criterion.regexp) { - result = criterion.regexp.test(value); - } - else { - result = value === operand; - } - } - else { - result = false; - } - return operator === "=" ? result : !result; - } - if (typeof value === typeof operand) { - switch (operator) { - case "<": - return value < operand; - case ">": - return value > operand; - case "<=": - return value <= operand; - case ">=": - return value >= operand; - } - } - return false; - } - /** - * Functions used especially for predicate evaluation on ranges. - * - * Take ranges with same dimensions and take predicates, one for each range. - * For (i, j) coordinates, if all elements with coordinates (i, j) of each - * range correspond to the associated predicate, then the function uses a callback - * function with the parameters "i" and "j". - * - * Syntax: - * visitMatchingRanges([range1, predicate1, range2, predicate2, ...], cb(i,j), likeSelection) - * - * - range1 (range): The range to check against predicate1. - * - predicate1 (string): The pattern or test to apply to range1. - * - range2: (range, repeatable) ranges to check. - * - predicate2 (string, repeatable): Additional pattern or test to apply to range2. - * - * - cb(i: number, j: number) => void: the callback function. - * - * - isQuery (boolean) indicates if the comparison with a string should be done as a SQL-like query. - * (Ex1 isQuery = true, predicate = "abc", element = "abcde": predicate match the element), - * (Ex2 isQuery = false, predicate = "abc", element = "abcde": predicate not match the element). - * (Ex3 isQuery = true, predicate = "abc", element = "abc": predicate match the element), - * (Ex4 isQuery = false, predicate = "abc", element = "abc": predicate match the element). - */ - function visitMatchingRanges(args, cb, locale, isQuery = false) { - const countArg = args.length; - if (countArg % 2 === 1) { - throw new Error(_t(`Function [[FUNCTION_NAME]] expects criteria_range and criterion to be in pairs.`)); - } - const dimRow = args[0].length; - const dimCol = args[0][0].length; - let predicates = []; - for (let i = 0; i < countArg - 1; i += 2) { - const criteriaRange = args[i]; - if (!isMatrix(criteriaRange) || - criteriaRange.length !== dimRow || - criteriaRange[0].length !== dimCol) { - throw new Error(_t(`Function [[FUNCTION_NAME]] expects criteria_range to have the same dimension`)); - } - const description = toString(args[i + 1]); - predicates.push(getPredicate(description, isQuery, locale)); - } - for (let i = 0; i < dimRow; i++) { - for (let j = 0; j < dimCol; j++) { - let validatedPredicates = true; - for (let k = 0; k < countArg - 1; k += 2) { - const criteriaValue = args[k][i][j]; - const criterion = predicates[k / 2]; - validatedPredicates = evaluatePredicate(criteriaValue ?? undefined, criterion); - if (!validatedPredicates) { - break; - } - } - if (validatedPredicates) { - cb(i, j); - } - } - } - } - // ----------------------------------------------------------------------------- - // COMMON FUNCTIONS - // ----------------------------------------------------------------------------- - /** - * Perform a dichotomic search on an array and return the index of the nearest match. - * - * The array should be sorted, if not an incorrect value might be returned. In the case where multiple - * element of the array match the target, the method will return the first match if the array is sorted - * in descending order, and the last match if the array is in ascending order. - * - * - * @param data the array in which to search. - * @param target the value to search. - * @param mode "nextGreater/nextSmaller" : return next greater/smaller value if no exact match is found. - * @param sortOrder whether the array is sorted in ascending or descending order. - * @param rangeLength the number of elements to consider in the search array. - * @param getValueInData function returning the element at index i in the search array. - */ - function dichotomicSearch(data, target, mode, sortOrder, rangeLength, getValueInData) { - if (target === null || target === undefined) { - return -1; - } - const _target = normalizeValue(target); - const targetType = typeof _target; - let matchVal = undefined; - let matchValIndex = undefined; - let indexLeft = 0; - let indexRight = rangeLength - 1; - let indexMedian; - let currentIndex; - let currentVal; - let currentType; - while (indexRight - indexLeft >= 0) { - indexMedian = Math.floor((indexLeft + indexRight) / 2); - currentIndex = indexMedian; - currentVal = normalizeValue(getValueInData(data, currentIndex)); - currentType = typeof currentVal; - // 1 - linear search to find value with the same type - while (indexLeft < currentIndex && targetType !== currentType) { - currentIndex--; - currentVal = normalizeValue(getValueInData(data, currentIndex)); - currentType = typeof currentVal; - } - if (currentType !== targetType || currentVal === undefined || currentVal === null) { - indexLeft = indexMedian + 1; - continue; - } - // 2 - check if value match - if (mode === "strict" && currentVal === _target) { - matchVal = currentVal; - matchValIndex = currentIndex; - } - else if (mode === "nextSmaller" && currentVal <= _target) { - if (matchVal === undefined || - matchVal === null || - matchVal < currentVal || - (matchVal === currentVal && sortOrder === "asc" && matchValIndex < currentIndex) || - (matchVal === currentVal && sortOrder === "desc" && matchValIndex > currentIndex)) { - matchVal = currentVal; - matchValIndex = currentIndex; - } - } - else if (mode === "nextGreater" && currentVal >= _target) { - if (matchVal === undefined || - matchVal > currentVal || - (matchVal === currentVal && sortOrder === "asc" && matchValIndex < currentIndex) || - (matchVal === currentVal && sortOrder === "desc" && matchValIndex > currentIndex)) { - matchVal = currentVal; - matchValIndex = currentIndex; - } - } - // 3 - give new indexes for the Binary search - if ((sortOrder === "asc" && currentVal > _target) || - (sortOrder === "desc" && currentVal <= _target)) { - indexRight = currentIndex - 1; - } - else { - indexLeft = indexMedian + 1; - } - } - // note that valMinIndex could be 0 - return matchValIndex !== undefined ? matchValIndex : -1; - } - /** - * Perform a linear search and return the index of the match. - * -1 is returned if no value is found. - * - * Example: - * - [3, 6, 10], 3 => 0 - * - [3, 6, 10], 6 => 1 - * - [3, 6, 10], 9 => -1 - * - [3, 6, 10], 2 => -1 - * - * @param data the array to search in. - * @param target the value to search in the array. - * @param mode if "strict" return exact match index. "nextGreater" returns the next greater - * element from the target and "nextSmaller" the next smaller - * @param numberOfValues the number of elements to consider in the search array. - * @param getValueInData function returning the element at index i in the search array. - * @param reverseSearch if true, search in the array starting from the end. - - */ - function linearSearch(data, target, mode, numberOfValues, getValueInData, reverseSearch = false) { - if (target === null || target === undefined) - return -1; - const _target = normalizeValue(target); - const getValue = reverseSearch - ? (data, i) => getValueInData(data, numberOfValues - i - 1) - : getValueInData; - let closestMatch = undefined; - let closestMatchIndex = -1; - for (let i = 0; i < numberOfValues; i++) { - const value = normalizeValue(getValue(data, i)); - if (value === _target) { - return reverseSearch ? numberOfValues - i - 1 : i; - } - if (mode === "nextSmaller") { - if ((!closestMatch && compareCellValues(_target, value) >= 0) || - (compareCellValues(_target, value) >= 0 && compareCellValues(value, closestMatch) > 0)) { - closestMatch = value; - closestMatchIndex = i; - } - } - else if (mode === "nextGreater") { - if ((!closestMatch && compareCellValues(_target, value) <= 0) || - (compareCellValues(_target, value) <= 0 && compareCellValues(value, closestMatch) < 0)) { - closestMatch = value; - closestMatchIndex = i; - } - } - } - return reverseSearch ? numberOfValues - closestMatchIndex - 1 : closestMatchIndex; - } - /** - * Normalize a value. - * If the cell value is a string, this will set it to lowercase and replacing accent letters with plain letters - */ - function normalizeValue(value) { - return typeof value === "string" ? normalizeString(value) : value; - } - function compareCellValues(left, right) { - let typeOrder = SORT_TYPES_ORDER.indexOf(typeof left) - SORT_TYPES_ORDER.indexOf(typeof right); - if (typeOrder === 0) { - if (typeof left === "string" && typeof right === "string") { - typeOrder = left.localeCompare(right); - } - else if (typeof left === "number" && typeof right === "number") { - typeOrder = left - right; - } - else if (typeof left === "boolean" && typeof right === "boolean") { - typeOrder = Number(left) - Number(right); - } - } - return typeOrder; - } - function toMatrix(data) { - if (data === undefined) { - return [[]]; - } - return isMatrix(data) ? data : [[data]]; - } - /** - * Flatten an array of items, where each item can be a single value or a 2D array, and apply the - * callback to each element. - * - * The 2D array are flattened row first. - */ - function flattenRowFirst(items, callback) { - /**/ - return reduceAny(items, (array, val) => { - array.push(callback(val)); - return array; - }, [], "rowFirst"); - } - function evaluateLiteral(content, localeFormat) { return createEvaluatedCell(parseLiteral(content || "", localeFormat.locale), localeFormat); } @@ -5389,29 +5506,50 @@ width: ${ERROR_TOOLTIP_WIDTH}px; box-sizing: border-box !important; overflow-wrap: break-word; + + .o-error-tooltip-message { + overflow: hidden; + display: -webkit-box; /* Limit to 3 lines */ + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + } } `; class ErrorToolTip extends owl.Component { static maxSize = { maxHeight: ERROR_TOOLTIP_MAX_HEIGHT }; static template = "o-spreadsheet-ErrorToolTip"; - static components = {}; } ErrorToolTip.props = { - text: String, + errors: Array, onClosed: { type: Function, optional: true }, }; const ErrorToolTipPopoverBuilder = { onHover: (position, getters) => { const cell = getters.getEvaluatedCell(position); - if (cell.type === CellValueType.error && cell.error.logLevel > CellErrorLevel.silent) { - return { - isOpen: true, - props: { text: cell.error.message }, - Component: ErrorToolTip, - cellCorner: "TopRight", - }; + const errors = []; + if (cell.type === CellValueType.error && cell.error.isVerbose) { + errors.push({ + title: _t("Error"), + message: cell.error.message, + }); } - return { isOpen: false }; + const validationErrorMessage = getters.getInvalidDataValidationMessage(position); + if (validationErrorMessage) { + errors.push({ + title: _t("Invalid"), + message: validationErrorMessage, + }); + } + if (!errors.length) { + return { isOpen: false }; + } + return { + isOpen: true, + props: { errors: errors }, + Component: ErrorToolTip, + cellCorner: "TopRight", + }; }, }; @@ -5568,6 +5706,9 @@ }); this.state.values = this.getFilterValues(this.props.filterPosition); } + get isReadonly() { + return this.env.model.getters.isReadonly(); + } getFilterValues(position) { const sheetId = this.env.model.getters.getActiveSheetId(); const filter = this.env.model.getters.getFilter({ sheetId, ...position }); @@ -15710,13 +15851,42 @@ } return true; } - /** Change a content string to its canonical form (en_US locale) */ + /** + * Change a content string from the given locale to its canonical form (en_US locale). Don't convert date string. + * + * @example + * canonicalizeNumberContent("=SUM(1,5; 02/12/2012)", FR_LOCALE) // "=SUM(1.5, 02/12/2012)" + * canonicalizeNumberContent("125,9", FR_LOCALE) // "125.9" + * canonicalizeNumberContent("02/12/2012", FR_LOCALE) // "02/12/2012" + */ + function canonicalizeNumberContent(content, locale) { + return content.startsWith("=") + ? canonicalizeFormula$1(content, locale) + : canonicalizeNumberLiteral(content, locale); + } + /** + * Change a content string from the given locale to its canonical form (en_US locale). Also convert date string. + * This is destructive and won't preserve the original format. + * + * @example + * canonicalizeContent("=SUM(1,5; 5)", FR_LOCALE) // "=SUM(1.5, 5)" + * canonicalizeContent("125,9", FR_LOCALE) // "125.9" + * canonicalizeContent("02/12/2012", FR_LOCALE) // "12/02/2012" + * canonicalizeContent("02-12-2012", FR_LOCALE) // "12/02/2012" + */ function canonicalizeContent(content, locale) { return content.startsWith("=") ? canonicalizeFormula$1(content, locale) - : toCanonicalNumberString(content, locale); + : canonicalizeLiteral(content, locale); } - /** Change the content of a cell to its canonical form (en_US locale) */ + /** + * Change a content string from its canonical form (en_US locale) to the given locale. Also convert date string. + * + * @example + * localizeContent("=SUM(1.5, 5)", FR_LOCALE) // "=SUM(1,5; 5)" + * localizeContent("125.9", FR_LOCALE) // "125,9" + * localizeContent("12/02/2012", FR_LOCALE) // "02/12/2012" + */ function localizeContent(content, locale) { return content.startsWith("=") ? localizeFormula(content, locale) @@ -15751,30 +15921,86 @@ return localizedFormula; } /** - * Replace localized number with localized decimal separator by a number with "." as decimal separator + * Change a literal string from the given locale to its canonical form (en_US locale). Don't convert date string. + * + * @example + * canonicalizeNumberLiteral("125,9", FR_LOCALE) // "125.9" + * canonicalizeNumberLiteral("02/12/2012", FR_LOCALE) // "02/12/2012" */ - function toCanonicalNumberString(content, locale) { + function canonicalizeNumberLiteral(content, locale) { if (locale.decimalSeparator === "." || !isNumber(content, locale)) { return content; } return content.replace(locale.decimalSeparator, "."); } - function localizeLiteral(content, locale) { - if (locale.decimalSeparator === "." || !isNumber(content, DEFAULT_LOCALE)) { - return content; + /** + * Change a content string from the given locale to its canonical form (en_US locale). Also convert date string. + * This is destructive and won't preserve the original format. + * + * @example + * canonicalizeLiteral("125,9", FR_LOCALE) // "125.9" + * canonicalizeLiteral("02/12/2012", FR_LOCALE) // "12/02/2012" + * canonicalizeLiteral("02-12-2012", FR_LOCALE) // "12/02/2012" + */ + function canonicalizeLiteral(content, locale) { + if (isDateTime(content, locale)) { + const dateNumber = toNumber(content, locale); + let format = DEFAULT_LOCALE.dateFormat; + if (!Number.isInteger(dateNumber)) { + format += " " + DEFAULT_LOCALE.timeFormat; + } + return formatValue(dateNumber, { locale: DEFAULT_LOCALE, format }); + } + return canonicalizeNumberLiteral(content, locale); + } + /** + * Change a literal string from its canonical form (en_US locale) to the given locale. Don't convert date string. + * This is destructive and won't preserve the original format. + * + * @example + * localizeNumberLiteral("125.9", FR_LOCALE) // "125,9" + * localizeNumberLiteral("12/02/2012", FR_LOCALE) // "12/02/2012" + * localizeNumberLiteral("12-02-2012", FR_LOCALE) // "12/02/2012" + */ + function localizeNumberLiteral(literal, locale) { + if (locale.decimalSeparator === "." || !isNumber(literal, DEFAULT_LOCALE)) { + return literal; } const decimalNumberRegex = getDecimalNumberRegex(DEFAULT_LOCALE); - const localized = content.replace(decimalNumberRegex, (match) => { + const localized = literal.replace(decimalNumberRegex, (match) => { return match.replace(".", locale.decimalSeparator); }); return localized; } + /** + * Change a literal string from its canonical form (en_US locale) to the given locale. Also convert date string. + * + * @example + * localizeLiteral("125.9", FR_LOCALE) // "125,9" + * localizeLiteral("12/02/2012", FR_LOCALE) // "02/12/2012" + */ + function localizeLiteral(literal, locale) { + if (isDateTime(literal, DEFAULT_LOCALE)) { + const dateNumber = toNumber(literal, DEFAULT_LOCALE); + let format = locale.dateFormat; + if (!Number.isInteger(dateNumber)) { + format += " " + locale.timeFormat; + } + return formatValue(dateNumber, { locale, format }); + } + return localizeNumberLiteral(literal, locale); + } function canonicalizeCFRule(cf, locale) { return changeCFRuleLocale(cf, (content) => canonicalizeContent(content, locale)); } function localizeCFRule(cf, locale) { return changeCFRuleLocale(cf, (content) => localizeContent(content, locale)); } + function localizeDataValidationRule(rule, locale) { + const localizedDVRule = deepCopy(rule); + localizedDVRule.criterion.values = localizedDVRule.criterion.values.map((content) => localizeContent(content, locale)); + return localizedDVRule; + } function changeCFRuleLocale(rule, changeContentLocale) { rule = deepCopy(rule); switch (rule.type) { @@ -15982,26 +16208,6 @@ padding-top: 16px; padding-bottom: 16px; text-align: right; - .o-button { - border: 1px solid lightgrey; - padding: 0px 20px 0px 20px; - border-radius: 4px; - font-weight: 500; - font-size: 14px; - height: 30px; - line-height: 16px; - background: white; - margin-right: 8px; - &:hover:enabled { - background-color: rgba(0, 0, 0, 0.08); - } - } - .o-button:enabled { - cursor: pointer; - } - .o-button:last-child { - margin-right: 0px; - } } input { box-sizing: border-box; @@ -16092,7 +16298,9 @@ save() { const { col, row } = this.props.cellPosition; const locale = this.env.model.getters.getLocale(); - const label = this.link.label ? canonicalizeContent(this.link.label, locale) : this.link.url; + const label = this.link.label + ? canonicalizeNumberContent(this.link.label, locale) + : this.link.url; this.env.model.dispatch("UPDATE_CELL", { col: col, row: row, @@ -16669,6 +16877,30 @@ ["WillRemoveExistingMerge" /* CommandResult.WillRemoveExistingMerge */]: PasteInteractiveContent.willRemoveExistingMerge, }, }; + const DVTerms = { + DateIs: { + today: _t("today"), + yesterday: _t("yesterday"), + tomorrow: _t("tomorrow"), + lastWeek: _t("in the past week"), + lastMonth: _t("in the past month"), + lastYear: _t("in the past year"), + }, + DateIsBefore: { + today: _t("today"), + yesterday: _t("yesterday"), + tomorrow: _t("tomorrow"), + lastWeek: _t("one week ago"), + lastMonth: _t("one month ago"), + lastYear: _t("one year ago"), + }, + CriterionError: { + notEmptyValue: _t("The value must not be empty"), + numberValue: _t("The value must be a number"), + dateValue: _t("The value must be a date"), + validRange: _t("The value must be a valid range"), + }, + }; /** * This file contains helpers that are common to different runtime charts (mainly @@ -17887,6 +18119,17 @@ config.options.plugins.tooltip.callbacks.title = function (tooltipItems) { return tooltipItems[0].dataset.label; }; + config.options.plugins.tooltip.callbacks.label = function (tooltipItem) { + const { format, locale } = localeFormat; + const data = tooltipItem.dataset.data; + const dataIndex = tooltipItem.dataIndex; + const percentage = calculatePercentage(data, dataIndex); + const xLabel = tooltipItem.label || tooltipItem.dataset.label; + const yLabel = tooltipItem.parsed.y ?? tooltipItem.parsed; + const toolTipFormat = !format && yLabel > 1000 ? "#,##" : format; + const yLabelStr = formatValue(yLabel, { format: toolTipFormat, locale }); + return xLabel ? `${xLabel}: ${yLabelStr} (${percentage}%)` : `${yLabelStr} (${percentage}%)`; + }; return config; } function getPieColors(colors, dataSetsValues) { @@ -17897,6 +18140,15 @@ } return pieColors; } + function calculatePercentage(dataset, dataIndex) { + const numericData = dataset.filter((value) => typeof value === "number"); + const total = numericData.reduce((sum, value) => sum + value, 0); + if (!total) { + return ""; + } + const percentage = (dataset[dataIndex] / total) * 100; + return percentage.toFixed(2); + } function createPieChartRuntime(chart, getters) { const labelValues = getChartLabelValues(getters, chart.dataSets, chart.labelRange); let labels = labelValues.formattedValues; @@ -21330,15 +21582,25 @@ .addChild("split_to_columns", ["data"], { ...splitToColumns, sequence: 20, + }) + .addChild("data_validation", ["data"], { + name: _t("Data Validation"), + execute: (env) => { + env.openSidePanel("DataValidation"); + }, + icon: "o-spreadsheet-Icon.DATA_VALIDATION", + sequence: 30, separator: true, }) .addChild("add_data_filter", ["data"], { ...addDataFilter, - sequence: 30, + sequence: 40, + separator: true, }) .addChild("remove_data_filter", ["data"], { ...removeDataFilter, - sequence: 30, + sequence: 40, + separator: true, }); class OTRegistry extends Registry { @@ -21457,13 +21719,11 @@ const existingSelectionRanges = this.env.model.getters.getSelectionInput(this.id); const ranges = existingSelectionRanges.length ? existingSelectionRanges - : this.props.ranges() - ? this.props.ranges().map((xc, id) => ({ - xc, - id, - isFocused: false, - })) - : []; + : this.props.ranges().map((xc, id) => ({ + xc, + id, + isFocused: false, + })); return ranges.map((range) => ({ ...range, isValidRange: range.xc === "" || this.env.model.getters.isRangeValid(range.xc), @@ -21623,8 +21883,8 @@ }; css /* scss */ ` - .o-sidepanel-error, - .o-sidepanel-warning { + .o-validation-error, + .o-validation-warning { margin-top: 10px; .o-icon { @@ -21634,23 +21894,23 @@ } } `; - class SidePanelErrors extends owl.Component { - static template = "o-spreadsheet-SidePanelErrors"; + class ValidationMessages extends owl.Component { + static template = "o-spreadsheet-ValidationMessages"; get divClasses() { if (this.props.msgType === "warning") { - return "o-sidepanel-warning text-warning"; + return "o-validation-warning text-warning"; } - return "o-sidepanel-error text-danger"; + return "o-validation-error text-danger"; } } - SidePanelErrors.props = { + ValidationMessages.props = { messages: Array, msgType: String, }; class LineBarPieConfigPanel extends owl.Component { static template = "o-spreadsheet-LineBarPieConfigPanel"; - static components = { SelectionInput, SidePanelErrors }; + static components = { SelectionInput, ValidationMessages }; state = owl.useState({ datasetDispatchResult: undefined, labelsDispatchResult: undefined, @@ -22336,7 +22596,7 @@ class GaugeChartConfigPanel extends owl.Component { static template = "o-spreadsheet-GaugeChartConfigPanel"; - static components = { SelectionInput, SidePanelErrors }; + static components = { SelectionInput, ValidationMessages }; state = owl.useState({ dataRangeDispatchResult: undefined, }); @@ -22403,7 +22663,7 @@ `; class GaugeChartDesignPanel extends owl.Component { static template = "o-spreadsheet-GaugeChartDesignPanel"; - static components = { ColorPickerWidget, SidePanelErrors }; + static components = { ColorPickerWidget, ValidationMessages }; state = owl.useState({ title: "", openedMenu: undefined, @@ -22521,7 +22781,7 @@ class ScorecardChartConfigPanel extends owl.Component { static template = "o-spreadsheet-ScorecardChartConfigPanel"; - static components = { SelectionInput, SidePanelErrors }; + static components = { SelectionInput, ValidationMessages }; state = owl.useState({ keyValueDispatchResult: undefined, baselineDispatchResult: undefined, @@ -22895,7 +23155,445 @@ onIconPicked: Function, }; - // TODO vsc: add ordering of rules + function useDragAndDropListItems() { + let dndHelper; + const previousCursor = document.body.style.cursor; + let cleanupFns = []; + const cleanUp = () => { + dndHelper = undefined; + document.body.style.cursor = previousCursor; + cleanupFns.forEach((fn) => fn()); + cleanupFns = []; + }; + const start = (direction, args) => { + const onChange = () => { + document.body.style.cursor = "move"; + if (!dndHelper) + return; + Object.assign(state.itemsStyle, dndHelper.getItemStyles()); + args.onChange?.(); + }; + state.cancel = () => { + state.draggedItemId = undefined; + state.itemsStyle = {}; + document.body.style.cursor = previousCursor; + args.onCancel?.(); + }; + const onDragEnd = (itemId, indexAtEnd) => { + state.draggedItemId = undefined; + state.itemsStyle = {}; + document.body.style.cursor = previousCursor; + args.onDragEnd?.(itemId, indexAtEnd); + cleanUp(); + }; + document.body.style.cursor = "move"; + state.draggedItemId = args.draggedItemId; + const container = direction === "horizontal" + ? new HorizontalContainer(args.containerEl) + : new VerticalContainer(args.containerEl); + dndHelper = new DOMDndHelper({ + ...args, + container, + onChange, + onDragEnd, + onCancel: state.cancel, + }); + startDnd(dndHelper.onMouseMove.bind(dndHelper), dndHelper.onMouseUp.bind(dndHelper)); + const onScroll = dndHelper.onScroll.bind(dndHelper); + args.containerEl.addEventListener("scroll", onScroll); + cleanupFns.push(() => args.containerEl.removeEventListener("scroll", onScroll)); + cleanupFns.push(dndHelper.destroy.bind(dndHelper)); + }; + owl.onWillUnmount(() => { + cleanUp(); + }); + const state = owl.useState({ + itemsStyle: {}, + draggedItemId: undefined, + start, + cancel: () => { }, + }); + return state; + } + class DOMDndHelper { + draggedItemId; + items; + container; + initialMousePosition; + currentMousePosition; + initialScroll; + minPosition; + maxPosition; + edgeScrollIntervalId; + onChange; + onCancel; + onDragEnd; + /** + * The dead zone is an area in which the mousemove events are ignored. + * + * This is useful when swapping the dragged item with a larger item. After the swap, + * the mouse is still hovering on the item we just swapped with. In this case, we don't want + * a mouse move to trigger another swap the other way around, so we create a dead zone. We will clear + * the dead zone when the mouse leaves the swapped item. + */ + deadZone; + constructor(args) { + this.items = args.items.map((item) => ({ ...item, positionAtStart: item.position })); + this.draggedItemId = args.draggedItemId; + this.container = args.container; + this.onChange = args.onChange; + this.onCancel = args.onCancel; + this.onDragEnd = args.onDragEnd; + this.initialMousePosition = args.initialMousePosition; + this.currentMousePosition = args.initialMousePosition; + this.initialScroll = this.container.scroll; + this.minPosition = this.items[0].position; + this.maxPosition = + this.items[this.items.length - 1].position + this.items[this.items.length - 1].size; + } + getItemStyles() { + const styles = {}; + for (let item of this.items) { + styles[item.id] = this.getItemStyle(item.id); + } + return styles; + } + getItemStyle(itemId) { + const position = this.container.cssPositionProperty; + const style = {}; + style.position = "relative"; + style[position] = (this.getItemsPositions()[itemId] || 0) + "px"; + style.transition = `${position} 0.5s`; + style["pointer-events"] = "none"; + if (this.draggedItemId === itemId) { + style.transition = `${position} 0s`; + style["z-index"] = "1000"; + } + return cssPropertiesToCss(style); + } + onScroll() { + this.moveDraggedItemToPosition(this.currentMousePosition + this.scrollOffset); + } + onMouseMove(ev) { + if (ev.button !== 0) { + this.onCancel(); + return; + } + const mousePosition = this.container.getMousePosition(ev); + this.currentMousePosition = mousePosition; + if (mousePosition < this.container.start || mousePosition > this.container.end) { + this.startEdgeScroll(mousePosition < this.container.start ? -1 : 1); + return; + } + else { + this.stopEdgeScroll(); + } + this.moveDraggedItemToPosition(mousePosition + this.scrollOffset); + } + moveDraggedItemToPosition(position) { + const hoveredItemIndex = this.getHoveredItemIndex(position, this.items); + const draggedItemIndex = this.items.findIndex((item) => item.id === this.draggedItemId); + const draggedItem = this.items[draggedItemIndex]; + if (this.deadZone && this.isInZone(position, this.deadZone)) { + this.onChange(this.getItemsPositions()); + return; + } + else if (this.isInZone(position, { + start: draggedItem.position, + end: draggedItem.position + draggedItem.size, + })) { + this.deadZone = undefined; + } + if (draggedItemIndex === hoveredItemIndex) { + this.onChange(this.getItemsPositions()); + return; + } + const startIndex = Math.min(draggedItemIndex, hoveredItemIndex); + const endIndex = Math.max(draggedItemIndex, hoveredItemIndex); + const direction = Math.sign(hoveredItemIndex - draggedItemIndex); + let draggedItemMoveSize = 0; + for (let i = startIndex; i <= endIndex; i++) { + if (i === draggedItemIndex) { + continue; + } + this.items[i].position -= direction * draggedItem.size; + draggedItemMoveSize += this.items[i].size; + } + draggedItem.position += direction * draggedItemMoveSize; + this.items.sort((item1, item2) => item1.position - item2.position); + this.deadZone = + direction > 0 + ? { start: position, end: draggedItem.position } + : { start: draggedItem.position + draggedItem.size, end: position }; + this.onChange(this.getItemsPositions()); + } + onMouseUp(ev) { + if (ev.button !== 0) { + this.onCancel(); + } + ev.stopPropagation(); + ev.preventDefault(); + const targetItemIndex = this.items.findIndex((item) => item.id === this.draggedItemId); + this.onDragEnd(this.draggedItemId, targetItemIndex); + this.stopEdgeScroll(); + return false; + } + startEdgeScroll(direction) { + if (this.edgeScrollIntervalId) + return; + this.edgeScrollIntervalId = window.setInterval(() => { + const offset = direction * 3; + let newPosition = this.currentMousePosition + offset; + if (newPosition < Math.min(this.container.start, this.minPosition)) { + newPosition = Math.min(this.container.start, this.minPosition); + } + else if (newPosition > Math.max(this.container.end, this.maxPosition)) { + newPosition = Math.max(this.container.end, this.maxPosition); + } + this.container.scroll += offset; + }, 5); + } + stopEdgeScroll() { + window.clearInterval(this.edgeScrollIntervalId); + this.edgeScrollIntervalId = undefined; + } + /** + * Get the index of the item the given mouse position is inside. + * If the mouse is outside the container, return the first or last item index. + */ + getHoveredItemIndex(mousePosition, items) { + if (mousePosition <= this.minPosition) + return 0; + if (mousePosition >= this.maxPosition) + return items.length - 1; + return items.findIndex((item) => item.position + item.size >= mousePosition); + } + getItemsPositions() { + const positions = {}; + for (let item of this.items) { + if (item.id !== this.draggedItemId) { + positions[item.id] = item.position - item.positionAtStart; + continue; + } + const mouseOffset = this.currentMousePosition - this.initialMousePosition; + let start = mouseOffset + this.scrollOffset; + start = Math.max(this.minPosition - item.positionAtStart, start); + start = Math.min(this.maxPosition - item.positionAtStart - item.size, start); + positions[item.id] = start; + } + return positions; + } + isInZone(position, zone) { + return position >= zone.start && position <= zone.end; + } + get scrollOffset() { + return this.container.scroll - this.initialScroll; + } + destroy() { + this.stopEdgeScroll(); + } + } + class ContainerWrapper { + el; + constructor(el) { + this.el = el; + } + get containerRect() { + return this.el.getBoundingClientRect(); + } + } + class VerticalContainer extends ContainerWrapper { + get start() { + return this.containerRect.top; + } + get end() { + return this.containerRect.bottom; + } + get cssPositionProperty() { + return "top"; + } + get scroll() { + return this.el.scrollTop; + } + set scroll(scroll) { + this.el.scrollTop = scroll; + } + getMousePosition(ev) { + return ev.clientY; + } + } + class HorizontalContainer extends ContainerWrapper { + get start() { + return this.containerRect.left; + } + get end() { + return this.containerRect.right; + } + get cssPositionProperty() { + return "left"; + } + get scroll() { + return this.el.scrollLeft; + } + set scroll(scroll) { + this.el.scrollLeft = scroll; + } + getMousePosition(ev) { + return ev.clientX; + } + } + + css /* scss */ ` + .o-cf-preview-list { + .o-cf-preview { + &.o-cf-cursor-ptr { + cursor: pointer; + } + + border-bottom: 1px solid #ccc; + height: 60px; + padding: 10px; + position: relative; + cursor: pointer; + &:hover, + &.o-cf-dragging { + background-color: #ebebeb; + } + + &:not(:hover) .o-cf-delete-button { + display: none; + } + .o-cf-preview-icon { + border: 1px solid lightgrey; + position: absolute; + height: 50px; + width: 50px; + } + .o-cf-preview-description { + left: 65px; + margin-bottom: auto; + margin-right: 8px; + margin-top: auto; + position: relative; + width: 142px; + .o-cf-preview-description-rule { + margin-bottom: 4px; + font-weight: 600; + color: #303030; + max-height: 2.8em; + line-height: 1.4em; + } + .o-cf-preview-range { + font-size: 12px; + } + } + .o-cf-delete { + left: 90%; + top: 39%; + position: absolute; + } + &:not(:hover):not(.o-cf-dragging) .o-cf-drag-handle { + display: none !important; + } + .o-cf-drag-handle { + left: -8px; + cursor: move; + .o-icon { + width: 6px; + height: 30px; + } + } + } + } +`; + class ConditionalFormatPreviewList extends owl.Component { + static template = "o-spreadsheet-ConditionalFormatPreviewList"; + icons = ICONS; + dragAndDrop = useDragAndDropListItems(); + cfListRef = owl.useRef("cfList"); + setup() { + owl.onWillUpdateProps((nextProps) => { + if (!deepEquals(this.props.conditionalFormats, nextProps.conditionalFormats)) { + this.dragAndDrop.cancel(); + } + }); + } + getPreviewDivStyle(cf) { + return this.dragAndDrop.itemsStyle[cf.id] || ""; + } + getPreviewImageStyle(rule) { + if (rule.type === "CellIsRule") { + return cssPropertiesToCss(cellStyleToCss(rule.style)); + } + else if (rule.type === "ColorScaleRule") { + const minColor = colorNumberString(rule.minimum.color); + const midColor = rule.midpoint ? colorNumberString(rule.midpoint.color) : null; + const maxColor = colorNumberString(rule.maximum.color); + const baseString = "background-image: linear-gradient(to right, "; + return midColor + ? baseString + minColor + ", " + midColor + ", " + maxColor + ")" + : baseString + minColor + ", " + maxColor + ")"; + } + return ""; + } + getDescription(cf) { + switch (cf.rule.type) { + case "CellIsRule": + const description = CellIsOperators[cf.rule.operator]; + if (cf.rule.values.length === 1) { + return `${description} ${cf.rule.values[0]}`; + } + if (cf.rule.values.length === 2) { + return _t("%s %s and %s", description, cf.rule.values[0], cf.rule.values[1]); + } + return description; + case "ColorScaleRule": + return CfTerms.ColorScale; + case "IconSetRule": + return CfTerms.IconSet; + } + } + deleteConditionalFormat(cf) { + this.env.model.dispatch("REMOVE_CONDITIONAL_FORMAT", { + id: cf.id, + sheetId: this.env.model.getters.getActiveSheetId(), + }); + } + onMouseDown(cf, event) { + if (event.button !== 0) + return; + const previewRects = Array.from(this.cfListRef.el.children).map((previewEl) => getBoundingRectAsPOJO(previewEl)); + const items = this.props.conditionalFormats.map((cf, index) => ({ + id: cf.id, + size: previewRects[index].height, + position: previewRects[index].y, + })); + this.dragAndDrop.start("vertical", { + draggedItemId: cf.id, + initialMousePosition: event.clientY, + items: items, + containerEl: this.cfListRef.el, + onDragEnd: (cfId, finalIndex) => this.onDragEnd(cfId, finalIndex), + }); + } + onDragEnd(cfId, finalIndex) { + const originalIndex = this.props.conditionalFormats.findIndex((sheet) => sheet.id === cfId); + const delta = originalIndex - finalIndex; + if (delta !== 0) { + this.env.model.dispatch("CHANGE_CONDITIONAL_FORMAT_PRIORITY", { + cfId, + delta, + sheetId: this.env.model.getters.getActiveSheetId(), + }); + } + } + } + ConditionalFormatPreviewList.props = { + conditionalFormats: Array, + onPreviewClick: Function, + onAddConditionalFormat: Function, + }; + css /* scss */ ` label { vertical-align: middle; @@ -22925,97 +23623,6 @@ .o-section-subtitle:first-child { margin-top: 0px; } - .o-cf-cursor-ptr { - cursor: pointer; - } - .o-cf-preview { - background-color: #fff; - border-bottom: 1px solid #ccc; - display: flex; - height: 60px; - padding: 10px; - position: relative; - &:hover { - background-color: rgba(0, 0, 0, 0.08); - } - &:not(:hover) .o-cf-delete-button { - display: none; - } - .o-cf-preview-image { - border: 1px solid lightgrey; - height: 50px; - line-height: 50px; - margin-right: 15px; - margin-top: 3px; - position: absolute; - text-align: center; - width: 50px; - } - .o-cf-preview-icon { - border: 1px solid lightgrey; - position: absolute; - height: 50px; - line-height: 50px; - margin-right: 15px; - margin-top: 3px; - display: flex; - justify-content: space-around; - align-items: center; - } - .o-cf-preview-description { - left: 65px; - margin-bottom: auto; - margin-right: 8px; - margin-top: auto; - position: relative; - width: 142px; - .o-cf-preview-description-rule { - margin-bottom: 4px; - overflow: hidden; - text-overflow: ellipsis; - font-weight: 600; - color: #303030; - max-height: 2.8em; - line-height: 1.4em; - } - .o-cf-preview-range { - text-overflow: ellipsis; - font-size: 12px; - overflow: hidden; - } - } - .o-cf-delete { - color: dimgrey; - left: 90%; - top: 39%; - position: absolute; - } - .o-cf-reorder { - color: gray; - left: 90%; - position: absolute; - height: 100%; - width: 10%; - } - .o-cf-reorder-button:hover { - cursor: pointer; - background-color: rgba(0, 0, 0, 0.08); - } - .o-cf-reorder-button-up { - width: 15px; - height: 20px; - padding: 5px; - padding-top: 0px; - } - .o-cf-reorder-button-down { - width: 15px; - height: 20px; - bottom: 20px; - padding: 5px; - padding-top: 0px; - position: absolute; - } - } .o-cf-ruleEditor { font-size: 12px; line-height: 1.5; @@ -23030,17 +23637,6 @@ line-height: 35px; } } - .o-cf-btn-link { - font-size: 14px; - padding: 20px 24px 11px 24px; - height: 44px; - cursor: pointer; - text-decoration: none; - } - .o-cf-btn-link:hover { - color: #003a39; - text-decoration: none; - } .o-cf-error { color: red; margin-top: 10px; @@ -23167,116 +23763,54 @@ } } `; - class ConditionalFormattingPanel extends owl.Component { - static template = "o-spreadsheet-ConditionalFormattingPanel"; - static components = { SelectionInput, IconPicker, ColorPickerWidget }; + class ConditionalFormattingEditor extends owl.Component { + static template = "o-spreadsheet-ConditionalFormattingEditor"; + static components = { + SelectionInput, + IconPicker, + ColorPickerWidget, + ConditionalFormatPreviewList, + }; icons = ICONS; cellIsOperators = CellIsOperators; iconSets = ICON_SETS; getTextDecoration = getTextDecoration; colorNumberString = colorNumberString; - activeSheetId; state; setup() { - this.activeSheetId = this.env.model.getters.getActiveSheetId(); + const cf = this.props.editedCf || { + id: this.env.model.uuidGenerator.uuidv4(), + ranges: this.env.model.getters + .getSelectedZones() + .map((zone) => this.env.model.getters.zoneToXC(this.env.model.getters.getActiveSheetId(), zone)), + }; this.state = owl.useState({ - mode: "list", + currentCF: cf, + currentCFType: this.props.editedCf?.rule.type || "CellIsRule", errors: [], rules: this.getDefaultRules(), }); - const sheetId = this.env.model.getters.getActiveSheetId(); - const rules = this.env.model.getters.getRulesSelection(sheetId, this.props.selection || []); - if (rules.length === 1) { - const cf = this.conditionalFormats.find((c) => c.id === rules[0]); - if (cf) { - this.editConditionalFormat(cf); + if (this.props.editedCf) { + switch (this.props.editedCf.rule.type) { + case "CellIsRule": + this.state.rules.cellIs = this.props.editedCf.rule; + break; + case "ColorScaleRule": + this.state.rules.colorScale = this.props.editedCf.rule; + break; + case "IconSetRule": + this.state.rules.iconSet = this.props.editedCf.rule; + break; } } - owl.onWillUpdateProps((nextProps) => { - const newActiveSheetId = this.env.model.getters.getActiveSheetId(); - if (newActiveSheetId !== this.activeSheetId) { - this.activeSheetId = newActiveSheetId; - this.switchToList(); - } - else if (nextProps.selection !== this.props.selection) { - const sheetId = this.env.model.getters.getActiveSheetId(); - const rules = this.env.model.getters.getRulesSelection(sheetId, nextProps.selection || []); - if (rules.length === 1) { - const cf = this.conditionalFormats.find((c) => c.id === rules[0]); - if (cf) { - this.editConditionalFormat(cf); - } - } - else { - this.switchToList(); - } - } - }); owl.useExternalListener(window, "click", this.closeMenus); } - get conditionalFormats() { - const cfs = this.env.model.getters.getConditionalFormats(this.env.model.getters.getActiveSheetId()); - return cfs.map((cf) => ({ - ...cf, - rule: localizeCFRule(cf.rule, this.env.model.getters.getLocale()), - })); - } get isRangeValid() { return this.state.errors.includes("EmptyRange" /* CommandResult.EmptyRange */); } errorMessage(error) { return CfTerms.Errors[error] || CfTerms.Errors.Unexpected; } - /** - * Switch to the list view - */ - switchToList() { - this.state.mode = "list"; - this.state.currentCF = undefined; - this.state.currentCFType = undefined; - this.state.errors = []; - this.state.rules = this.getDefaultRules(); - } - getStyle(rule) { - if (rule.type === "CellIsRule") { - return cssPropertiesToCss({ - "font-weight": rule.style.bold ? "bold" : "normal", - "text-decoration": getTextDecoration(rule.style), - "font-style": rule.style.italic ? "italic" : "normal", - color: rule.style.textColor || "none", - "background-color": rule.style.fillColor || "none", - }); - } - else if (rule.type === "ColorScaleRule") { - const minColor = colorNumberString(rule.minimum.color); - const midColor = rule.midpoint ? colorNumberString(rule.midpoint.color) : null; - const maxColor = colorNumberString(rule.maximum.color); - const baseString = "background-image: linear-gradient(to right, "; - return midColor - ? baseString + minColor + ", " + midColor + ", " + maxColor + ")" - : baseString + minColor + ", " + maxColor + ")"; - } - return ""; - } - getDescription(cf) { - switch (cf.rule.type) { - case "CellIsRule": - const description = CellIsOperators[cf.rule.operator]; - if (cf.rule.values.length === 1) { - return `${description} ${cf.rule.values[0]}`; - } - if (cf.rule.values.length === 2) { - return _t("%s %s and %s", description, cf.rule.values[0], cf.rule.values[1]); - } - return description; - case "ColorScaleRule": - return CfTerms.ColorScale; - case "IconSetRule": - return CfTerms.IconSet; - default: - return ""; - } - } saveConditionalFormat() { if (this.state.currentCF) { const invalidRanges = this.state.currentCF.ranges.some((xc) => !xc.match(rangeReference)); @@ -23289,9 +23823,7 @@ const result = this.env.model.dispatch("ADD_CONDITIONAL_FORMAT", { cf: { rule: canonicalizeCFRule(this.getEditorRule(), locale), - id: this.state.mode === "edit" - ? this.state.currentCF.id - : this.env.model.uuidGenerator.uuidv4(), + id: this.state.currentCF.id, }, ranges: this.state.currentCF.ranges.map((xc) => this.env.model.getters.getRangeDataFromXc(sheetId, xc)), sheetId, @@ -23300,7 +23832,7 @@ this.state.errors = result.reasons; } else { - this.switchToList(); + this.props.onExitEdition(); } } } @@ -23316,7 +23848,6 @@ case "IconSetRule": return this.state.rules.iconSet; } - throw new Error(`Invalid cf type: ${this.state.currentCFType}`); } getDefaultRules() { return { @@ -23352,62 +23883,6 @@ }, }; } - /** - * Create a new CF, a CellIsRule by default - */ - addConditionalFormat() { - this.state.mode = "add"; - this.state.currentCFType = "CellIsRule"; - this.state.currentCF = { - id: this.env.model.uuidGenerator.uuidv4(), - ranges: this.env.model.getters - .getSelectedZones() - .map((zone) => this.env.model.getters.zoneToXC(this.env.model.getters.getActiveSheetId(), zone)), - }; - } - /** - * Delete a CF - */ - deleteConditionalFormat(cf) { - this.env.model.dispatch("REMOVE_CONDITIONAL_FORMAT", { - id: cf.id, - sheetId: this.env.model.getters.getActiveSheetId(), - }); - } - /** - * Edit an existing CF. Return without doing anything in reorder mode. - */ - editConditionalFormat(cf) { - if (this.state.mode === "reorder") - return; - this.state.mode = "edit"; - this.state.currentCF = cf; - this.state.currentCFType = cf.rule.type; - switch (cf.rule.type) { - case "CellIsRule": - this.state.rules.cellIs = cf.rule; - break; - case "ColorScaleRule": - this.state.rules.colorScale = cf.rule; - break; - case "IconSetRule": - this.state.rules.iconSet = cf.rule; - break; - } - } - /** - * Reorder existing CFs - */ - reorderConditionalFormats() { - this.state.mode = "reorder"; - } - reorderRule(cf, direction) { - this.env.model.dispatch("MOVE_CONDITIONAL_FORMAT", { - cfId: cf.id, - direction: direction, - sheetId: this.env.model.getters.getActiveSheetId(), - }); - } changeRuleType(ruleType) { if (this.state.currentCFType === ruleType || !this.state.rules) { return; @@ -23554,6 +24029,71 @@ this.state.rules.iconSet.icons[target] = icon; } } + ConditionalFormattingEditor.props = { + editedCf: { type: Object, optional: true }, + onExitEdition: Function, + }; + + class ConditionalFormattingPanel extends owl.Component { + static template = "o-spreadsheet-ConditionalFormattingPanel"; + static components = { + ConditionalFormatPreviewList, + ConditionalFormattingEditor, + }; + activeSheetId; + state = owl.useState({ + mode: "list", + }); + setup() { + this.activeSheetId = this.env.model.getters.getActiveSheetId(); + const sheetId = this.env.model.getters.getActiveSheetId(); + const rules = this.env.model.getters.getRulesSelection(sheetId, this.props.selection || []); + if (rules.length === 1) { + const cf = this.conditionalFormats.find((c) => c.id === rules[0]); + if (cf) { + this.editConditionalFormat(cf); + } + } + owl.onWillUpdateProps((nextProps) => { + const newActiveSheetId = this.env.model.getters.getActiveSheetId(); + if (newActiveSheetId !== this.activeSheetId) { + this.activeSheetId = newActiveSheetId; + this.switchToList(); + } + else if (nextProps.selection !== this.props.selection) { + const sheetId = this.env.model.getters.getActiveSheetId(); + const rules = this.env.model.getters.getRulesSelection(sheetId, nextProps.selection || []); + if (rules.length === 1) { + const cf = this.conditionalFormats.find((c) => c.id === rules[0]); + if (cf) { + this.editConditionalFormat(cf); + } + } + else { + this.switchToList(); + } + } + }); + } + get conditionalFormats() { + const cfs = this.env.model.getters.getConditionalFormats(this.env.model.getters.getActiveSheetId()); + return cfs.map((cf) => ({ + ...cf, + rule: localizeCFRule(cf.rule, this.env.model.getters.getLocale()), + })); + } + switchToList() { + this.state.mode = "list"; + this.state.editedCf = undefined; + } + addConditionalFormat() { + this.state.mode = "edit"; + } + editConditionalFormat(cf) { + this.state.mode = "edit"; + this.state.editedCf = cf; + } + } ConditionalFormattingPanel.props = { selection: { type: Object, optional: true }, onCloseSidePanel: Function, @@ -23719,7 +24259,6 @@ state = owl.useState(this.initialState()); debounceTimeoutId; showFormulaState = false; - debouncedUpdateSearch; searchInput = owl.useRef("searchInput"); get hasSearchResult() { return this.env.model.getters.getCurrentSelectedMatchIndex() !== null; @@ -23729,9 +24268,9 @@ } setup() { this.showFormulaState = this.env.model.getters.shouldShowFormulas(); - this.debouncedUpdateSearch = debounce(this.updateSearch.bind(this), 200); owl.onMounted(() => this.searchInput.el?.focus()); owl.onWillUnmount(() => { + clearTimeout(this.debounceTimeoutId); this.env.model.dispatch("CLEAR_SEARCH"); this.env.model.dispatch("SET_FORMULA_VISIBILITY", { show: this.showFormulaState }); }); @@ -23776,6 +24315,13 @@ searchOptions: this.state.searchOptions, }); } + debouncedUpdateSearch() { + clearTimeout(this.debounceTimeoutId); + this.debounceTimeoutId = setTimeout(() => { + this.updateSearch(); + this.debounceTimeoutId = undefined; + }, 200); + } replace() { this.env.model.dispatch("REPLACE_SEARCH", { replaceWith: this.state.replaceWith, @@ -23812,7 +24358,7 @@ `; class RemoveDuplicatesPanel extends owl.Component { static template = "o-spreadsheet-RemoveDuplicatesPanel"; - static components = { SidePanelErrors }; + static components = { ValidationMessages }; state = owl.useState({ hasHeader: false, columns: {}, @@ -23970,6 +24516,565 @@ return result; } + const dataValidationEvaluatorRegistry = new Registry(); + dataValidationEvaluatorRegistry.add("textContains", { + type: "textContains", + isValueValid: (value, criterion) => { + const strValue = String(value); + return strValue.toLowerCase().includes(criterion.values[0].toLowerCase()); + }, + getErrorString: (criterion) => { + return _t('The value must be a text that contains "%s"', criterion.values[0]); + }, + isCriterionValueValid: (value) => !!value, + criterionValueErrorString: DVTerms.CriterionError.notEmptyValue, + numberOfValues: () => 1, + name: _t("Text contains"), + getPreview: (criterion) => _t('Text contains "%s"', criterion.values[0]), + }); + dataValidationEvaluatorRegistry.add("textNotContains", { + type: "textNotContains", + isValueValid: (value, criterion) => { + const strValue = String(value); + return !strValue.toLowerCase().includes(criterion.values[0].toLowerCase()); + }, + getErrorString: (criterion) => { + return _t('The value must be a text that does not contain "%s"', criterion.values[0]); + }, + isCriterionValueValid: (value) => !!value, + criterionValueErrorString: DVTerms.CriterionError.notEmptyValue, + numberOfValues: () => 1, + name: _t("Text does not contains"), + getPreview: (criterion) => _t('Text does not contain "%s"', criterion.values[0]), + }); + dataValidationEvaluatorRegistry.add("textIs", { + type: "textIs", + isValueValid: (value, criterion) => { + const strValue = String(value); + return strValue.toLowerCase() === criterion.values[0].toLowerCase(); + }, + getErrorString: (criterion) => { + return _t('The value must be exactly "%s"', criterion.values[0]); + }, + isCriterionValueValid: (value) => !!value, + criterionValueErrorString: DVTerms.CriterionError.notEmptyValue, + numberOfValues: () => 1, + name: _t("Text is exactly"), + getPreview: (criterion) => _t('Text is exactly "%s"', criterion.values[0]), + }); + /** Note: this regex doesn't allow for all the RFC-compliant mail addresses but should be enough for our purpose. */ + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/; + dataValidationEvaluatorRegistry.add("textIsEmail", { + type: "textIsEmail", + isValueValid: (value) => typeof value === "string" && emailRegex.test(value), + getErrorString: () => _t("The value must be a valid email address"), + isCriterionValueValid: () => true, + criterionValueErrorString: "", + numberOfValues: () => 0, + name: _t("Text is valid email"), + getPreview: () => _t("Text is valid email"), + }); + dataValidationEvaluatorRegistry.add("textIsLink", { + type: "textIsLink", + isValueValid: (value) => detectLink(value) !== undefined, + getErrorString: () => _t("The value must be a valid link"), + isCriterionValueValid: () => true, + criterionValueErrorString: "", + numberOfValues: () => 0, + name: _t("Text is valid link"), + getPreview: () => _t("Text is valid link"), + }); + dataValidationEvaluatorRegistry.add("dateIs", { + type: "dateIs", + isValueValid: (value, criterion) => { + const criterionValue = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE)[0]; + const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); + if (dateValue === undefined || criterionValue === undefined) { + return false; + } + if (["lastWeek", "lastMonth", "lastYear"].includes(criterion.dateValue)) { + const today = jsDateToRoundNumber(new Date()); + return isDateBetween(dateValue, today, criterionValue); + } + return areDatesSameDay(dateValue, criterionValue); + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + return criterion.dateValue === "exactDate" + ? _t("The value must be the date %s", getDateCriterionLocalizedValues(criterion, locale)[0]) + : _t("The value must be %s", DVTerms.DateIs[criterion.dateValue]); + }, + isCriterionValueValid: (value) => checkValueIsDate(value), + criterionValueErrorString: DVTerms.CriterionError.dateValue, + numberOfValues: (criterion) => (criterion.dateValue === "exactDate" ? 1 : 0), + name: _t("Date is"), + getPreview: (criterion, getters) => { + return criterion.dateValue === "exactDate" + ? _t("Date is %s", getDateCriterionFormattedValues(criterion, getters)[0]) + : _t("Date is %s", DVTerms.DateIs[criterion.dateValue]); + }, + }); + dataValidationEvaluatorRegistry.add("dateIsBefore", { + type: "dateIsBefore", + isValueValid: (value, criterion) => { + const criterionValue = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE)[0]; + const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); + return (dateValue !== undefined && + criterionValue !== undefined && + isDateStrictlyBefore(dateValue, criterionValue)); + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + return criterion.dateValue === "exactDate" + ? _t("The value must be a date before %s", getDateCriterionLocalizedValues(criterion, locale)[0]) + : _t("The value must be a date before %s", DVTerms.DateIsBefore[criterion.dateValue]); + }, + isCriterionValueValid: (value) => checkValueIsDate(value), + criterionValueErrorString: DVTerms.CriterionError.dateValue, + numberOfValues: (criterion) => (criterion.dateValue === "exactDate" ? 1 : 0), + name: _t("Date is before"), + getPreview: (criterion, getters) => { + return criterion.dateValue === "exactDate" + ? _t("Date is before %s", getDateCriterionFormattedValues(criterion, getters)[0]) + : _t("Date is before %s", DVTerms.DateIsBefore[criterion.dateValue]); + }, + }); + dataValidationEvaluatorRegistry.add("dateIsOnOrBefore", { + type: "dateIsOnOrBefore", + isValueValid: (value, criterion) => { + const criterionValue = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE)[0]; + const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); + return (dateValue !== undefined && + criterionValue !== undefined && + isDateBefore(dateValue, criterionValue)); + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + return criterion.dateValue === "exactDate" + ? _t("The value must be a date on or before %s", getDateCriterionLocalizedValues(criterion, locale)[0]) + : _t("The value must be a date on or before %s", DVTerms.DateIsBefore[criterion.dateValue]); + }, + isCriterionValueValid: (value) => checkValueIsDate(value), + criterionValueErrorString: DVTerms.CriterionError.dateValue, + numberOfValues: (criterion) => (criterion.dateValue === "exactDate" ? 1 : 0), + name: _t("Date is on or before"), + getPreview: (criterion, getters) => { + return criterion.dateValue === "exactDate" + ? _t("Date is on or before %s", getDateCriterionFormattedValues(criterion, getters)[0]) + : _t("Date is on or before %s", DVTerms.DateIsBefore[criterion.dateValue]); + }, + }); + dataValidationEvaluatorRegistry.add("dateIsAfter", { + type: "dateIsAfter", + isValueValid: (value, criterion) => { + const criterionValue = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE)[0]; + const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); + return (dateValue !== undefined && + criterionValue !== undefined && + isDateStrictlyAfter(dateValue, criterionValue)); + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + return criterion.dateValue === "exactDate" + ? _t("The value must be a date after %s", getDateCriterionLocalizedValues(criterion, locale)[0]) + : _t("The value must be a date after %s", DVTerms.DateIsBefore[criterion.dateValue]); + }, + isCriterionValueValid: (value) => checkValueIsDate(value), + criterionValueErrorString: DVTerms.CriterionError.dateValue, + numberOfValues: (criterion) => (criterion.dateValue === "exactDate" ? 1 : 0), + name: _t("Date is after"), + getPreview: (criterion, getters) => { + return criterion.dateValue === "exactDate" + ? _t("Date is after %s", getDateCriterionFormattedValues(criterion, getters)[0]) + : _t("Date is after %s", DVTerms.DateIsBefore[criterion.dateValue]); + }, + }); + dataValidationEvaluatorRegistry.add("dateIsOnOrAfter", { + type: "dateIsOnOrAfter", + isValueValid: (value, criterion) => { + const criterionValue = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE)[0]; + const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); + return (dateValue !== undefined && + criterionValue !== undefined && + isDateAfter(dateValue, criterionValue)); + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + return criterion.dateValue === "exactDate" + ? _t("The value must be a date on or after %s", getDateCriterionLocalizedValues(criterion, locale)[0]) + : _t("The value must be a date on or after %s", DVTerms.DateIsBefore[criterion.dateValue]); + }, + isCriterionValueValid: (value) => checkValueIsDate(value), + criterionValueErrorString: DVTerms.CriterionError.dateValue, + numberOfValues: (criterion) => (criterion.dateValue === "exactDate" ? 1 : 0), + name: _t("Date is on or after"), + getPreview: (criterion, getters) => { + return criterion.dateValue === "exactDate" + ? _t("Date is on or after %s", getDateCriterionFormattedValues(criterion, getters)[0]) + : _t("Date is on or after %s", DVTerms.DateIsBefore[criterion.dateValue]); + }, + }); + dataValidationEvaluatorRegistry.add("dateIsBetween", { + type: "dateIsBetween", + isValueValid: (value, criterion) => { + const criterionValues = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE); + const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); + if (dateValue === undefined || + criterionValues[0] === undefined || + criterionValues[1] === undefined) { + return false; + } + return isDateBetween(dateValue, criterionValues[0], criterionValues[1]); + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + const criterionValues = getDateCriterionLocalizedValues(criterion, locale); + return _t("The value must be a date between %s and %s", criterionValues[0], criterionValues[1]); + }, + isCriterionValueValid: (value) => checkValueIsDate(value), + criterionValueErrorString: DVTerms.CriterionError.dateValue, + numberOfValues: () => 2, + name: _t("Date is between"), + getPreview: (criterion, getters) => { + const values = getDateCriterionFormattedValues(criterion, getters); + return _t("Date is between %s and %s", values[0], values[1]); + }, + }); + dataValidationEvaluatorRegistry.add("dateIsNotBetween", { + type: "dateIsNotBetween", + isValueValid: (value, criterion) => { + const criterionValues = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE); + const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); + if (dateValue === undefined || + criterionValues[0] === undefined || + criterionValues[1] === undefined) { + return false; + } + return !isDateBetween(dateValue, criterionValues[0], criterionValues[1]); + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + const criterionValues = getDateCriterionLocalizedValues(criterion, locale); + return _t("The value must be a date not between %s and %s", criterionValues[0], criterionValues[1]); + }, + isCriterionValueValid: (value) => checkValueIsDate(value), + criterionValueErrorString: DVTerms.CriterionError.dateValue, + numberOfValues: () => 2, + name: _t("Date is not between"), + getPreview: (criterion, getters) => { + const values = getDateCriterionFormattedValues(criterion, getters); + return _t("Date is not between %s and %s", values[0], values[1]); + }, + }); + dataValidationEvaluatorRegistry.add("dateIsValid", { + type: "dateIsValid", + isValueValid: (value) => { + return valueToDateNumber(value, DEFAULT_LOCALE) !== undefined; + }, + getErrorString: () => _t("The value must be a valid date"), + isCriterionValueValid: (value) => checkValueIsDate(value), + criterionValueErrorString: "", + numberOfValues: () => 0, + name: _t("Is valid date"), + getPreview: () => _t("Date is valid"), + }); + dataValidationEvaluatorRegistry.add("isEqual", { + type: "isEqual", + isValueValid: (value, criterion) => { + if (typeof value !== "number") { + return false; + } + const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; + if (criterionValue === undefined) { + return false; + } + return value === criterionValue; + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + const values = getNumberCriterionlocalizedValues(criterion, locale); + return _t("The value must be equal to %s", values[0]); + }, + isCriterionValueValid: (value) => checkValueIsNumber(value), + criterionValueErrorString: DVTerms.CriterionError.numberValue, + numberOfValues: () => 1, + name: _t("Is equal to"), + getPreview: (criterion) => _t("Value is equal to %s", criterion.values[0]), + }); + dataValidationEvaluatorRegistry.add("isNotEqual", { + type: "isNotEqual", + isValueValid: (value, criterion) => { + if (typeof value !== "number") { + return false; + } + const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; + if (criterionValue === undefined) { + return false; + } + return value !== criterionValue; + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + const values = getNumberCriterionlocalizedValues(criterion, locale); + return _t("The value must not be equal to %s", values[0]); + }, + isCriterionValueValid: (value) => checkValueIsNumber(value), + criterionValueErrorString: DVTerms.CriterionError.numberValue, + numberOfValues: () => 1, + name: _t("Is not equal to"), + getPreview: (criterion) => _t("Value is not equal to %s", criterion.values[0]), + }); + dataValidationEvaluatorRegistry.add("isGreaterThan", { + type: "isGreaterThan", + isValueValid: (value, criterion) => { + if (typeof value !== "number") { + return false; + } + const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; + if (criterionValue === undefined) { + return false; + } + return value > criterionValue; + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + const values = getNumberCriterionlocalizedValues(criterion, locale); + return _t("The value must be greater than %s", values[0]); + }, + isCriterionValueValid: (value) => checkValueIsNumber(value), + criterionValueErrorString: DVTerms.CriterionError.numberValue, + numberOfValues: () => 1, + name: _t("Is greater than"), + getPreview: (criterion) => _t("Value is greater than %s", criterion.values[0]), + }); + dataValidationEvaluatorRegistry.add("isGreaterOrEqualTo", { + type: "isGreaterOrEqualTo", + isValueValid: (value, criterion) => { + if (typeof value !== "number") { + return false; + } + const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; + if (criterionValue === undefined) { + return false; + } + return value >= criterionValue; + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + const values = getNumberCriterionlocalizedValues(criterion, locale); + return _t("The value must be greater or equal to %s", values[0]); + }, + isCriterionValueValid: (value) => checkValueIsNumber(value), + criterionValueErrorString: DVTerms.CriterionError.numberValue, + numberOfValues: () => 1, + name: _t("Is greater or equal to"), + getPreview: (criterion) => _t("Value is greater or equal to %s", criterion.values[0]), + }); + dataValidationEvaluatorRegistry.add("isLessThan", { + type: "isLessThan", + isValueValid: (value, criterion) => { + if (typeof value !== "number") { + return false; + } + const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; + if (criterionValue === undefined) { + return false; + } + return value < criterionValue; + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + const values = getNumberCriterionlocalizedValues(criterion, locale); + return _t("The value must be less than %s", values[0]); + }, + isCriterionValueValid: (value) => checkValueIsNumber(value), + criterionValueErrorString: DVTerms.CriterionError.numberValue, + numberOfValues: () => 1, + name: _t("Is less than"), + getPreview: (criterion) => _t("Value is less than %s", criterion.values[0]), + }); + dataValidationEvaluatorRegistry.add("isLessOrEqualTo", { + type: "isLessOrEqualTo", + isValueValid: (value, criterion) => { + if (typeof value !== "number") { + return false; + } + const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; + if (criterionValue === undefined) { + return false; + } + return value <= criterionValue; + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + const values = getNumberCriterionlocalizedValues(criterion, locale); + return _t("The value must be less or equal to %s", values[0]); + }, + isCriterionValueValid: (value) => checkValueIsNumber(value), + criterionValueErrorString: DVTerms.CriterionError.numberValue, + numberOfValues: () => 1, + name: _t("Is less or equal to"), + getPreview: (criterion) => _t("Value is less or equal to %s", criterion.values[0]), + }); + dataValidationEvaluatorRegistry.add("isBetween", { + type: "isBetween", + isValueValid: (value, criterion) => { + if (typeof value !== "number") { + return false; + } + const criterionValues = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE); + if (criterionValues[0] === undefined || criterionValues[1] === undefined) { + return false; + } + return isNumberBetween(value, criterionValues[0], criterionValues[1]); + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + const values = getNumberCriterionlocalizedValues(criterion, locale); + return _t("The value must be between %s and %s", values[0], values[1]); + }, + isCriterionValueValid: (value) => checkValueIsNumber(value), + criterionValueErrorString: DVTerms.CriterionError.numberValue, + numberOfValues: () => 2, + name: _t("Is between"), + getPreview: (criterion) => _t("Value is between %s and %s", criterion.values[0], criterion.values[1]), + }); + dataValidationEvaluatorRegistry.add("isNotBetween", { + type: "isNotBetween", + isValueValid: (value, criterion) => { + if (typeof value !== "number") { + return false; + } + const criterionValues = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE); + if (criterionValues[0] === undefined || criterionValues[1] === undefined) { + return false; + } + return !isNumberBetween(value, criterionValues[0], criterionValues[1]); + }, + getErrorString: (criterion, getters) => { + const locale = getters.getLocale(); + const values = getNumberCriterionlocalizedValues(criterion, locale); + return _t("The value must not be between %s and %s", values[0], values[1]); + }, + isCriterionValueValid: (value) => checkValueIsNumber(value), + criterionValueErrorString: DVTerms.CriterionError.numberValue, + numberOfValues: () => 2, + name: _t("Is not between"), + getPreview: (criterion) => _t("Value is not between %s and %s", criterion.values[0], criterion.values[1]), + }); + dataValidationEvaluatorRegistry.add("isBoolean", { + type: "isBoolean", + isValueValid: (value) => value === "" || typeof value === "boolean", + getErrorString: () => _t("The value must be a boolean"), + isCriterionValueValid: () => true, + criterionValueErrorString: "", + numberOfValues: () => 0, + name: _t("Checkbox"), + getPreview: () => _t("Checkbox"), + }); + dataValidationEvaluatorRegistry.add("isValueInList", { + type: "isValueInList", + isValueValid: (value, criterion) => { + if (value === null) { + return false; + } + return criterion.values + .map((str) => str.toLowerCase()) + .includes(value.toString().toLowerCase()); + }, + getErrorString: (criterion) => _t("The value must be one of: %s", criterion.values.join(", ")), + isCriterionValueValid: () => true, + criterionValueErrorString: "", + numberOfValues: () => undefined, + allowedValues: "onlyLiterals", + name: _t("Value in list"), + getPreview: (criterion) => _t("Value one of: %s", criterion.values.join(", ")), + }); + dataValidationEvaluatorRegistry.add("isValueInRange", { + type: "isValueInList", + isValueValid: (value, criterion, getters, sheetId) => { + if (!value) { + return false; + } + const range = getters.getRangeFromSheetXC(sheetId, criterion.values[0]); + const criterionValues = getters.getRangeValues(range); + return criterionValues + .filter(isNotNull) + .map((value) => value.toString().toLowerCase()) + .includes(value.toString().toLowerCase()); + }, + getErrorString: (criterion) => _t("The value must be a value in the range %s", criterion.values[0]), + isCriterionValueValid: (value) => rangeReference.test(value), + criterionValueErrorString: DVTerms.CriterionError.validRange, + numberOfValues: () => 1, + allowedValues: "onlyLiterals", + name: _t("Value in range"), + getPreview: (criterion) => _t("Value in range %s", criterion.values[0]), + }); + dataValidationEvaluatorRegistry.add("customFormula", { + type: "customFormula", + isValueValid: (value, criterion) => { + const parsedValue = parseLiteral(criterion.values[0], DEFAULT_LOCALE); + if (typeof parsedValue === "number" || typeof parsedValue === "boolean") { + return !!parsedValue; + } + return false; + }, + getErrorString: () => _t("The value does not match the custom formula data validation rule"), + isCriterionValueValid: () => true, + criterionValueErrorString: "", + numberOfValues: () => 1, + allowedValues: "onlyFormulas", + name: _t("Custom formula"), + getPreview: (criterion) => _t("Custom formula %s", criterion.values[0]), + }); + function getNumberCriterionlocalizedValues(criterion, locale) { + return criterion.values.map((value) => value !== undefined ? localizeContent(value, locale) : CellErrorType.InvalidReference); + } + function getDateCriterionLocalizedValues(criterion, locale) { + const values = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE); + return values.map((value) => value !== undefined + ? formatValue(value, { locale, format: locale.dateFormat }) + : CellErrorType.InvalidReference); + } + function checkValueIsDate(value) { + const valueAsNumber = valueToDateNumber(value, DEFAULT_LOCALE); + return valueAsNumber !== undefined; + } + function checkValueIsNumber(value) { + const valueAsNumber = tryToNumber(value, DEFAULT_LOCALE); + return valueAsNumber !== undefined; + } + function getDateCriterionFormattedValues(criterion, getters) { + const locale = getters.getLocale(); + return criterion.values.map((valueStr) => { + if (valueStr.startsWith("=")) { + return valueStr; + } + const value = parseLiteral(valueStr, locale); + if (typeof value === "number") { + return formatValue(value, { format: locale.dateFormat, locale }); + } + return ""; + }); + } + + function interactiveStopEdition(env) { + const result = env.model.dispatch("STOP_EDITION"); + if (result.isCancelledBecause("BlockingValidationRule" /* CommandResult.BlockingValidationRule */)) { + const editedCell = env.model.getters.getCurrentEditedCell(); + const cellXc = toXC(editedCell.col, editedCell.row); + const rule = env.model.getters.getValidationRuleForCell(editedCell); + if (!rule) { + return; + } + const evaluator = dataValidationEvaluatorRegistry.get(rule.criterion.type); + const errorStr = evaluator.getErrorString(rule.criterion, env.model.getters, editedCell.sheetId); + env.raiseError(_t("The data you entered in %s violates the data validation rule set on the cell:\n%s", cellXc, errorStr)); + env.model.dispatch("CANCEL_EDITION"); + } + } + const SEPARATORS = [ { name: _t("Detect automatically"), value: "auto" }, { name: _t("Custom separator"), value: "custom" }, @@ -23980,7 +25085,7 @@ ]; class SplitIntoColumnsPanel extends owl.Component { static template = "o-spreadsheet-SplitIntoColumnsPanel"; - static components = { SidePanelErrors }; + static components = { ValidationMessages }; state = owl.useState({ separatorValue: "auto", addNewColumns: false, customSeparator: "" }); setup() { owl.onWillUpdateProps(() => { @@ -23991,7 +25096,7 @@ } }); owl.onMounted(() => { - this.env.model.dispatch("STOP_EDITION"); + interactiveStopEdition(this.env); }); } onSeparatorChange(value) { @@ -24063,6 +25168,613 @@ onCloseSidePanel: Function, }; + /** This component looks like a select input, but on click it opens a Menu with the items given as props instead of a dropdown */ + class SelectMenu extends owl.Component { + static template = "o-spreadsheet-SelectMenu"; + static components = { Menu }; + selectRef = owl.useRef("select"); + selectRect = useAbsoluteBoundingRect(this.selectRef); + state = owl.useState({ + isMenuOpen: false, + }); + onClick() { + this.state.isMenuOpen = true; + } + onMenuClosed() { + this.state.isMenuOpen = false; + } + get menuPosition() { + return { + x: this.selectRect.x, + y: this.selectRect.y, + }; + } + } + SelectMenu.props = { + menuItems: Array, + selectedValue: String, + class: { type: String, optional: true }, + }; + + class DataValidationCriterionForm extends owl.Component { + setup() { + owl.onMounted(() => { + interactiveStopEdition(this.env); + }); + } + updateCriterion(criterion) { + const filteredCriterion = { + ...this.props.criterion, + ...criterion, + }; + this.props.onCriterionChanged(filteredCriterion); + } + } + DataValidationCriterionForm.props = { + criterion: Object, + onCriterionChanged: Function, + }; + + css /* scss */ ` + .o-dv-input { + .o-invalid { + background-color: #ffdddd; + } + .error-icon { + right: 7px; + top: 7px; + } + } +`; + class DataValidationInput extends owl.Component { + static template = "o-spreadsheet-DataValidationInput"; + static defaultProps = { + value: "", + onKeyDown: () => { }, + focused: false, + onBlur: () => { }, + }; + inputRef = owl.useRef("input"); + setup() { + owl.useEffect(() => { + if (this.props.focused) { + this.inputRef.el.focus(); + } + }, () => [this.props.focused, this.inputRef.el]); + } + state = owl.useState({ + shouldDisplayError: !!this.props.value, // Don't display error if user inputted nothing yet + }); + onValueChanged(ev) { + this.state.shouldDisplayError = true; + this.props.onValueChanged(ev.target.value); + } + get placeholder() { + const evaluator = dataValidationEvaluatorRegistry.get(this.props.criterionType); + if (evaluator.allowedValues === "onlyFormulas") { + return _t("Formula"); + } + else if (evaluator.allowedValues === "onlyLiterals") { + return _t("Value"); + } + return _t("Value or formula"); + } + get errorMessage() { + if (!this.state.shouldDisplayError) { + return undefined; + } + return this.env.model.getters.getDataValidationInvalidCriterionValueMessage(this.props.criterionType, canonicalizeContent(this.props.value, this.env.model.getters.getLocale())); + } + } + DataValidationInput.props = { + value: { type: String, optional: true }, + criterionType: String, + onValueChanged: Function, + onKeyDown: { type: Function, optional: true }, + focused: { type: Boolean, optional: true }, + onBlur: { type: Function, optional: true }, + onFocus: { type: Function, optional: true }, + }; + + const DATES_VALUES = { + today: _t("today"), + yesterday: _t("yesterday"), + tomorrow: _t("tomorrow"), + lastWeek: _t("in the past week"), + lastMonth: _t("in the past month"), + lastYear: _t("in the past year"), + exactDate: _t("exact date"), + }; + class DataValidationDateCriterionForm extends DataValidationCriterionForm { + static template = "o-spreadsheet-DataValidationDateCriterion"; + static components = { DataValidationInput }; + setup() { + super.setup(); + const setupDefault = (props) => { + if (props.criterion.dateValue === undefined) { + this.updateCriterion({ dateValue: "exactDate" }); + } + }; + owl.onWillUpdateProps(setupDefault); + owl.onWillStart(() => setupDefault(this.props)); + } + onValueChanged(value) { + this.updateCriterion({ values: [value] }); + } + onDateValueChanged(ev) { + const dateValue = ev.target.value; + this.updateCriterion({ dateValue }); + } + get dateValues() { + return Object.keys(DATES_VALUES).map((key) => ({ + value: key, + title: DATES_VALUES[key], + })); + } + } + + class DataValidationDoubleInputCriterionForm extends DataValidationCriterionForm { + static template = "o-spreadsheet-DataValidationDoubleInput"; + static components = { DataValidationInput }; + onFirstValueChanged(value) { + const values = this.props.criterion.values; + this.updateCriterion({ + values: [value, values[1]], + }); + } + onSecondValueChanged(value) { + const values = this.props.criterion.values; + this.updateCriterion({ + values: [values[0], value], + }); + } + } + + class DataValidationSingleInputCriterionForm extends DataValidationCriterionForm { + static template = "o-spreadsheet-DataValidationSingleInput"; + static components = { DataValidationInput }; + onValueChanged(value) { + const criterion = deepCopy(this.props.criterion); + criterion.values[0] = value; + this.updateCriterion(criterion); + } + } + + css /* scss */ ` + .o-dv-list-item-delete { + color: #666666; + cursor: pointer; + } +`; + class DataValidationListCriterionForm extends DataValidationCriterionForm { + static template = "o-spreadsheet-DataValidationListCriterionForm"; + static components = { DataValidationInput }; + state = owl.useState({ + numberOfValues: Math.max(this.props.criterion.values.length, 2), + }); + setup() { + super.setup(); + const setupDefault = (props) => { + if (props.criterion.displayStyle === undefined) { + this.updateCriterion({ displayStyle: "arrow" }); + } + }; + owl.onWillUpdateProps(setupDefault); + owl.onWillStart(() => setupDefault(this.props)); + } + onValueChanged(value, index) { + const values = [...this.displayedValues]; + values[index] = value; + this.updateCriterion({ values }); + } + onAddAnotherValue() { + this.state.numberOfValues++; + } + removeItem(index) { + const values = [...this.displayedValues]; + values.splice(index, 1); + this.state.numberOfValues--; + this.updateCriterion({ values }); + } + onChangedDisplayStyle(ev) { + const displayStyle = ev.target.value; + this.updateCriterion({ displayStyle }); + } + onKeyDown(ev, index) { + if ((ev.key === "Enter" || ev.key === "Tab") && index === this.state.numberOfValues - 1) { + this.onAddAnotherValue(); + this.state.focusedValueIndex = index + 1; + ev.preventDefault(); + } + else if (ev.key === "Enter") { + this.state.focusedValueIndex = index + 1; + } + } + onBlurInput() { + this.state.focusedValueIndex = undefined; + } + get displayedValues() { + const values = []; + for (let i = 0; i < this.state.numberOfValues; i++) { + values.push(this.props.criterion.values[i] || ""); + } + return values; + } + } + + class DataValidationValueInRangeCriterionForm extends DataValidationCriterionForm { + static template = "o-spreadsheet-DataValidationValueInRangeCriterionForm"; + static components = { SelectionInput }; + setup() { + super.setup(); + const setupDefault = (props) => { + if (props.criterion.displayStyle === undefined) { + this.updateCriterion({ displayStyle: "arrow" }); + } + }; + owl.onWillUpdateProps(setupDefault); + owl.onWillStart(() => setupDefault(this.props)); + } + onRangeChanged(rangeXc) { + this.updateCriterion({ values: [rangeXc] }); + } + onChangedDisplayStyle(ev) { + const displayStyle = ev.target.value; + this.updateCriterion({ displayStyle }); + } + } + + const dvCriterionCategoriesSequences = { + list: 10, + text: 20, + date: 30, + number: 40, + misc: 50, + }; + const dataValidationPanelCriteriaRegistry = new Registry(); + dataValidationPanelCriteriaRegistry.add("textContains", { + type: "textContains", + component: DataValidationSingleInputCriterionForm, + category: "text", + sequence: 10, + }); + dataValidationPanelCriteriaRegistry.add("textNotContains", { + type: "textNotContains", + component: DataValidationSingleInputCriterionForm, + category: "text", + sequence: 20, + }); + dataValidationPanelCriteriaRegistry.add("textIs", { + type: "textIs", + component: DataValidationSingleInputCriterionForm, + category: "text", + sequence: 30, + }); + dataValidationPanelCriteriaRegistry.add("textIsEmail", { + type: "textIsEmail", + component: undefined, + category: "text", + sequence: 40, + }); + dataValidationPanelCriteriaRegistry.add("textIsLink", { + type: "textIsLink", + component: undefined, + category: "text", + sequence: 50, + }); + dataValidationPanelCriteriaRegistry.add("dateIs", { + type: "dateIs", + component: DataValidationDateCriterionForm, + category: "date", + sequence: 20, + }); + dataValidationPanelCriteriaRegistry.add("dateIsBefore", { + type: "dateIsBefore", + component: DataValidationDateCriterionForm, + category: "date", + sequence: 30, + }); + dataValidationPanelCriteriaRegistry.add("dateIsOnOrBefore", { + type: "dateIsOnOrBefore", + component: DataValidationDateCriterionForm, + category: "date", + sequence: 40, + }); + dataValidationPanelCriteriaRegistry.add("dateIsAfter", { + type: "dateIsAfter", + component: DataValidationDateCriterionForm, + category: "date", + sequence: 50, + }); + dataValidationPanelCriteriaRegistry.add("dateIsOnOrAfter", { + type: "dateIsOnOrAfter", + component: DataValidationDateCriterionForm, + category: "date", + sequence: 60, + }); + dataValidationPanelCriteriaRegistry.add("dateIsBetween", { + type: "dateIsBetween", + component: DataValidationDoubleInputCriterionForm, + category: "date", + sequence: 70, + }); + dataValidationPanelCriteriaRegistry.add("dateIsNotBetween", { + type: "dateIsNotBetween", + component: DataValidationDoubleInputCriterionForm, + category: "date", + sequence: 80, + }); + dataValidationPanelCriteriaRegistry.add("dateIsValid", { + type: "dateIsValid", + component: undefined, + category: "date", + sequence: 10, + }); + dataValidationPanelCriteriaRegistry.add("isEqual", { + type: "isEqual", + component: DataValidationSingleInputCriterionForm, + category: "number", + sequence: 10, + }); + dataValidationPanelCriteriaRegistry.add("isNotEqual", { + type: "isNotEqual", + component: DataValidationSingleInputCriterionForm, + category: "number", + sequence: 20, + }); + dataValidationPanelCriteriaRegistry.add("isGreaterThan", { + type: "isGreaterThan", + component: DataValidationSingleInputCriterionForm, + category: "number", + sequence: 50, + }); + dataValidationPanelCriteriaRegistry.add("isGreaterOrEqualTo", { + type: "isGreaterOrEqualTo", + component: DataValidationSingleInputCriterionForm, + category: "number", + sequence: 60, + }); + dataValidationPanelCriteriaRegistry.add("isLessThan", { + type: "isLessThan", + component: DataValidationSingleInputCriterionForm, + category: "number", + sequence: 30, + }); + dataValidationPanelCriteriaRegistry.add("isLessOrEqualTo", { + type: "isLessOrEqualTo", + component: DataValidationSingleInputCriterionForm, + category: "number", + sequence: 40, + }); + dataValidationPanelCriteriaRegistry.add("isBetween", { + type: "isBetween", + component: DataValidationDoubleInputCriterionForm, + category: "number", + sequence: 70, + }); + dataValidationPanelCriteriaRegistry.add("isNotBetween", { + type: "isNotBetween", + component: DataValidationDoubleInputCriterionForm, + category: "number", + sequence: 80, + }); + dataValidationPanelCriteriaRegistry.add("isBoolean", { + type: "isBoolean", + component: undefined, + category: "misc", + sequence: 10, + }); + dataValidationPanelCriteriaRegistry.add("isValueInList", { + type: "isValueInList", + component: DataValidationListCriterionForm, + category: "list", + sequence: 10, + }); + dataValidationPanelCriteriaRegistry.add("isValueInRange", { + type: "isValueInRange", + component: DataValidationValueInRangeCriterionForm, + category: "list", + sequence: 20, + }); + dataValidationPanelCriteriaRegistry.add("customFormula", { + type: "customFormula", + component: DataValidationSingleInputCriterionForm, + category: "misc", + sequence: 20, + }); + function getDataValidationCriterionMenuItems(callback) { + const items = dataValidationPanelCriteriaRegistry.getAll().sort((a, b) => { + if (a.category === b.category) { + return a.sequence - b.sequence; + } + return dvCriterionCategoriesSequences[a.category] - dvCriterionCategoriesSequences[b.category]; + }); + const actionSpecs = items.map((item, index) => { + const evaluator = dataValidationEvaluatorRegistry.get(item.type); + return { + name: evaluator.name, + id: item.type, + separator: item.category !== items[index + 1]?.category, + execute: () => callback(item.type), + }; + }); + return createActions(actionSpecs); + } + + css /* scss */ ` + .o-sidePanel .o-sidePanelBody .o-dv-form { + .o-section { + padding: 16px 16px 0 16px; + } + } +`; + class DataValidationEditor extends owl.Component { + static template = "o-spreadsheet-DataValidationEditor"; + static components = { SelectionInput, SelectMenu }; + state = owl.useState({ rule: this.defaultDataValidationRule }); + setup() { + if (this.props.rule) { + const sheetId = this.env.model.getters.getActiveSheetId(); + this.state.rule = { + ...this.props.rule, + ranges: this.props.rule.ranges.map((range) => this.env.model.getters.getRangeString(range, sheetId)), + }; + this.state.rule.criterion.type = this.props.rule.criterion.type; + } + } + onCriterionTypeChanged(type) { + this.state.rule.criterion.type = type; + } + onRangesChanged(ranges) { + this.state.rule.ranges = ranges; + } + onCriterionChanged(criterion) { + this.state.rule.criterion = criterion; + } + changeRuleIsBlocking(ev) { + const isBlocking = ev.target.value; + this.state.rule.isBlocking = isBlocking === "true"; + } + onSave() { + if (!this.canSave) { + return; + } + this.env.model.dispatch("ADD_DATA_VALIDATION_RULE", this.dispatchPayload); + this.props.onExit(); + } + get canSave() { + return this.env.model.canDispatch("ADD_DATA_VALIDATION_RULE", this.dispatchPayload) + .isSuccessful; + } + get dispatchPayload() { + const rule = { ...this.state.rule, ranges: undefined }; + const locale = this.env.model.getters.getLocale(); + const criterion = rule.criterion; + const criterionEvaluator = dataValidationEvaluatorRegistry.get(criterion.type); + const sheetId = this.env.model.getters.getActiveSheetId(); + const values = criterion.values + .slice(0, criterionEvaluator.numberOfValues(criterion)) + .map((value) => value?.trim()) + .filter((value) => value !== "" && value !== undefined) + .map((value) => canonicalizeContent(value, locale)); + rule.criterion = { ...criterion, values }; + return { + sheetId, + ranges: this.state.rule.ranges.map((xc) => this.env.model.getters.getRangeDataFromXc(sheetId, xc)), + rule, + }; + } + get dvCriterionMenuItems() { + return getDataValidationCriterionMenuItems((type) => this.onCriterionTypeChanged(type)); + } + get selectedCriterionName() { + const selectedType = this.state.rule.criterion.type; + return dataValidationEvaluatorRegistry.get(selectedType).name; + } + get defaultDataValidationRule() { + const sheetId = this.env.model.getters.getActiveSheetId(); + const ranges = this.env.model.getters + .getSelectedZones() + .map((zone) => zoneToXc(this.env.model.getters.getUnboundedZone(sheetId, zone))); + return { + id: this.env.model.uuidGenerator.uuidv4(), + criterion: { type: "textContains", values: [""] }, + ranges, + }; + } + get criterionComponent() { + return dataValidationPanelCriteriaRegistry.get(this.state.rule.criterion.type).component; + } + } + DataValidationEditor.props = { + rule: { type: Object, optional: true }, + onExit: Function, + }; + + css /* scss */ ` + .o-sidePanel { + .o-dv-preview { + height: 70px; + box-sizing: border-box; + cursor: pointer; + border-bottom: 1px solid ${FIGURE_BORDER_COLOR}; + + .o-dv-container { + min-width: 0; // otherwise flex won't shrink correctly + } + + .o-dv-preview-description { + font-size: 13px; + } + + &:hover { + background-color: rgba(0, 0, 0, 0.08); + } + + &:not(:hover) .o-dv-preview-delete { + display: none !important; + } + } + } +`; + class DataValidationPreview extends owl.Component { + static template = "o-spreadsheet-DataValidationPreview"; + deleteDataValidation() { + const sheetId = this.env.model.getters.getActiveSheetId(); + this.env.model.dispatch("REMOVE_DATA_VALIDATION_RULE", { sheetId, id: this.props.rule.id }); + } + get rangesString() { + const sheetId = this.env.model.getters.getActiveSheetId(); + return this.props.rule.ranges + .map((range) => this.env.model.getters.getRangeString(range, sheetId)) + .join(", "); + } + get descriptionString() { + return dataValidationEvaluatorRegistry + .get(this.props.rule.criterion.type) + .getPreview(this.props.rule.criterion, this.env.model.getters); + } + } + DataValidationPreview.props = { + onClick: Function, + rule: Object, + }; + + class DataValidationPanel extends owl.Component { + static template = "o-spreadsheet-DataValidationPanel"; + static components = { DataValidationPreview, DataValidationEditor }; + state = owl.useState({ mode: "list", activeRule: undefined }); + onPreviewClick(id) { + const sheetId = this.env.model.getters.getActiveSheetId(); + const rule = this.env.model.getters.getDataValidationRule(sheetId, id); + if (rule) { + this.state.mode = "edit"; + this.state.activeRule = rule; + } + } + addDataValidationRule() { + this.state.mode = "edit"; + this.state.activeRule = undefined; + } + onExitEditMode() { + this.state.mode = "list"; + this.state.activeRule = undefined; + } + localizeDVRule(rule) { + if (!rule) + return rule; + const locale = this.env.model.getters.getLocale(); + return localizeDataValidationRule(rule, locale); + } + get validationRules() { + const sheetId = this.env.model.getters.getActiveSheetId(); + return this.env.model.getters.getDataValidationRules(sheetId); + } + } + DataValidationPanel.props = { + onCloseSidePanel: Function, + }; + const sidePanelRegistry = new Registry(); sidePanelRegistry.add("ConditionalFormatting", { title: _t("Conditional formatting"), @@ -24092,6 +25804,10 @@ title: _t("Remove duplicates"), Body: RemoveDuplicatesPanel, }); + sidePanelRegistry.add("DataValidation", { + title: _t("Data validation"), + Body: DataValidationPanel, + }); class TopBarComponentRegistry extends Registry { mapping = {}; @@ -24595,6 +26311,7 @@ .o-autocomplete-dropdown { pointer-events: auto; background-color: #fff; + max-width: 400px; & > div:hover { background-color: #f2f2f2; } @@ -24603,7 +26320,7 @@ } & > div { - padding: 1px 0 5px 5px; + padding: 1px 5px 5px 5px; .o-autocomplete-description { padding-left: 5px; font-size: 11px; @@ -24616,7 +26333,7 @@ } TextValueProvider.props = { values: Array, - selectedIndex: Number, + selectedIndex: { type: Number, optional: true }, onValueSelected: Function, }; @@ -25079,7 +26796,8 @@ autoCompleteState = owl.useState({ showProvider: false, values: [], - selectedIndex: 0, + selectedIndex: undefined, + type: "function", }); functionDescriptionState = owl.useState({ showDescription: false, @@ -25089,10 +26807,14 @@ }); compositionActive = false; get assistantStyle() { + const assistantStyle = {}; + assistantStyle["min-width"] = `${this.props.rect?.width || ASSISTANT_WIDTH}px`; + if (this.autoCompleteState.type === "function") { + assistantStyle.width = `${ASSISTANT_WIDTH}px`; + } if (this.props.delimitation && this.props.rect) { const { x: cellX, y: cellY, height: cellHeight } = this.props.rect; const remainingHeight = this.props.delimitation.height - (cellY + cellHeight); - let assistantStyle = {}; if (cellY > remainingHeight) { // render top // We compensate 2 px of margin on the assistant style + 1px for design reasons @@ -25103,10 +26825,8 @@ // render left assistantStyle.right = `0px`; } - assistantStyle.width = `${ASSISTANT_WIDTH}px`; - return cssPropertiesToCss(assistantStyle); } - return cssPropertiesToCss({ width: `${ASSISTANT_WIDTH}px` }); + return cssPropertiesToCss(assistantStyle); } // we can't allow input events to be triggered while we remove and add back the content of the composer in processContent shouldProcessInputEvents = false; @@ -25130,6 +26850,7 @@ owl.onMounted(() => { const el = this.composerRef.el; this.contentHelper.updateEl(el); + this.processTokenAtCursor(); }); owl.onWillUnmount(() => { this.props.onComposerUnmounted?.(); @@ -25154,7 +26875,7 @@ if (this.props.focus === "cellFocus" && !this.autoCompleteState.showProvider && !content.startsWith("=")) { - this.env.model.dispatch("STOP_EDITION"); + interactiveStopEdition(this.env); return; } // All arrow keys are processed: up and down should move autocomplete, left @@ -25166,6 +26887,10 @@ // only for arrow up and down if (["ArrowUp", "ArrowDown"].includes(ev.key) && this.autoCompleteState.showProvider) { ev.preventDefault(); + if (this.autoCompleteState.selectedIndex === undefined) { + this.autoCompleteState.selectedIndex = 0; + return; + } if (ev.key === "ArrowUp") { this.autoCompleteState.selectedIndex--; if (this.autoCompleteState.selectedIndex < 0) { @@ -25181,22 +26906,23 @@ processTabKey(ev) { ev.preventDefault(); ev.stopPropagation(); - if (this.autoCompleteState.showProvider) { - const autoCompleteValue = this.autoCompleteState.values[this.autoCompleteState.selectedIndex]?.text; + const state = this.autoCompleteState; + if (state.showProvider && state.selectedIndex !== undefined) { + const autoCompleteValue = this.autoCompleteState.values[state.selectedIndex]?.text; if (autoCompleteValue) { this.autoComplete(autoCompleteValue); return; } } const direction = ev.shiftKey ? "left" : "right"; - this.env.model.dispatch("STOP_EDITION"); + interactiveStopEdition(this.env); this.env.model.selection.moveAnchorCell(direction, 1); } processSpaceKey(ev) { if (ev.ctrlKey) { ev.preventDefault(); ev.stopPropagation(); - this.showAutocomplete(""); + this.showFunctionAutocomplete(""); this.env.model.dispatch("STOP_COMPOSER_RANGE_SELECTION"); } } @@ -25207,19 +26933,20 @@ this.composerRef.el?.dispatchEvent(new InputEvent("input", { inputType: "insertParagraph", bubbles: true, isComposing: false })); return; } - if (this.autoCompleteState.showProvider) { - const autoCompleteValue = this.autoCompleteState.values[this.autoCompleteState.selectedIndex]?.text; + const state = this.autoCompleteState; + if (state.showProvider && state.selectedIndex !== undefined) { + const autoCompleteValue = this.autoCompleteState.values[state.selectedIndex]?.text; if (autoCompleteValue) { this.autoComplete(autoCompleteValue); return; } } - this.env.model.dispatch("STOP_EDITION"); + interactiveStopEdition(this.env); const direction = ev.shiftKey ? "up" : "down"; this.env.model.selection.moveAnchorCell(direction, 1); } processEscapeKey() { - this.env.model.dispatch("STOP_EDITION", { cancel: true }); + this.env.model.dispatch("CANCEL_EDITION"); } processF4Key() { this.env.model.dispatch("CYCLE_EDITION_REFERENCES"); @@ -25297,7 +27024,7 @@ this.processTokenAtCursor(); } } - showAutocomplete(searchTerm) { + showFunctionAutocomplete(searchTerm) { const searchTermUpperCase = searchTerm.toUpperCase(); if (!this.env.model.getters.getCurrentContent().startsWith("=") || searchTermUpperCase === "TRUE" || @@ -25305,6 +27032,7 @@ return; } this.autoCompleteState.showProvider = true; + this.autoCompleteState.type = "function"; let values = Object.entries(functionRegistry.content) .filter(([_, { hidden }]) => !hidden) .map(([text, { description }]) => { @@ -25530,6 +27258,10 @@ let content = this.env.model.getters.getCurrentContent(); this.autoCompleteState.showProvider = false; this.functionDescriptionState.showDescription = false; + const dataValidationAutocompleteValues = this.env.model.getters.getAutoCompleteDataValidationValues(); + if (!content.startsWith("=") && dataValidationAutocompleteValues.length) { + this.showDataValidationAutocomplete(dataValidationAutocompleteValues); + } if (content.startsWith("=")) { const token = this.env.model.getters.getTokenAtCursor(); if (!token) { @@ -25537,7 +27269,7 @@ } if (token.type === "SYMBOL") { // initialize Autocomplete Dropdown - this.showAutocomplete(token.value); + this.showFunctionAutocomplete(token.value); return; } const tokenContext = token.functionContext; @@ -25557,7 +27289,10 @@ } } autoComplete(value) { - if (value) { + if (!value) { + return; + } + if (this.autoCompleteState.type === "function") { const tokenAtCursor = this.env.model.getters.getTokenAtCursor(); if (tokenAtCursor) { let start = tokenAtCursor.end; @@ -25577,17 +27312,22 @@ } } } - this.env.model.dispatch("CHANGE_COMPOSER_CURSOR_SELECTION", { - start, - end, - }); + this.env.model.dispatch("CHANGE_COMPOSER_CURSOR_SELECTION", { start, end }); } - this.env.model.dispatch("REPLACE_COMPOSER_CURSOR_SELECTION", { - text: value, - }); + this.env.model.dispatch("REPLACE_COMPOSER_CURSOR_SELECTION", { text: value }); + } + else { + this.env.model.dispatch("SET_CURRENT_CONTENT", { content: value }); + this.env.model.dispatch("STOP_EDITION"); } this.processTokenAtCursor(); } + showDataValidationAutocomplete(values) { + this.autoCompleteState.showProvider = true; + this.autoCompleteState.type = "dataValidation"; + this.autoCompleteState.selectedIndex = undefined; + this.autoCompleteState.values = values.map((value) => ({ text: value, description: "" })); + } } Composer.props = { focus: { validate: (value) => ["inactive", "cellFocus", "contentFocus"].includes(value) }, @@ -25742,14 +27482,78 @@ }; const CSS$1 = css /* scss */ ` + .o-grid-cell-icon { + width: ${GRID_ICON_EDGE_LENGTH}px; + height: ${GRID_ICON_EDGE_LENGTH}px; + } +`; + class GridCellIcon extends owl.Component { + static style = CSS$1; + static template = "o-spreadsheet-GridCellIcon"; + get iconStyle() { + const x = this.getIconHorizontalPosition(); + const y = this.getIconVerticalPosition(); + return cssPropertiesToCss({ + top: `${y + (this.props.offset?.y || 0)}px`, + left: `${x + (this.props.offset?.x || 0)}px`, + }); + } + getIconVerticalPosition() { + const { sheetId, row } = this.props.cellPosition; + const merge = this.env.model.getters.getMerge(this.props.cellPosition); + const start = this.env.model.getters.getRowDimensionsInViewport(sheetId, row).start; + const end = this.env.model.getters.getRowDimensionsInViewport(sheetId, merge ? merge.bottom : row).end; + const cell = this.env.model.getters.getCell(this.props.cellPosition); + const align = this.props.verticalAlign || cell?.style?.verticalAlign || DEFAULT_VERTICAL_ALIGN; + switch (align) { + case "bottom": + return end - GRID_ICON_MARGIN - GRID_ICON_EDGE_LENGTH; + case "top": + return start + GRID_ICON_MARGIN; + default: + const centeringOffset = Math.floor((end - start - GRID_ICON_EDGE_LENGTH) / 2); + return end - GRID_ICON_EDGE_LENGTH - centeringOffset; + } + } + getIconHorizontalPosition() { + const { sheetId, col } = this.props.cellPosition; + const merge = this.env.model.getters.getMerge(this.props.cellPosition); + const start = this.env.model.getters.getColDimensionsInViewport(sheetId, col).start; + const end = this.env.model.getters.getColDimensionsInViewport(sheetId, merge ? merge.right : col).end; + const cell = this.env.model.getters.getCell(this.props.cellPosition); + const evaluatedCell = this.env.model.getters.getEvaluatedCell(this.props.cellPosition); + const align = this.props.horizontalAlign || cell?.style?.align || evaluatedCell.defaultAlign; + switch (align) { + case "right": + return end - GRID_ICON_MARGIN - GRID_ICON_EDGE_LENGTH; + case "left": + return start + GRID_ICON_MARGIN; + default: + const centeringOffset = Math.floor((end - start - GRID_ICON_EDGE_LENGTH) / 2); + return end - GRID_ICON_EDGE_LENGTH - centeringOffset; + } + } + isPositionVisible(position) { + const rect = this.env.model.getters.getVisibleRect(positionToZone(position)); + return !(rect.width === 0 || rect.height === 0); + } + } + GridCellIcon.props = { + cellPosition: Object, + horizontalAlign: { type: String, optional: true }, + verticalAlign: { type: String, optional: true }, + offset: { type: Object, optional: true }, + slots: Object, + }; + + const CSS = css /* scss */ ` .o-filter-icon { color: ${FILTERS_COLOR}; - position: absolute; display: flex; align-items: center; justify-content: center; - width: ${FILTER_ICON_EDGE_LENGTH}px; - height: ${FILTER_ICON_EDGE_LENGTH}px; + width: ${GRID_ICON_EDGE_LENGTH}px; + height: ${GRID_ICON_EDGE_LENGTH}px; } .o-filter-icon:hover { background: ${FILTERS_COLOR}; @@ -25757,62 +27561,10 @@ } `; class FilterIcon extends owl.Component { - static style = CSS$1; - static template = "o-spreadsheet-FilterIcon"; - get style() { - const { x, y } = this.props.position; - return `top:${y}px;left:${x}px`; - } - } - FilterIcon.props = { - position: Object, - isActive: Boolean, - onClick: Function, - }; - - const CSS = css /* scss */ ``; - class FilterIconsOverlay extends owl.Component { static style = CSS; - static template = "o-spreadsheet-FilterIconsOverlay"; - static components = { - FilterIcon, - }; - static defaultProps = { - gridPosition: { x: 0, y: 0 }, - }; - getVisibleFilterHeaders() { - const sheetId = this.env.model.getters.getActiveSheetId(); - const headerPositions = this.env.model.getters.getFilterHeaders(sheetId); - return headerPositions.filter((position) => this.isPositionVisible(position.col, position.row)); - } - getFilterHeaderPosition(position) { - const sheetId = this.env.model.getters.getActiveSheetId(); - const rowDims = this.env.model.getters.getRowDimensionsInViewport(sheetId, position.row); - const colDims = this.env.model.getters.getColDimensionsInViewport(sheetId, position.col); - const cell = this.env.model.getters.getCell({ sheetId, ...position }); - const verticalFilterIconPosition = this.getIconVerticalPosition(rowDims, cell); - return { - x: colDims.end - FILTER_ICON_EDGE_LENGTH + this.props.gridPosition.x - FILTER_ICON_MARGIN - 1, - y: verticalFilterIconPosition + this.props.gridPosition.y, - }; - } - // Calculates the vertical position of the filter icon based on the row dimensions and cell styles. - getIconVerticalPosition(rowDims, cell) { - const centeringOffset = Math.floor((rowDims.size - FILTER_ICON_EDGE_LENGTH) / 2); - switch (cell?.style?.verticalAlign) { - case "bottom": - return rowDims.end - FILTER_ICON_MARGIN - FILTER_ICON_EDGE_LENGTH; - case "top": - return rowDims.start + FILTER_ICON_MARGIN; - default: - return rowDims.end - FILTER_ICON_EDGE_LENGTH - centeringOffset; - } - } - isFilterActive(position) { - const sheetId = this.env.model.getters.getActiveSheetId(); - return this.env.model.getters.isFilterActive({ sheetId, ...position }); - } - toggleFilterMenu(position) { + static template = "o-spreadsheet-FilterIcon"; + onClick() { + const position = this.props.cellPosition; const activePopoverType = this.env.model.getters.getPersistentPopoverTypeAtPosition(position); if (activePopoverType && activePopoverType === "FilterMenu") { this.env.model.dispatch("CLOSE_CELL_POPOVER"); @@ -25825,20 +27577,108 @@ popoverType: "FilterMenu", }); } - isPositionVisible(x, y) { - const rect = this.env.model.getters.getVisibleRect({ - left: x, - right: x, - top: y, - bottom: y, - }); - return !(rect.width === 0 || rect.height === 0); + get isFilterActive() { + return this.env.model.getters.isFilterActive(this.props.cellPosition); + } + } + FilterIcon.props = { + cellPosition: Object, + }; + + class FilterIconsOverlay extends owl.Component { + static template = "o-spreadsheet-FilterIconsOverlay"; + static components = { + GridCellIcon, + FilterIcon, + }; + static defaultProps = { + gridPosition: { x: 0, y: 0 }, + }; + getFilterHeadersPositions() { + const sheetId = this.env.model.getters.getActiveSheetId(); + const headerPositions = this.env.model.getters.getFilterHeaders(sheetId); + return headerPositions.map((position) => ({ sheetId, ...position })); } } FilterIconsOverlay.props = { gridPosition: { type: Object, optional: true }, }; + const CHECKBOX_WIDTH = 15; + const MARGIN = (GRID_ICON_EDGE_LENGTH - CHECKBOX_WIDTH) / 2; + css /* scss */ ` + .o-dv-checkbox { + width: ${CHECKBOX_WIDTH}px; + height: ${CHECKBOX_WIDTH}px; + accent-color: #808080; + margin: ${MARGIN}px; + } +`; + class DataValidationCheckbox extends owl.Component { + static template = "o-spreadsheet-DataValidationCheckbox"; + onCheckboxChange(ev) { + const newValue = ev.target.checked; + const { sheetId, col, row } = this.props.cellPosition; + const cellContent = newValue ? "TRUE" : "FALSE"; + this.env.model.dispatch("UPDATE_CELL", { sheetId, col, row, content: cellContent }); + } + get checkBoxValue() { + return !!this.env.model.getters.getEvaluatedCell(this.props.cellPosition).value; + } + get isDisabled() { + const cell = this.env.model.getters.getCell(this.props.cellPosition); + return !!cell?.isFormula; + } + } + DataValidationCheckbox.props = { + cellPosition: Object, + }; + + const ICON_WIDTH = 13; + css /* scss */ ` + .o-dv-list-icon { + color: #808080; + border-radius: 1px; + height: ${GRID_ICON_EDGE_LENGTH}px; + width: ${GRID_ICON_EDGE_LENGTH}px; + + &:hover { + color: #ffffff; + background-color: #808080; + } + + svg { + width: ${ICON_WIDTH}px; + height: ${ICON_WIDTH}px; + } + } +`; + class DataValidationListIcon extends owl.Component { + static template = "o-spreadsheet-DataValidationListIcon"; + onClick() { + const { col, row } = this.props.cellPosition; + this.env.model.selection.selectCell(col, row); + this.env.startCellEdition(); + } + } + DataValidationListIcon.props = { + cellPosition: Object, + }; + + class DataValidationOverlay extends owl.Component { + static template = "o-spreadsheet-DataValidationOverlay"; + static components = { GridCellIcon, DataValidationCheckbox, DataValidationListIcon }; + get checkBoxCellPositions() { + return this.env.model.getters.getDataValidationCheckBoxCellPositions(); + } + get listIconsCellPositions() { + return this.env.model.getters.isReadonly() + ? [] + : this.env.model.getters.getDataValidationListCellsPositions(); + } + } + DataValidationOverlay.props = {}; + /** * Transform a figure with coordinates from the model, to coordinates as they are shown on the screen, * taking into account the scroll position of the active sheet and the frozen panes. @@ -26426,6 +28266,93 @@ onFigureDeleted: Function, }; + css /* scss */ ` + .o-grid-add-rows { + input { + box-sizing: border-box; + width: 60px; + height: 30px; + } + + .o-validation-error { + display: inline-block !important; + margin-top: 0; + margin-left: 8px; + } + } +`; + class GridAddRowsFooter extends owl.Component { + static template = "o-spreadsheet-GridAddRowsFooter"; + static components = { ValidationMessages }; + inputRef = owl.useRef("inputRef"); + state = owl.useState({ + inputValue: "100", + errorFlag: false, + }); + setup() { + owl.useExternalListener(window, "click", this.onExternalClick, { capture: true }); + } + get addRowsPosition() { + const activeSheetId = this.env.model.getters.getActiveSheetId(); + const { numberOfRows } = this.env.model.getters.getSheetSize(activeSheetId); + const { scrollY } = this.env.model.getters.getActiveSheetScrollInfo(); + const rowDimensions = this.env.model.getters.getRowDimensions(activeSheetId, numberOfRows - 1); + const top = rowDimensions.end - scrollY; + return cssPropertiesToCss({ + top: `${top}px`, + }); + } + get errorMessages() { + return [_t("Please enter a number between 0 and 10000.")]; + } + onKeydown(ev) { + if (ev.key.toUpperCase() === "ESCAPE") { + this.props.focusGrid(); + } + else if (ev.key.toUpperCase() === "ENTER") { + this.onConfirm(); + } + } + onInput(ev) { + const value = ev.target.value; + this.state.inputValue = value; + const quantity = Number(value); + this.state.errorFlag = Number.isNaN(quantity) || quantity <= 0 || quantity > 10000; + } + onConfirm() { + if (this.state.errorFlag) { + return; + } + const quantity = Number(this.state.inputValue); + const activeSheetId = this.env.model.getters.getActiveSheetId(); + const rowNumber = this.env.model.getters.getNumberRows(activeSheetId); + this.env.model.dispatch("ADD_COLUMNS_ROWS", { + sheetId: activeSheetId, + position: "after", + base: rowNumber - 1, + quantity, + dimension: "ROW", + }); + this.props.focusGrid(); + // After adding new rows, scroll down to the new last row + const { scrollX } = this.env.model.getters.getActiveSheetDOMScrollInfo(); + const { end } = this.env.model.getters.getRowDimensions(activeSheetId, rowNumber + quantity - 1); + this.env.model.dispatch("SET_VIEWPORT_OFFSET", { + offsetX: scrollX, + offsetY: end, + }); + } + onExternalClick(ev) { + if (this.inputRef.el !== document.activeElement || ev.target === this.inputRef.el) { + return; + } + this.props.focusGrid(); + } + } + GridAddRowsFooter.props = { + focusGrid: Function, + }; + /** * Manages an event listener on a ref. Useful for hooks that want to manage * event listeners, especially more than one. Prefer using t-on directly in @@ -26580,7 +28507,7 @@ } class GridOverlay extends owl.Component { static template = "o-spreadsheet-GridOverlay"; - static components = { FiguresContainer }; + static components = { FiguresContainer, DataValidationOverlay, GridAddRowsFooter }; static defaultProps = { onCellHovered: () => { }, onCellDoubleClicked: () => { }, @@ -26589,9 +28516,9 @@ onGridResized: () => { }, onFigureDeleted: () => { }, }; - gridOverlay; + gridOverlay = owl.useRef("gridOverlay"); + gridOverlayRect = useAbsoluteBoundingRect(this.gridOverlay); setup() { - this.gridOverlay = owl.useRef("gridOverlay"); useCellHovered(this.env, this.gridOverlay, this.props.onCellHovered); const resizeObserver = new ResizeObserver(() => { const boundingRect = this.gridOverlayEl.getBoundingClientRect(); @@ -26643,8 +28570,10 @@ this.props.onCellRightClicked(col, row, { x: ev.clientX, y: ev.clientY }); } getCartesianCoordinates(ev) { - const colIndex = this.env.model.getters.getColIndex(ev.offsetX); - const rowIndex = this.env.model.getters.getRowIndex(ev.offsetY); + const x = ev.clientX - this.gridOverlayRect.x; + const y = ev.clientY - this.gridOverlayRect.y; + const colIndex = this.env.model.getters.getColIndex(x); + const rowIndex = this.env.model.getters.getRowIndex(y); return [colIndex, rowIndex]; } } @@ -27991,7 +29920,7 @@ this.closeOpenedPopover(); } if (this.env.model.getters.getEditionMode() === "editing") { - this.env.model.dispatch("STOP_EDITION"); + interactiveStopEdition(this.env); } if (shiftKey) { this.env.model.selection.setAnchorCorner(col, row); @@ -28099,7 +30028,7 @@ const lastZone = this.env.model.getters.getSelectedZone(); const { left: col, top: row } = lastZone; let type = "CELL"; - this.env.model.dispatch("STOP_EDITION"); + interactiveStopEdition(this.env); if (this.env.model.getters.getActiveCols().has(col)) { type = "COL"; } @@ -33423,7 +35352,7 @@ export(data) { if (data.sheets) { for (let sheet of data.sheets) { - // TODO This code is false, if two plugins want to insert figures on the sheet, it will crash ! + // TODO This code is false, if two plugins want ot insert figures on the sheet, it will crash ! const sheetFigures = this.getters.getFigures(sheet.id); const figures = []; for (let sheetFigure of sheetFigures) { @@ -33561,8 +35490,8 @@ switch (cmd.type) { case "ADD_CONDITIONAL_FORMAT": return this.checkValidations(cmd, this.checkCFRule, this.checkEmptyRange); - case "MOVE_CONDITIONAL_FORMAT": - return this.checkValidReordering(cmd.cfId, cmd.direction, cmd.sheetId); + case "CHANGE_CONDITIONAL_FORMAT_PRIORITY": + return this.checkValidPriorityChange(cmd.cfId, cmd.delta, cmd.sheetId); } return "Success" /* CommandResult.Success */; } @@ -33592,8 +35521,8 @@ case "REMOVE_CONDITIONAL_FORMAT": this.removeConditionalFormatting(cmd.id, cmd.sheetId); break; - case "MOVE_CONDITIONAL_FORMAT": - this.reorderConditionalFormatting(cmd.cfId, cmd.direction, cmd.sheetId); + case "CHANGE_CONDITIONAL_FORMAT_PRIORITY": + this.changeCFPriority(cmd.cfId, cmd.delta, cmd.sheetId); break; } } @@ -33709,13 +35638,13 @@ } this.history.update("cfRules", sheet, currentCF); } - checkValidReordering(cfId, direction, sheetId) { + checkValidPriorityChange(cfId, delta, sheetId) { if (!this.cfRules[sheetId]) return "InvalidSheetId" /* CommandResult.InvalidSheetId */; const ruleIndex = this.cfRules[sheetId].findIndex((cf) => cf.id === cfId); if (ruleIndex === -1) return "InvalidConditionalFormatId" /* CommandResult.InvalidConditionalFormatId */; - const cfIndex2 = direction === "up" ? ruleIndex - 1 : ruleIndex + 1; + const cfIndex2 = ruleIndex - delta; if (cfIndex2 < 0 || cfIndex2 >= this.cfRules[sheetId].length) { return "InvalidConditionalFormatId" /* CommandResult.InvalidConditionalFormatId */; } @@ -33864,19 +35793,225 @@ this.history.update("cfRules", sheet, currentCF); } } - reorderConditionalFormatting(cfId, direction, sheetId) { - const cfIndex1 = this.cfRules[sheetId].findIndex((s) => s.id === cfId); - const cfIndex2 = direction === "up" ? cfIndex1 - 1 : cfIndex1 + 1; - if (cfIndex2 < 0 || cfIndex2 >= this.cfRules[sheetId].length) - return; - if (cfIndex1 !== -1 && cfIndex2 !== -1) { - const currentCF = [...this.cfRules[sheetId]]; - const tmp = currentCF[cfIndex1]; - currentCF[cfIndex1] = currentCF[cfIndex2]; - currentCF[cfIndex2] = tmp; - this.history.update("cfRules", sheetId, currentCF); + changeCFPriority(cfId, delta, sheetId) { + const currentIndex = this.cfRules[sheetId].findIndex((s) => s.id === cfId); + const cf = this.cfRules[sheetId][currentIndex]; + const targetIndex = currentIndex - delta; // priority goes up when index goes down + const cfRules = [...this.cfRules[sheetId]]; + cfRules.splice(currentIndex, 1); + cfRules.splice(targetIndex, 0, cf); + this.history.update("cfRules", sheetId, cfRules); + } + } + + class DataValidationPlugin extends CorePlugin { + static getters = [ + "cellHasListDataValidationIcon", + "getDataValidationRule", + "getDataValidationRules", + "getValidationRuleForCell", + ]; + rules = {}; + adaptRanges(applyChange, sheetId) { + const sheetIds = sheetId ? [sheetId] : Object.keys(this.rules); + for (const sheetId of sheetIds) { + this.loopThroughRangesOfSheet(sheetId, applyChange); } } + loopThroughRangesOfSheet(sheetId, applyChange) { + const rules = this.rules[sheetId]; + for (let ruleIndex = rules.length - 1; ruleIndex >= 0; ruleIndex--) { + const rule = this.rules[sheetId][ruleIndex]; + for (let rangeIndex = rule.ranges.length - 1; rangeIndex >= 0; rangeIndex--) { + const range = rule.ranges[rangeIndex]; + const change = applyChange(range); + switch (change.changeType) { + case "REMOVE": + if (rule.ranges.length === 1) { + this.removeDataValidationRule(sheetId, rule.id); + } + else { + const copy = rule.ranges.slice(); + copy.splice(rangeIndex, 1); + this.history.update("rules", sheetId, ruleIndex, "ranges", copy); + } + break; + case "RESIZE": + case "MOVE": + case "CHANGE": + this.history.update("rules", sheetId, ruleIndex, "ranges", rangeIndex, change.range); + break; + } + } + } + } + allowDispatch(cmd) { + switch (cmd.type) { + case "ADD_DATA_VALIDATION_RULE": + return this.checkValidations(cmd, this.chainValidations(this.checkCriterionTypeIsValid, this.checkCriterionHasValidNumberOfValues, this.checkCriterionValuesAreValid)); + case "REMOVE_DATA_VALIDATION_RULE": + if (!this.rules[cmd.sheetId].find((rule) => rule.id === cmd.id)) { + return "UnknownDataValidationRule" /* CommandResult.UnknownDataValidationRule */; + } + break; + } + return "Success" /* CommandResult.Success */; + } + handle(cmd) { + switch (cmd.type) { + case "CREATE_SHEET": + this.history.update("rules", cmd.sheetId, []); + break; + case "DUPLICATE_SHEET": { + const rules = deepCopy(this.rules[cmd.sheetId]).map((rule) => ({ + ...rule, + ranges: rule.ranges.map((range) => copyRangeWithNewSheetId(cmd.sheetId, cmd.sheetIdTo, range)), + })); + this.history.update("rules", cmd.sheetIdTo, rules); + break; + } + case "DELETE_SHEET": { + const rules = { ...this.rules }; + delete rules[cmd.sheetId]; + this.history.update("rules", rules); + break; + } + case "REMOVE_DATA_VALIDATION_RULE": { + this.removeDataValidationRule(cmd.sheetId, cmd.id); + break; + } + case "ADD_DATA_VALIDATION_RULE": { + const ranges = cmd.ranges.map((range) => this.getters.getRangeFromRangeData(range)); + this.addDataValidationRule(cmd.sheetId, { ...cmd.rule, ranges }); + break; + } + } + } + getDataValidationRules(sheetId) { + return this.rules[sheetId]; + } + getDataValidationRule(sheetId, id) { + return this.rules[sheetId].find((rule) => rule.id === id); + } + getValidationRuleForCell({ sheetId, col, row }) { + for (const rule of this.rules[sheetId]) { + for (const range of rule.ranges) { + if (isInside(col, row, range.zone)) { + return rule; + } + } + } + return undefined; + } + cellHasListDataValidationIcon(cellPosition) { + const rule = this.getValidationRuleForCell(cellPosition); + if (!rule) + return false; + return ((rule.criterion.type === "isValueInList" || rule.criterion.type === "isValueInRange") && + rule.criterion.displayStyle === "arrow"); + } + addDataValidationRule(sheetId, newRule) { + const rules = this.rules[sheetId]; + if (newRule.criterion.type === "isBoolean") { + this.setCenterStyleToBooleanCells(newRule); + } + const adaptedRules = this.removeRangesFromRules(sheetId, newRule.ranges, rules); + const ruleIndex = adaptedRules.findIndex((rule) => rule.id === newRule.id); + if (ruleIndex !== -1) { + adaptedRules[ruleIndex] = newRule; + this.history.update("rules", sheetId, adaptedRules); + } + else { + this.history.update("rules", sheetId, [...adaptedRules, newRule]); + } + } + removeRangesFromRules(sheetId, ranges, rules) { + rules = deepCopy(rules); + const rangesXcs = ranges.map((range) => this.getters.getRangeString(range, sheetId)); + for (const rule of rules) { + const ruleRanges = rule.ranges.map((range) => this.getters.getRangeString(range, sheetId)); + rule.ranges = recomputeZones(ruleRanges, rangesXcs).map((xc) => this.getters.getRangeFromSheetXC(sheetId, xc)); + } + return rules.filter((rule) => rule.ranges.length > 0); + } + removeDataValidationRule(sheetId, ruleId) { + const rules = this.rules[sheetId]; + const newRules = rules.filter((rule) => rule.id !== ruleId); + this.history.update("rules", sheetId, newRules); + } + setCenterStyleToBooleanCells(rule) { + for (const position of getCellPositionsInRanges(rule.ranges)) { + const cell = this.getters.getCell(position); + const { sheetId, col, row } = position; + const style = { + ...cell?.style, + align: cell?.style?.align ?? "center", + verticalAlign: cell?.style?.verticalAlign ?? "middle", + }; + this.dispatch("UPDATE_CELL", { sheetId, col, row, style }); + } + } + import(data) { + for (const sheet of data.sheets) { + this.rules[sheet.id] = []; + if (!sheet.dataValidationRules) { + continue; + } + for (const rule of sheet.dataValidationRules) { + this.rules[sheet.id].push({ + ...rule, + ranges: rule.ranges.map((range) => this.getters.getRangeFromSheetXC(sheet.id, range)), + }); + } + } + } + export(data) { + if (!data.sheets) { + return; + } + for (const sheet of data.sheets) { + sheet.dataValidationRules = []; + for (const rule of this.rules[sheet.id]) { + sheet.dataValidationRules.push({ + ...rule, + ranges: rule.ranges.map((range) => this.getters.getRangeString(range, sheet.id)), + }); + } + } + } + checkCriterionTypeIsValid(cmd) { + return dataValidationEvaluatorRegistry.contains(cmd.rule.criterion.type) + ? "Success" /* CommandResult.Success */ + : "UnknownDataValidationCriterionType" /* CommandResult.UnknownDataValidationCriterionType */; + } + checkCriterionHasValidNumberOfValues(cmd) { + const criterion = cmd.rule.criterion; + const evaluator = dataValidationEvaluatorRegistry.get(criterion.type); + const expectedNumberOfValues = evaluator.numberOfValues(criterion); + if (expectedNumberOfValues !== undefined && + criterion.values.length !== expectedNumberOfValues) { + return "InvalidNumberOfCriterionValues" /* CommandResult.InvalidNumberOfCriterionValues */; + } + return "Success" /* CommandResult.Success */; + } + checkCriterionValuesAreValid(cmd) { + const criterion = cmd.rule.criterion; + const evaluator = dataValidationEvaluatorRegistry.get(criterion.type); + if (criterion.values.some((value) => { + if (value.startsWith("=")) { + return evaluator.allowedValues === "onlyLiterals"; + } + else if (evaluator.allowedValues === "onlyFormulas") { + return true; + } + else { + return !evaluator.isCriterionValueValid(value); + } + })) { + return "InvalidDataValidationCriterionValue" /* CommandResult.InvalidDataValidationCriterionValue */; + } + return "Success" /* CommandResult.Success */; + } } class FigurePlugin extends CorePlugin { @@ -34817,6 +36952,7 @@ "getMergesInZone", "isSingleCellOrMerge", "getSelectionRangeString", + "isMainCellPosition", ]; nextId = 1; merges = {}; @@ -35021,6 +37157,9 @@ const { numberOfCols, numberOfRows } = zoneToDimension(zone); return numberOfCols === 1 && numberOfRows === 1; } + isMainCellPosition(position) { + return deepEquals(this.getMainCellPosition(position), position); + } // --------------------------------------------------------------------------- // Merges // --------------------------------------------------------------------------- @@ -38381,12 +40520,11 @@ if (cell.type === CellValueType.error) { return false; } - const locale = this.getters.getLocale(); const [value0, value1] = rule.values.map((val) => { if (val.startsWith("=")) { return this.getters.evaluateFormula(target.sheetId, val); } - return parseLiteral(val, locale); + return parseLiteral(val, DEFAULT_LOCALE); }); switch (rule.operator) { case "IsEmpty": @@ -38437,6 +40575,154 @@ }; } + const VALID_RESULT = { isValid: true }; + class EvaluationDataValidationPlugin extends UIPlugin { + static getters = [ + "getDataValidationInvalidCriterionValueMessage", + "getDataValidationCheckBoxCellPositions", + "getDataValidationListCellsPositions", + "getInvalidDataValidationMessage", + "getValidationResultForCellValue", + "isCellValidCheckbox", + "isDataValidationInvalid", + ]; + validationResults = {}; + handle(cmd) { + if (invalidateEvaluationCommands.has(cmd.type) || + cmd.type === "EVALUATE_CELLS" || + (cmd.type === "UPDATE_CELL" && "content" in cmd)) { + this.validationResults = {}; + return; + } + switch (cmd.type) { + case "ADD_DATA_VALIDATION_RULE": + case "REMOVE_DATA_VALIDATION_RULE": + delete this.validationResults[cmd.sheetId]; + break; + } + } + isDataValidationInvalid(cellPosition) { + return !this.getValidationResultForCell(cellPosition).isValid; + } + getInvalidDataValidationMessage(cellPosition) { + const validationResult = this.getValidationResultForCell(cellPosition); + return validationResult.isValid ? undefined : validationResult.error; + } + /** + * Check if the value is valid for the given criterion, and return an error message if not. + * + * The value must be canonicalized. + */ + getDataValidationInvalidCriterionValueMessage(criterionType, value) { + const evaluator = dataValidationEvaluatorRegistry.get(criterionType); + if (value.startsWith("=")) { + return evaluator.allowedValues === "onlyLiterals" + ? _t("The value must not be a formula") + : undefined; + } + else if (evaluator.allowedValues === "onlyFormulas") { + return _t("The value must be a formula"); + } + return evaluator.isCriterionValueValid(value) ? undefined : evaluator.criterionValueErrorString; + } + isCellValidCheckbox(cellPosition) { + if (!this.getters.isMainCellPosition(cellPosition)) { + return false; + } + const rule = this.getters.getValidationRuleForCell(cellPosition); + if (!rule || rule.criterion.type !== "isBoolean") { + return false; + } + return this.getValidationResultForCell(cellPosition).isValid; + } + /** Get the validation result if the cell on the given position had the given value */ + getValidationResultForCellValue(cellValue, cellPosition) { + const rule = this.getters.getValidationRuleForCell(cellPosition); + if (!rule) { + return VALID_RESULT; + } + const error = this.getRuleErrorForCellValue(cellValue, cellPosition, rule); + return error ? { error, rule, isValid: false } : VALID_RESULT; + } + getDataValidationCheckBoxCellPositions() { + const rules = this.getters + .getDataValidationRules(this.getters.getActiveSheetId()) + .filter((rule) => rule.criterion.type === "isBoolean"); + return getCellPositionsInRanges(rules.map((rule) => rule.ranges).flat()).filter((position) => this.isCellValidCheckbox(position)); + } + getDataValidationListCellsPositions() { + const rules = this.getters + .getDataValidationRules(this.getters.getActiveSheetId()) + .filter((rule) => (rule.criterion.type === "isValueInList" || rule.criterion.type === "isValueInRange") && + rule.criterion.displayStyle === "arrow"); + return getCellPositionsInRanges(rules.map((rule) => rule.ranges).flat()); + } + getValidationResultForCell(cellPosition) { + const { col, row, sheetId } = cellPosition; + if (!this.validationResults[sheetId]) { + this.validationResults[sheetId] = this.computeSheetValidationResults(sheetId); + } + return this.validationResults[sheetId][col]?.[row]?.() || VALID_RESULT; + } + computeSheetValidationResults(sheetId) { + const validationResults = {}; + const ranges = this.getters.getDataValidationRules(sheetId).map((rule) => rule.ranges); + for (const cellPosition of getCellPositionsInRanges(ranges.flat())) { + const { col, row } = cellPosition; + if (!validationResults[col]) { + validationResults[col] = []; + } + validationResults[col][row] = lazy(() => { + const evaluatedCell = this.getters.getEvaluatedCell(cellPosition); + if (evaluatedCell.type === CellValueType.empty) { + return VALID_RESULT; + } + return this.getValidationResultForCellValue(evaluatedCell.value, cellPosition); + }); + } + return validationResults; + } + getRuleErrorForCellValue(cellValue, cellPosition, rule) { + const { sheetId } = cellPosition; + const criterion = rule.criterion; + const evaluator = dataValidationEvaluatorRegistry.get(criterion.type); + const offset = this.getCellOffsetInRule(cellPosition, rule); + const evaluatedCriterionValues = this.getEvaluatedCriterionValues(sheetId, offset, criterion); + const evaluatedCriterion = { ...criterion, values: evaluatedCriterionValues }; + if (evaluator.isValueValid(cellValue, evaluatedCriterion, this.getters, sheetId)) { + return undefined; + } + return evaluator.getErrorString(evaluatedCriterion, this.getters, sheetId); + } + /** Get the offset of the cell inside the ranges of the rule. Throws an error if the cell isn't inside the rule. */ + getCellOffsetInRule(cellPosition, rule) { + const range = rule.ranges.find((range) => isInside(cellPosition.col, cellPosition.row, range.zone)); + if (!range) { + throw new Error("The cell is not in any range of the rule"); + } + return { + col: cellPosition.col - range.zone.left, + row: cellPosition.row - range.zone.top, + }; + } + getEvaluatedCriterionValues(sheetId, offset, criterion) { + return criterion.values.map((value) => { + if (!value.startsWith("=")) { + return value; + } + try { + const formula = compile(value); + const translatedFormula = this.getters.getTranslatedCellFormula(sheetId, offset.col, offset.row, formula, formula.dependencies.map((d) => this.getters.getRangeFromSheetXC(sheetId, d))); + const evaluated = this.getters.evaluateFormula(sheetId, translatedFormula); + return evaluated ? evaluated.toString() : ""; + } + catch (e) { + return e instanceof EvaluationError ? e.errorType : CellErrorType.GenericError; + } + }); + } + } + class UIRowSizePlugin extends UIPlugin { static getters = ["getRowSize"]; tallestCellInRow = {}; @@ -38708,6 +40994,7 @@ border: cmd.border, }); this.autofillCF(cmd.originCol, cmd.originRow, cmd.col, cmd.row); + this.autofillDV(cmd.originCol, cmd.originRow, cmd.col, cmd.row); } } // --------------------------------------------------------------------------- @@ -38979,6 +41266,21 @@ } } } + autofillDV(originCol, originRow, col, row) { + const sheetId = this.getters.getActiveSheetId(); + const cellPosition = { sheetId, col: originCol, row: originRow }; + const dvOrigin = this.getters.getValidationRuleForCell(cellPosition); + if (!dvOrigin) { + return; + } + const dvRangesXcs = dvOrigin.ranges.map((range) => this.getters.getRangeString(range, sheetId)); + const newDvRanges = recomputeZones(dvRangesXcs.concat(toXC(col, row)), []); + this.dispatch("ADD_DATA_VALIDATION_RULE", { + rule: dvOrigin, + ranges: newDvRanges.map((xc) => this.getters.getRangeDataFromXc(sheetId, xc)), + sheetId, + }); + } // --------------------------------------------------------------------------- // Grid rendering // --------------------------------------------------------------------------- @@ -40504,7 +42806,7 @@ }); for (const filterTable of this.copiedTables) { this.dispatch("REMOVE_FILTER_TABLE", { - sheetId: this.getters.getActiveSheetId(), + sheetId: this.sheetId, target: [filterTable.zone], }); } @@ -40604,6 +42906,7 @@ // We have to do it when the command handled is "PASTE", not "INSERT_CELL" // or "DELETE_CELL". So, the state should be the local state const shouldPasteCF = clipboardOptions?.pasteOption !== "onlyValue" && clipboardOptions?.shouldPasteCF; + const shouldPasteDV = !clipboardOptions?.pasteOption; const sheetId = this.getters.getActiveSheetId(); // first, add missing cols/rows if needed this.addMissingDimensions(width, height, col, row); @@ -40623,6 +42926,9 @@ if (shouldPasteCF) { this.pasteCf(origin.position, position); } + if (shouldPasteDV) { + this.pasteDataValidation(origin.position, position); + } } } } @@ -40840,6 +43146,54 @@ .find((cf) => cf.stopIfTrue === originCF.stopIfTrue && deepEquals(cf.rule, originCF.rule)); return cfInTarget ? cfInTarget : { ...originCF, id: this.uuidGenerator.uuidv4(), ranges: [] }; } + pasteDataValidation(origin, target) { + const rule = this.getters.getValidationRuleForCell(origin); + if (!rule) { + return; + } + const xc = toXC(target.col, target.row); + for (const range of rule.ranges) { + if (isInside(origin.col, origin.row, range.zone)) { + const toRemoveRange = []; + if (this.operation === "CUT") { + toRemoveRange.push(toXC(origin.col, origin.row)); + } + if (origin.sheetId === target.sheetId) { + this.adaptDataValidationRule(origin.sheetId, rule, [xc], toRemoveRange); + } + else { + this.adaptDataValidationRule(origin.sheetId, rule, [], toRemoveRange); + let copyToRule = this.getDataValidationRuleToCopyTo(target.sheetId, rule); + this.adaptDataValidationRule(target.sheetId, copyToRule, [xc], []); + } + } + } + } + getDataValidationRuleToCopyTo(targetSheetId, originRule) { + const ruleInTargetSheet = this.getters + .getDataValidationRules(targetSheetId) + .find((rule) => deepEquals(originRule.criterion, rule.criterion) && + originRule.isBlocking === rule.isBlocking); + return ruleInTargetSheet + ? ruleInTargetSheet + : { ...originRule, id: this.uuidGenerator.uuidv4(), ranges: [] }; + } + /** + * Add or remove XCs to a given data validation rule. + */ + adaptDataValidationRule(sheetId, rule, toAdd, toRemove) { + const dvRangesXcs = rule.ranges.map((range) => this.getters.getRangeString(range, sheetId)); + const newRangesXC = recomputeZones([...dvRangesXcs, ...toAdd], toRemove); + if (newRangesXC.length === 0) { + this.dispatch("REMOVE_DATA_VALIDATION_RULE", { sheetId, id: rule.id }); + return; + } + this.dispatch("ADD_DATA_VALIDATION_RULE", { + rule, + ranges: newRangesXC.map((xc) => this.getters.getRangeDataFromXc(sheetId, xc)), + sheetId, + }); + } } class DataCleanupPlugin extends UIPlugin { @@ -41065,14 +43419,10 @@ case "REPLACE_ALL_SEARCH": this.replaceAll(cmd.replaceWith); break; - case "EVALUATE_CELLS": - case "UPDATE_CELL": - case "REMOVE_FILTER_TABLE": - case "UPDATE_FILTER": - this.isSearchDirty = true; - break; case "UNDO": case "REDO": + case "REMOVE_FILTER_TABLE": + case "UPDATE_FILTER": case "REMOVE_COLUMNS_ROWS": case "HIDE_COLUMNS_ROWS": case "UNHIDE_COLUMNS_ROWS": @@ -41224,7 +43574,7 @@ const replaceRegex = new RegExp(this.currentSearchRegex.source, this.currentSearchRegex.flags + "g"); const toReplace = this.getSearchableString({ sheetId, col, row }); const content = toReplace.replace(replaceRegex, replaceWith); - const canonicalContent = canonicalizeContent(content, this.getters.getLocale()); + const canonicalContent = canonicalizeNumberContent(content, this.getters.getLocale()); this.dispatch("UPDATE_CELL", { sheetId, col, row, content: canonicalContent }); } /** @@ -41574,7 +43924,7 @@ ctx.fillStyle = style.fillColor || "#ffffff"; ctx.fillRect(box.x, box.y, box.width, box.height); } - if (box.error) { + if (box.isError) { ctx.fillStyle = "red"; ctx.beginPath(); ctx.moveTo(box.x + box.width - 5, box.y); @@ -41718,7 +44068,7 @@ x += box.width - MIN_CELL_TEXT_MARGIN - - (box.isFilterHeader ? ICON_EDGE_LENGTH + FILTER_ICON_MARGIN : 0); + (box.hasIcon ? GRID_ICON_EDGE_LENGTH + GRID_ICON_MARGIN : 0); } else { x += box.width / 2; @@ -41949,9 +44299,13 @@ const position = { sheetId, col: col + 1, row }; const nextCell = this.getters.getEvaluatedCell(position); const nextCellBorder = this.getters.getCellBorderWithFilterBorder(position); + const cellHasIcon = this.getters.doesCellHaveGridIcon(position); + const cellHasCheckbox = this.getters.isCellValidCheckbox(position); if (nextCell.type !== CellValueType.empty || this.getters.isInMerge(position) || - nextCellBorder?.left) { + nextCellBorder?.left || + cellHasIcon || + cellHasCheckbox) { return col; } col++; @@ -41965,9 +44319,13 @@ const position = { sheetId, col: col - 1, row }; const previousCell = this.getters.getEvaluatedCell(position); const previousCellBorder = this.getters.getCellBorderWithFilterBorder(position); + const cellHasIcon = this.getters.doesCellHaveGridIcon(position); + const cellHasCheckbox = this.getters.isCellValidCheckbox(position); if (previousCell.type !== CellValueType.empty || this.getters.isInMerge(position) || - previousCellBorder?.right) { + previousCellBorder?.right || + cellHasIcon || + cellHasCheckbox) { return col; } col--; @@ -42003,8 +44361,10 @@ border: this.getters.getCellBorderWithFilterBorder(position) || undefined, style: this.getters.getCellComputedStyle(position), verticalAlign, + isError: (cell.type === CellValueType.error && cell.error.isVerbose) || + this.getters.isDataValidationInvalid(position), }; - if (cell.type === CellValueType.empty) { + if (cell.type === CellValueType.empty || this.getters.isCellValidCheckbox(position)) { return box; } /** Icon CF */ @@ -42019,9 +44379,9 @@ image: ICONS[cfIcon].img, }; } - /** Filter Header */ - box.isFilterHeader = this.getters.isFilterHeader(position); - const headerIconWidth = box.isFilterHeader ? FILTER_ICON_EDGE_LENGTH + FILTER_ICON_MARGIN : 0; + /** Filter Header or data validation icon */ + box.hasIcon = this.getters.doesCellHaveGridIcon(position); + const headerIconWidth = box.hasIcon ? GRID_ICON_EDGE_LENGTH + GRID_ICON_MARGIN : 0; /** Content */ const style = this.getters.getCellComputedStyle(position); const wrapping = style.wrapping || "overflow"; @@ -42035,13 +44395,9 @@ width: wrapping === "overflow" ? textWidth : width, align, }; - /** Error */ - if (cell.type === CellValueType.error && cell.error.logLevel > CellErrorLevel.silent) { - box.error = cell.error.message; - } /** ClipRect */ const isOverflowing = contentWidth > width || fontSizePX > height; - if (cfIcon || box.isFilterHeader) { + if (cfIcon || box.hasIcon) { box.clipRect = { x: box.x + iconBoxWidth, y: box.y, @@ -42468,7 +44824,10 @@ case "REMOVE_RANGE": if (cmd.id !== this.focusedInputId) { const input = this.inputs[cmd.id]; - this.selection.capture(input, { cell: { col: 0, row: 0 }, zone: positionToZone({ col: 0, row: 0 }) }, { handleEvent: input.handleEvent.bind(input) }); + this.selection.capture(input, { cell: { col: 0, row: 0 }, zone: positionToZone({ col: 0, row: 0 }) }, { + handleEvent: input.handleEvent.bind(input), + release: () => (this.focusedInputId = null), + }); this.focusedInputId = cmd.id; } break; @@ -42480,7 +44839,10 @@ if (this.isRangeValid(range?.xc || "A1")) { const sheetId = this.getters.getActiveSheetId(); const zone = this.getters.getRangeFromSheetXC(sheetId, range?.xc || "A1").zone; - this.selection.capture(input, { cell: { col: zone.left, row: zone.top }, zone }, { handleEvent: input.handleEvent.bind(input) }); + this.selection.capture(input, { cell: { col: zone.left, row: zone.top }, zone }, { + handleEvent: input.handleEvent.bind(input), + release: () => (this.focusedInputId = null), + }); } this.focusedInputId = cmd.id; } @@ -42536,7 +44898,10 @@ zone: positionToZone({ col: 0, row: 0 }), cell: { col: 0, row: 0 }, }; - this.selection.capture(input, anchor, { handleEvent: input.handleEvent.bind(input) }); + this.selection.capture(input, anchor, { + handleEvent: input.handleEvent.bind(input), + release: () => (this.focusedInputId = null), + }); this.focusedInputId = id; } } @@ -42860,6 +45225,7 @@ class SheetUIPlugin extends UIPlugin { static getters = [ + "doesCellHaveGridIcon", "getCellWidth", "getTextWidth", "getCellText", @@ -42917,9 +45283,8 @@ if (icon) { contentWidth += computeIconWidth(style); } - const isFilterHeader = this.getters.isFilterHeader(position); - if (isFilterHeader) { - contentWidth += ICON_EDGE_LENGTH + FILTER_ICON_MARGIN; + if (this.getters.doesCellHaveGridIcon(position)) { + contentWidth += ICON_EDGE_LENGTH + GRID_ICON_MARGIN; } if (contentWidth === 0) { return 0; @@ -43001,6 +45366,11 @@ } return offset; } + doesCellHaveGridIcon(position) { + const isFilterHeader = this.getters.isFilterHeader(position); + const hasListIcon = !this.getters.isReadonly() && this.getters.cellHasListDataValidationIcon(position); + return isFilterHeader || hasListIcon; + } // --------------------------------------------------------------------------- // Grid manipulation // --------------------------------------------------------------------------- @@ -43434,7 +45804,7 @@ sheetId, col: col + index, row, - content: canonicalizeContent(content, this.getters.getLocale()), + content: canonicalizeNumberContent(content, this.getters.getLocale()), format: "", style: mainCell?.style || null, }); @@ -43691,7 +46061,7 @@ function canonicalizeNumberValue(content, locale) { return content.startsWith("=") ? canonicalizeFormula(content, locale) - : toCanonicalNumberString(content, locale); + : canonicalizeNumberLiteral(content, locale); } /** Change a formula to its canonical form (en_US locale) */ function canonicalizeFormula(formula, locale) { @@ -44150,6 +46520,7 @@ "getComposerHighlights", "getCurrentEditedCell", "getCycledReference", + "getAutoCompleteDataValidationValues", ]; col = 0; row = 0; @@ -44172,20 +46543,20 @@ if (cmd.selection) { return this.validateSelection(cmd.content.length, cmd.selection.start, cmd.selection.end); } - else { - return "Success" /* CommandResult.Success */; - } + break; case "START_EDITION": if (cmd.selection) { const content = cmd.text || this.getComposerContent(this.getters.getActivePosition()); return this.validateSelection(content.length, cmd.selection.start, cmd.selection.end); } - else { + break; + case "STOP_EDITION": + if (this.mode === "inactive") { return "Success" /* CommandResult.Success */; } - default: - return "Success" /* CommandResult.Success */; + return this.checkDataValidation(cmd); } + return "Success" /* CommandResult.Success */; } handleEvent(event) { const sheetId = this.getters.getActiveSheetId(); @@ -44233,13 +46604,12 @@ this.updateRangeColor(); break; case "STOP_EDITION": - if (cmd.cancel) { - this.cancelEditionAndActivateSheet(); - this.resetContent(); - } - else { - this.stopEdition(); - } + this.stopEdition(); + this.colorIndexByRange = {}; + break; + case "CANCEL_EDITION": + this.cancelEditionAndActivateSheet(); + this.resetContent(); this.colorIndexByRange = {}; break; case "SET_CURRENT_CONTENT": @@ -44379,6 +46749,9 @@ } return { content: newContent, selection: newSelection }; } + getInitialComposerContent() { + return this.initialContent; + } // --------------------------------------------------------------------------- // Misc // --------------------------------------------------------------------------- @@ -44399,7 +46772,7 @@ } onColumnsRemoved(cmd) { if (cmd.elements.includes(this.col) && this.mode !== "inactive") { - this.dispatch("STOP_EDITION", { cancel: true }); + this.dispatch("CANCEL_EDITION"); this.ui.raiseBlockingErrorUI(CELL_DELETED_MESSAGE); return; } @@ -44409,7 +46782,7 @@ } onRowsRemoved(cmd) { if (cmd.elements.includes(this.row) && this.mode !== "inactive") { - this.dispatch("STOP_EDITION", { cancel: true }); + this.dispatch("CANCEL_EDITION"); this.ui.raiseBlockingErrorUI(CELL_DELETED_MESSAGE); return; } @@ -44466,7 +46839,7 @@ this.cancelEditionAndActivateSheet(); const col = this.col; const row = this.row; - let content = this.currentContent; + let content = this.getCurrentCanonicalContent(); const didChange = this.initialContent !== content; if (!didChange) { return; @@ -44474,7 +46847,6 @@ if (content) { const sheetId = this.getters.getActiveSheetId(); const cell = this.getters.getEvaluatedCell({ sheetId, col: this.col, row: this.row }); - content = canonicalizeContent(content, this.getters.getLocale()); if (content.startsWith("=")) { const left = this.currentTokens.filter((t) => t.type === "LEFT_PAREN").length; const right = this.currentTokens.filter((t) => t.type === "RIGHT_PAREN").length; @@ -44504,6 +46876,9 @@ this.setContent(""); } } + getCurrentCanonicalContent() { + return canonicalizeNumberContent(this.currentContent, this.getters.getLocale()); + } cancelEditionAndActivateSheet() { if (this.mode === "inactive") { return; @@ -44725,6 +47100,34 @@ .filter((token) => token.type === "REFERENCE") .map((token) => this.getters.getRangeFromSheetXC(editionSheetId, token.value)); } + getAutoCompleteDataValidationValues() { + if (this.mode === "inactive") { + return []; + } + const rule = this.getters.getValidationRuleForCell(this.getCurrentEditedCell()); + if (!rule || + (rule.criterion.type !== "isValueInList" && rule.criterion.type !== "isValueInRange")) { + return []; + } + let values; + if (rule.criterion.type === "isValueInList") { + values = rule.criterion.values; + } + else { + const range = this.getters.getRangeFromSheetXC(this.sheetId, rule.criterion.values[0]); + values = this.getters + .getRangeValues(range) + .filter(isNotNull) + .map((value) => value.toString()) + .filter((val) => val !== ""); + } + const composerContent = this.getCurrentContent(); + if (composerContent && composerContent !== this.getInitialComposerContent()) { + const filteredValues = fuzzyLookup(composerContent, values, (val) => val); + values = filteredValues.length ? filteredValues : values; + } + return values; + } /** * Function used to determine when composer selection can start. * Three conditions are necessary: @@ -44765,6 +47168,25 @@ } return false; } + checkDataValidation(cmd) { + const cellPosition = { sheetId: this.sheetId, col: this.col, row: this.row }; + try { + const content = this.getCurrentCanonicalContent(); + const cellValue = content.startsWith("=") + ? this.getters.evaluateFormula(this.sheetId, content) + : parseLiteral(content, this.getters.getLocale()); + const validationResult = this.getters.getValidationResultForCellValue(cellValue, cellPosition); + if (!validationResult.isValid && validationResult.rule.isBlocking) { + return "BlockingValidationRule" /* CommandResult.BlockingValidationRule */; + } + return "Success" /* CommandResult.Success */; + } + catch (e) { + // error at formula evaluation + const rule = this.getters.getValidationRuleForCell(cellPosition); + return rule?.isBlocking ? "BlockingValidationRule" /* CommandResult.BlockingValidationRule */ : "Success" /* CommandResult.Success */; + } + } } class FilterEvaluationPlugin extends UIPlugin { @@ -45701,6 +48123,9 @@ ); height = Math.max(height, this.viewportHeight); // if the viewport grid size is smaller than its client height, return client height } + if (lastRowEnd + FOOTER_HEIGHT > height && !this.getters.isReadonly()) { + height += FOOTER_HEIGHT; + } return { width, height }; } /** @@ -45749,30 +48174,26 @@ const { end } = this.getters.getColDimensions(sheetId, targetCol); const maxCol = this.getters.getNumberCols(sheetId); if (this.offsetX + this.offsetCorrectionX + this.viewportWidth < end) { - for (let col = this.left; this.offsetX + this.offsetCorrectionX + this.viewportWidth < end; col++) { - if (col > maxCol) { - break; - } - if (this.getters.isColHidden(sheetId, col)) { - continue; - } - this.offsetX = this.getters.getColDimensions(sheetId, col).end - this.offsetCorrectionX; - this.offsetScrollbarX = this.offsetX; - this.adjustViewportZoneX(); + let finalTarget = targetCol; + while (this.getters.isColHidden(sheetId, finalTarget) && targetCol < maxCol) { + finalTarget++; } + const finalTargetEnd = this.getters.getColDimensions(sheetId, finalTarget).end; + const startIndex = this.searchHeaderIndex("COL", finalTargetEnd - this.viewportWidth - this.offsetCorrectionX, this.boundaries.left, true); + this.offsetX = + this.getters.getColDimensions(sheetId, startIndex).end - this.offsetCorrectionX; + this.offsetScrollbarX = this.offsetX; + this.adjustViewportZoneX(); } else if (this.left > targetCol) { - for (let col = this.left; col >= targetCol; col--) { - if (col < 0) { - break; - } - if (this.getters.isColHidden(sheetId, col)) { - continue; - } - this.offsetX = this.getters.getColDimensions(sheetId, col).start - this.offsetCorrectionX; - this.offsetScrollbarX = this.offsetX; - this.adjustViewportZoneX(); + let finalTarget = targetCol; + while (this.getters.isColHidden(sheetId, finalTarget) && targetCol > 0) { + finalTarget--; } + this.offsetX = + this.getters.getColDimensions(sheetId, finalTarget).start - this.offsetCorrectionX; + this.offsetScrollbarX = this.offsetX; + this.adjustViewportZoneX(); } } adjustPositionY(targetRow) { @@ -45780,30 +48201,26 @@ const { end } = this.getters.getRowDimensions(sheetId, targetRow); const maxRow = this.getters.getNumberRows(sheetId); if (this.offsetY + this.viewportHeight + this.offsetCorrectionY < end) { - for (let row = this.top; this.offsetY + this.viewportHeight + this.offsetCorrectionY < end; row++) { - if (row > maxRow) { - break; - } - if (this.getters.isRowHidden(sheetId, row)) { - continue; - } - this.offsetY = this.getters.getRowDimensions(sheetId, row).end - this.offsetCorrectionY; - this.offsetScrollbarY = this.offsetY; - this.adjustViewportZoneY(); + let finalTarget = targetRow; + while (this.getters.isRowHidden(sheetId, finalTarget) && targetRow < maxRow) { + finalTarget++; } + const finalTargetEnd = this.getters.getRowDimensions(sheetId, finalTarget).end; + const startIndex = this.searchHeaderIndex("ROW", finalTargetEnd - this.viewportHeight - this.offsetCorrectionY, this.boundaries.top, true); + this.offsetY = + this.getters.getRowDimensions(sheetId, startIndex).end - this.offsetCorrectionY; + this.offsetScrollbarY = this.offsetY; + this.adjustViewportZoneY(); } else if (this.top > targetRow) { - for (let row = this.top; row >= targetRow; row--) { - if (row < 0) { - break; - } - if (this.getters.isRowHidden(sheetId, row)) { - continue; - } - this.offsetY = this.getters.getRowDimensions(sheetId, row).start - this.offsetCorrectionY; - this.offsetScrollbarY = this.offsetY; - this.adjustViewportZoneY(); + let finalTarget = targetRow; + while (this.getters.isRowHidden(sheetId, finalTarget) && targetRow > 0) { + finalTarget--; } + this.offsetY = + this.getters.getRowDimensions(sheetId, finalTarget).start - this.offsetCorrectionY; + this.offsetScrollbarY = this.offsetY; + this.adjustViewportZoneY(); } } setViewportOffset(offsetX, offsetY) { @@ -45911,7 +48328,7 @@ } } this.top = this.getRowIndex(this.offsetScrollbarY, true); - this.bottom = this.getRowIndex(this.offsetScrollbarY + this.viewportWidth, true); + this.bottom = this.getRowIndex(this.offsetScrollbarY + this.viewportHeight, true); if (this.bottom === -1) { this.bottom = this.boundaries.bottom; } @@ -46591,7 +49008,8 @@ .add("conditional formatting", ConditionalFormatPlugin) .add("figures", FigurePlugin) .add("chart", ChartPlugin) - .add("image", ImagePlugin); + .add("image", ImagePlugin) + .add("dataValidation", DataValidationPlugin); // Plugins which handle a specific feature, without handling any core commands const featurePluginRegistry = new Registry() .add("ui_sheet", SheetUIPlugin) @@ -46623,7 +49041,8 @@ .add("evaluation_chart", EvaluationChartPlugin) .add("evaluation_cf", EvaluationConditionalFormatPlugin) .add("row_size", UIRowSizePlugin) - .add("custom_colors", CustomColorsPlugin); + .add("custom_colors", CustomColorsPlugin) + .add("data_validation_ui", EvaluationDataValidationPlugin); const clickableCellRegistry = new Registry(); clickableCellRegistry.add("link", { @@ -47087,161 +49506,6 @@ closeContextMenu: Function, }; - class DOMDndHelper { - draggedItemId; - items; - containerEl; - initialMousePosition; - currentMousePosition; - minPosition; - maxPosition; - edgeScrollIntervalId; - edgeScrollOffset = 0; - onChange; - onCancel; - onDragEnd; - /** - * The dead zone is an area in which the mousemove events are ignored. - * - * This is useful when swapping the dragged item with a larger item. After the swap, - * the mouse is still hovering on the item we just swapped with. In this case, we don't want - * a mouse move to trigger another swap the other way around, so we create a dead zone. We will clear - * the dead zone when the mouse leaves the swapped item. - */ - deadZone; - constructor(args) { - this.items = args.items.map((item) => ({ ...item, positionAtStart: item.position })); - this.draggedItemId = args.draggedItemId; - this.containerEl = args.containerEl; - this.onChange = args.onChange; - this.onCancel = args.onCancel; - this.onDragEnd = args.onDragEnd; - this.initialMousePosition = args.mouseX; - this.currentMousePosition = args.mouseX; - this.minPosition = this.items[0].position; - this.maxPosition = - this.items[this.items.length - 1].position + this.items[this.items.length - 1].size; - startDnd(this.onMouseMove.bind(this), this.onMouseUp.bind(this)); - } - onMouseMove(ev) { - if (ev.button !== 0) { - this.onCancel(); - return; - } - const mousePosition = ev.clientX; - if (mousePosition < this.containerRect.left || mousePosition > this.containerRect.right) { - this.startEdgeScroll(mousePosition < this.containerRect.left ? -1 : 1); - return; - } - else { - this.stopEdgeScroll(); - } - this.moveDraggedItemToPosition(mousePosition + this.edgeScrollOffset); - } - moveDraggedItemToPosition(position) { - this.currentMousePosition = position; - const hoveredItemIndex = this.getHoveredItemIndex(position, this.items); - const draggedItemIndex = this.items.findIndex((item) => item.id === this.draggedItemId); - const draggedItem = this.items[draggedItemIndex]; - if (this.deadZone && this.isInZone(position, this.deadZone)) { - this.onChange(this.getItemsPositions()); - return; - } - else if (this.isInZone(position, { - start: draggedItem.position, - end: draggedItem.position + draggedItem.size, - })) { - this.deadZone = undefined; - } - if (draggedItemIndex === hoveredItemIndex) { - this.onChange(this.getItemsPositions()); - return; - } - const leftIndex = Math.min(draggedItemIndex, hoveredItemIndex); - const rightIndex = Math.max(draggedItemIndex, hoveredItemIndex); - const direction = Math.sign(hoveredItemIndex - draggedItemIndex); - let draggedItemMoveSize = 0; - for (let i = leftIndex; i <= rightIndex; i++) { - if (i === draggedItemIndex) { - continue; - } - this.items[i].position -= direction * draggedItem.size; - draggedItemMoveSize += this.items[i].size; - } - draggedItem.position += direction * draggedItemMoveSize; - this.items.sort((item1, item2) => item1.position - item2.position); - this.deadZone = - direction > 0 - ? { start: position, end: draggedItem.position } - : { start: draggedItem.position + draggedItem.size, end: position }; - this.onChange(this.getItemsPositions()); - } - onMouseUp(ev) { - if (ev.button !== 0) { - this.onCancel(); - } - const targetItemIndex = this.items.findIndex((item) => item.id === this.draggedItemId); - this.onDragEnd(this.draggedItemId, targetItemIndex); - this.stopEdgeScroll(); - } - startEdgeScroll(direction) { - if (this.edgeScrollIntervalId) - return; - this.edgeScrollIntervalId = window.setInterval(() => { - const offset = direction * 3; - let newPosition = this.currentMousePosition + offset; - if (newPosition < this.minPosition) { - newPosition = this.minPosition; - } - else if (newPosition > this.maxPosition) { - newPosition = this.maxPosition; - } - this.edgeScrollOffset += newPosition - this.currentMousePosition; - this.moveDraggedItemToPosition(newPosition); - this.containerEl.scrollLeft += offset; - }, 5); - } - stopEdgeScroll() { - window.clearInterval(this.edgeScrollIntervalId); - this.edgeScrollIntervalId = undefined; - } - /** - * Get the index of the item the given mouse position is inside. - * If the mouse is outside the container, return the first or last item index. - */ - getHoveredItemIndex(mousePosition, items) { - if (mousePosition <= this.minPosition) - return 0; - if (mousePosition >= this.maxPosition) - return items.length - 1; - return items.findIndex((item) => item.position + item.size >= mousePosition); - } - getItemsPositions() { - const positions = {}; - for (let item of this.items) { - if (item.id !== this.draggedItemId) { - positions[item.id] = item.position - item.positionAtStart; - continue; - } - let mouseOffset = this.currentMousePosition - this.initialMousePosition; - let left = mouseOffset; - left = Math.max(this.minPosition - item.positionAtStart, left); - left = Math.min(this.maxPosition - item.positionAtStart - item.size, left); - positions[item.id] = left; - } - return positions; - } - get containerRect() { - return this.containerEl.getBoundingClientRect(); - } - isInZone(position, zone) { - return position >= zone.start && position <= zone.end; - } - destroy() { - this.stopEdgeScroll(); - } - } - // ----------------------------------------------------------------------------- // SpreadSheet // ----------------------------------------------------------------------------- @@ -47294,7 +49558,7 @@ static components = { Menu, Ripple, BottomBarSheet, BottomBarStatistic }; bottomBarRef = owl.useRef("bottomBar"); sheetListRef = owl.useRef("sheetList"); - dndHelper; + dragAndDrop = useDragAndDropListItems(); targetScroll = undefined; state = owl.useState({ isSheetListScrollableLeft: false, @@ -47307,27 +49571,17 @@ position: null, menuItems: [], }); - sheetState = owl.useState({ - sheetList: this.getVisibleSheets(), - isDnd: false, - sheetDndPositions: undefined, - }); + sheetList = this.getVisibleSheets(); setup() { owl.onWillUpdateProps(() => { this.updateScrollState(); const visibleSheets = this.getVisibleSheets(); // Cancel sheet dragging when there is a change in the sheets - if (this.sheetState.isDnd && !deepEquals(this.sheetState.sheetList, visibleSheets)) { - this.stopDragging(); + if (!deepEquals(this.sheetList, visibleSheets)) { + this.dragAndDrop.cancel(); } - this.sheetState.sheetList = visibleSheets; + this.sheetList = visibleSheets; }); - owl.onWillUnmount(() => { - this.dndHelper?.destroy(); - }); - } - isDragged(sheetId) { - return this.sheetState.isDnd && this.dndHelper?.draggedItemId === sheetId; } clickAddSheet(ev) { const activeSheetId = this.env.model.getters.getActiveSheetId(); @@ -47343,9 +49597,6 @@ return { id: sheet.id, name: sheet.name }; }); } - getSheets() { - return this.sheetState.sheetList; - } clickListSheets(ev) { const registry = new MenuItemRegistry(); const from = this.env.model.getters.getActiveSheetId(); @@ -47435,8 +49686,6 @@ if (event.button !== 0) return; this.closeMenu(); - const mouseX = event.clientX; - document.body.style.cursor = "move"; const visibleSheets = this.getVisibleSheets(); const sheetRects = this.getSheetItemRects(); const sheets = visibleSheets.map((sheet, index) => ({ @@ -47444,21 +49693,16 @@ size: sheetRects[index].width, position: sheetRects[index].x, })); - this.dndHelper = new DOMDndHelper({ + this.dragAndDrop.start("horizontal", { draggedItemId: sheetId, - mouseX, + initialMousePosition: event.clientX, items: sheets, containerEl: this.sheetListRef.el, - onChange: (newPositions) => { - this.sheetState.isDnd = true; - this.sheetState.sheetDndPositions = newPositions; - }, - onCancel: () => this.stopDragging(), onDragEnd: (sheetId, finalIndex) => this.onDragEnd(sheetId, finalIndex), }); } onDragEnd(sheetId, finalIndex) { - const originalIndex = this.sheetState.sheetList.findIndex((sheet) => sheet.id === sheetId); + const originalIndex = this.getVisibleSheets().findIndex((sheet) => sheet.id === sheetId); const delta = finalIndex - originalIndex; if (sheetId && delta !== 0) { this.env.model.dispatch("MOVE_SHEET", { @@ -47466,28 +49710,9 @@ delta: delta, }); } - this.stopDragging(); } getSheetStyle(sheetId) { - const style = {}; - if (this.sheetState.isDnd) { - style.position = "relative"; - style.left = (this.sheetState.sheetDndPositions?.[sheetId] || 0) + "px"; - style.transition = "left 0.5s"; - style.cursor = "move"; - } - if (this.isDragged(sheetId)) { - style.transition = "left 0s"; - style["z-index"] = "1000"; - } - return cssPropertiesToCss(style); - } - stopDragging() { - document.body.style.cursor = ""; - this.sheetState.sheetList = this.getVisibleSheets(); - this.sheetState.isDnd = false; - this.sheetState.sheetDndPositions = undefined; - this.dndHelper = undefined; + return this.dragAndDrop.itemsStyle[sheetId] || ""; } getSheetItemRects() { return Array.from(this.bottomBarRef.el.querySelectorAll(`.o-sheet`)) @@ -47499,14 +49724,6 @@ height: rect.height, })); } - getSheetClasses(sheetId) { - let classes = ""; - if (this.isDragged(sheetId)) - classes += "dragged "; - if (this.sheetState.isDnd) - classes += "dragging "; - return classes; - } get sheetListCurrentScroll() { if (!this.sheetListRef.el) return 0; @@ -47992,45 +50209,27 @@ text-align: right; } - .o-sidePanelButton { - border: 1px solid lightgrey; - padding: 0px 20px 0px 20px; - border-radius: 4px; - font-weight: 500; + .o-sidePanel-btn-link { font-size: 14px; - height: 30px; - line-height: 16px; - background: white; - margin-right: 8px; - &:hover:enabled { - background-color: rgba(0, 0, 0, 0.08); - } - } - .o-sidePanelButton:enabled { + padding: 20px 24px 11px 24px; + height: 44px; cursor: pointer; - } - - .o-sidePanelButton:disabled { - color: ${DISABLED_TEXT_COLOR}; - } - - .o-sidePanelButton:last-child { - margin-right: 0px; - } - - .o-input { - color: #666666; - border-radius: 4px; - min-width: 0px; - padding: 4px 6px; - box-sizing: border-box; - line-height: 1; - width: 100%; - height: 28px; - .o-type-selector { - background-position: right 5px top 11px; + text-decoration: none; + &:hover { + color: #003a39; + text-decoration: none; } } + + .o-button.primary:not(.o-disabled) { + background-color: ${FILTERS_COLOR}; + color: white; + &:hover:enabled { + opacity: 0.8; + background-color: ${FILTERS_COLOR}; + } + } + input.o-required, select.o-required { border-color: #4c4c4c; @@ -48738,7 +50937,7 @@ this.state.menuState.parentMenu = menu; this.isSelectingMenu = true; this.openedEl = ev.target; - this.env.model.dispatch("STOP_EDITION"); + interactiveStopEdition(this.env); } closeMenus() { this.state.activeTool = ""; @@ -48956,6 +51155,44 @@ outline: none; } } + + .o-button { + border: 1px solid lightgrey; + padding: 0px 20px 0px 20px; + border-radius: 4px; + font-weight: 500; + font-size: 14px; + height: 30px; + line-height: 16px; + background: white; + margin-right: 8px; + &:hover:enabled { + background-color: rgba(0, 0, 0, 0.08); + } + + &:enabled { + cursor: pointer; + } + + &:disabled { + color: ${DISABLED_TEXT_COLOR}; + } + + &:last-child { + margin-right: 0px; + } + } + + .o-input { + color: #666666; + border-radius: 4px; + min-width: 0px; + padding: 4px 6px; + box-sizing: border-box; + line-height: 1; + width: 100%; + height: 28px; + } `; class Spreadsheet extends owl.Component { static template = "o-spreadsheet-Spreadsheet"; @@ -49106,7 +51343,6 @@ if (this.model.getters.isReadonly()) { return; } - this.model.dispatch("UNFOCUS_SELECTION_INPUT"); this.composer.topBarFocus = "contentFocus"; this.composer.gridFocusMode = "inactive"; this.setComposerContent({ selection } || {}); @@ -49115,7 +51351,6 @@ if (this.model.getters.isReadonly()) { return; } - this.model.dispatch("UNFOCUS_SELECTION_INPUT"); this.composer.topBarFocus = "inactive"; this.composer.gridFocusMode = "contentFocus"; this.setComposerContent({}); @@ -49124,7 +51359,6 @@ if (this.model.getters.isReadonly()) { return; } - this.model.dispatch("UNFOCUS_SELECTION_INPUT"); this.composer.topBarFocus = "inactive"; this.composer.gridFocusMode = "cellFocus"; this.setComposerContent({ content, selection } || {}); @@ -50022,7 +52256,7 @@ * */ class EventStream { - observers = []; + observers = new Map(); /** * the one we default to when someone releases the stream by themeselves */ @@ -50038,13 +52272,13 @@ * Register callbacks to observe the stream */ observe(owner, callbacks) { - this.observers.push({ owner, callbacks }); + this.observers.set(owner, { owner, callbacks }); } /** * Capture the stream for yourself */ capture(owner, callbacks) { - if (this.observers.find((sub) => sub.owner === owner)) { + if (this.observers.get(owner)) { throw new Error("You are already subscribed forever"); } if (this.mainSubscription?.owner && this.mainSubscription.owner !== owner) { @@ -50053,8 +52287,7 @@ this.mainSubscription = { owner, callbacks }; } release(owner) { - if (this.mainSubscription?.owner !== owner || - this.observers.find((sub) => sub.owner === owner)) { + if (this.mainSubscription?.owner !== owner || this.observers.get(owner)) { return; } this.mainSubscription = this.defaultSubscription; @@ -50080,7 +52313,7 @@ */ send(event) { this.mainSubscription?.callbacks.handleEvent(event); - [...this.observers].forEach((sub) => sub.callbacks.handleEvent(event)); + this.observers.forEach((sub) => sub.callbacks.handleEvent(event)); } } @@ -52687,7 +54920,7 @@ } updateMode(mode) { if (mode !== "normal") { - this.dispatch("STOP_EDITION", { cancel: true }); + this.dispatch("CANCEL_EDITION"); } // @ts-ignore For testing purposes only this.config.mode = mode; @@ -52828,6 +55061,9 @@ FigureComponent, Menu, }; + const hooks = { + useDragAndDropListItems, + }; function addFunction(functionName, functionDescription) { functionRegistry.add(functionName, functionDescription); return { @@ -52859,6 +55095,7 @@ exports.findCellInNewZone = findCellInNewZone; exports.functionCache = functionCache; exports.helpers = helpers; + exports.hooks = hooks; exports.invalidateCFEvaluationCommands = invalidateCFEvaluationCommands; exports.invalidateDependenciesCommands = invalidateDependenciesCommands; exports.invalidateEvaluationCommands = invalidateEvaluationCommands; @@ -52873,9 +55110,9 @@ exports.tokenize = tokenize; - __info__.version = "16.5.0-alpha.8"; - __info__.date = "2023-09-22T07:50:23.463Z"; - __info__.hash = "0b7527d5"; + __info__.version = "16.5.0-alpha.9"; + __info__.date = "2023-10-03T13:36:38.605Z"; + __info__.hash = "c46de914"; })(this.o_spreadsheet = this.o_spreadsheet || {}, owl); diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml index fbd944a199c..674b18f9234 100644 --- a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml @@ -1,9 +1,9 @@ @@ -193,7 +193,7 @@ t-ref="sheetList" t-on-wheel="onWheel" t-on-scroll="onScroll"> - +
-
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+
+
- + +
+ +
+
+ +
+
@@ -792,29 +831,26 @@ -
- +
+
- + t-foreach="getFilterHeadersPositions()" + t-as="position" + t-key="'filter'+position.col + '_' + position.row"> + + +
-
+
Sort ascending (A ⟶ Z)
@@ -822,7 +858,7 @@ Sort descending (Z ⟶ A)
-
+
Select all
Clear
@@ -1008,6 +1044,36 @@
+ +
+ + + more rows at the bottom + +
+
+ + +
+ +
+
+
+ +
@@ -2247,6 +2319,27 @@ /> + + + + + + + + + + + + + +
- +
@@ -2624,7 +2717,7 @@
- +
@@ -2690,7 +2783,7 @@
- +
@@ -2824,7 +2917,7 @@
- +
@@ -2951,7 +3044,7 @@
- +
@@ -3103,7 +3196,7 @@
- +
@@ -3276,6 +3369,90 @@
+ +
+
+
Apply to range
+
+ +
+
Format rules
+
+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+ + + + + + + + + +
+ + +
+
+
+
+ +
+
+
+
+
Preview text
@@ -3348,177 +3525,6 @@
- -
- -
-
- -
-
- - - - - - - -
- -
-
-
Apply to range
-
- -
-
Format rules
-
-
- - -
-
- - -
- -
- - -
-
-
-
- - - - - - - - - -
- - -
-
-
-
- -
-
-
-
-
-
- - -
- -
- - - -
-
- -
123
-
-
-
-
- -
-
-
-
- -
- -
- -
-
- -
- -
-
-
-
- -
-
- -
-
-
-
- -
Icons
@@ -3641,6 +3647,80 @@
+ +
+ + + + +
+
+ + +
+
+
+ +
+ +
+ + + +
+
+ +
+ 123 +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ + + +
+ + + + + + +
+
+
@@ -3694,7 +3774,7 @@
+ +
+ +
+ + + +
+ +
+ + + +
+
+ + + + + + + + + + +
and
+ +
+ + +
+ + + + +
+
+ + + + + + + +
+ +
+ +
+
+
+ + + +
Display style
+ + + + + + +
Display style
+ +
+ + +
+
+
Apply to range
+ + +
+
Criteria
+ + + +
+
+ +
+
If the data is invalid
+ +
+ +
+ + +
+
+
+ + +
+
+
+
+
+
+
+ +
+
+
+ +
@@ -3754,13 +4038,10 @@ -
@@ -3780,13 +4061,13 @@
@@ -3830,7 +4111,7 @@
- +
+ + + + +
@@ -3901,15 +4198,6 @@
- - -
- - -
-
-
-
@@ -3948,15 +4236,15 @@
- - + +
@@ -4191,4 +4479,13 @@ onMenuClicked="() => this.props.onClick()" /> + + + +
+ + +
+
+