From 803975ba6589fba6a88bf8538c5f1ca5affd38b8 Mon Sep 17 00:00:00 2001 From: "Adrien Minne (adrm)" Date: Wed, 19 Nov 2025 11:26:37 +0100 Subject: [PATCH 1/2] [REF] criterion: avoid double render on `DateCriterion` The component `DateCriterion` would trigger an `updateCriterion` call on startup if `criterion.dateValue` wasn't set. It's acceptable for data validations (it only triggers another render), but not acceptable for conditional formatting, where each call to `updateCriterion` will trigger a dispatch and a new revision. The component is now modified to default to `exactDate` if `criterion.dateValue` isn't set. Task: 5343283 --- .../date_criterion/date_criterion.ts | 17 ++++++----------- .../date_criterion/date_criterion.xml | 4 ++-- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/components/side_panel/criterion_form/date_criterion/date_criterion.ts b/src/components/side_panel/criterion_form/date_criterion/date_criterion.ts index e55a8b0962..1459696746 100644 --- a/src/components/side_panel/criterion_form/date_criterion/date_criterion.ts +++ b/src/components/side_panel/criterion_form/date_criterion/date_criterion.ts @@ -1,5 +1,4 @@ import { _t } from "@odoo/o-spreadsheet-engine/translation"; -import { onWillStart, onWillUpdateProps } from "@odoo/owl"; import { DateCriterionValue, GenericDateCriterion } from "../../../../types"; import { CriterionForm } from "../criterion_form"; import { CriterionInput } from "../criterion_input/criterion_input"; @@ -18,19 +17,15 @@ export class DateCriterionForm extends CriterionForm { static template = "o-spreadsheet-DataValidationDateCriterion"; static components = { CriterionInput }; - setup() { - super.setup(); - const setupDefault = (props: this["props"]) => { - if (props.criterion.dateValue === undefined) { - this.updateCriterion({ dateValue: "exactDate" }); - } - }; - onWillUpdateProps(setupDefault); - onWillStart(() => setupDefault(this.props)); + get currentDateValue() { + return this.props.criterion.dateValue || "exactDate"; } onValueChanged(value: string) { - this.updateCriterion({ values: [value] }); + this.updateCriterion({ + values: [value], + dateValue: this.currentDateValue, + }); } onDateValueChanged(ev: Event) { diff --git a/src/components/side_panel/criterion_form/date_criterion/date_criterion.xml b/src/components/side_panel/criterion_form/date_criterion/date_criterion.xml index e296650f66..9225a5db5a 100644 --- a/src/components/side_panel/criterion_form/date_criterion/date_criterion.xml +++ b/src/components/side_panel/criterion_form/date_criterion/date_criterion.xml @@ -7,12 +7,12 @@ t-key="dateValue.value" t-att-value="dateValue.value" t-esc="dateValue.title" - t-att-selected="dateValue.value === props.criterion.dateValue" + t-att-selected="dateValue.value === currentDateValue" /> Date: Wed, 19 Nov 2025 10:33:02 +0100 Subject: [PATCH 2/2] [IMP] CF: add new conditional formats for dates This commit adds the `dateIs/dateIsBefore/dateIsAfter` conditional formats, as well as the excel export for these. Note: The excel export doesn't use Excel's date conditional formats, because they don't have the same behaviour as ours. We'll use custom formulas instead. Example: today is 2025/11/19 - Our date is last month: 2025/10/19 -> 2025/11/18 - Excel's date is last month: 2025/10/01 -> 2025/10/30 Task: 5343283 --- .../src/helpers/locale.ts | 5 + .../evaluation_conditional_format.ts | 4 +- .../src/types/conditional_formatting.ts | 61 +- .../xlsx/functions/conditional_formatting.ts | 51 ++ .../src/xlsx/helpers/content_helpers.ts | 10 + .../cf_editor/cf_editor.ts | 12 +- ...itional_formatting_panel_component.test.ts | 22 + .../conditional_formatting_plugin.test.ts | 743 +++++++----------- tests/test_helpers/commands_helpers.ts | 15 + tests/xlsx/xlsx_export.test.ts | 77 +- 10 files changed, 507 insertions(+), 493 deletions(-) diff --git a/packages/o-spreadsheet-engine/src/helpers/locale.ts b/packages/o-spreadsheet-engine/src/helpers/locale.ts index d6e9712a46..bd4e43c681 100644 --- a/packages/o-spreadsheet-engine/src/helpers/locale.ts +++ b/packages/o-spreadsheet-engine/src/helpers/locale.ts @@ -276,6 +276,11 @@ function changeCFRuleLocale( case "isLessThan": case "isLessOrEqualTo": case "customFormula": + case "dateIs": + case "dateIsBefore": + case "dateIsAfter": + case "dateIsOnOrAfter": + case "dateIsOnOrBefore": rule.values = rule.values.map((v) => changeContentLocale(v)); return rule; case "beginsWithText": diff --git a/packages/o-spreadsheet-engine/src/plugins/ui_core_views/evaluation_conditional_format.ts b/packages/o-spreadsheet-engine/src/plugins/ui_core_views/evaluation_conditional_format.ts index 98da77bdd9..7d3dc9c5a3 100644 --- a/packages/o-spreadsheet-engine/src/plugins/ui_core_views/evaluation_conditional_format.ts +++ b/packages/o-spreadsheet-engine/src/plugins/ui_core_views/evaluation_conditional_format.ts @@ -21,6 +21,7 @@ import { IconSetRule, IconThreshold, } from "../../types/conditional_formatting"; +import { EvaluatedCriterion, EvaluatedDateCriterion } from "../../types/generic_criterion"; import { DEFAULT_LOCALE } from "../../types/locale"; import { CellPosition, DataBarFill, HeaderIndex, Lazy, Style, UID, Zone } from "../../types/misc"; import { CoreViewPlugin } from "../core_view_plugin"; @@ -373,9 +374,10 @@ export class EvaluationConditionalFormatPlugin extends CoreViewPlugin { return false; } - const evaluatedCriterion = { + const evaluatedCriterion: EvaluatedCriterion | EvaluatedDateCriterion = { type: rule.operator, values: evaluatedCriterionValues.map(toScalar), + dateValue: rule.dateValue || "exactDate", }; return evaluator.isValueValid(cell.value ?? "", evaluatedCriterion, this.getters, sheetId); } diff --git a/packages/o-spreadsheet-engine/src/types/conditional_formatting.ts b/packages/o-spreadsheet-engine/src/types/conditional_formatting.ts index 3e4d082c67..b35a341ef3 100644 --- a/packages/o-spreadsheet-engine/src/types/conditional_formatting.ts +++ b/packages/o-spreadsheet-engine/src/types/conditional_formatting.ts @@ -1,3 +1,4 @@ +import { DateCriterionValue } from "./generic_criterion"; import { Style, UID } from "./misc"; import { Range } from "./range"; @@ -54,6 +55,7 @@ export interface CellIsRule extends SingleColorRule { operator: ConditionalFormattingOperatorValues; // can be one value for all operator except between, then it is 2 values values: string[]; + dateValue?: DateCriterionValue; } export interface ExpressionRule extends SingleColorRule { type: "ExpressionRule"; @@ -154,38 +156,31 @@ export interface Top10Rule extends SingleColorRule { } //https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.conditionalformattingoperatorvalues?view=openxml-2.8.1 // Note: IsEmpty and IsNotEmpty does not exist on the specification -export type ConditionalFormattingOperatorValues = - | "beginsWithText" - | "isBetween" - | "containsText" - | "isEmpty" - | "isNotEmpty" - | "endsWithText" - | "isEqual" - | "isGreaterThan" - | "isGreaterOrEqualTo" - | "isLessThan" - | "isLessOrEqualTo" - | "isNotBetween" - | "notContainsText" - | "isNotEqual" - | "customFormula"; + +const cfOperators = [ + "containsText", + "notContainsText", + "isGreaterThan", + "isGreaterOrEqualTo", + "isLessThan", + "isLessOrEqualTo", + "isBetween", + "isNotBetween", + "beginsWithText", + "endsWithText", + "isNotEmpty", + "isEmpty", + "isNotEqual", + "isEqual", + "customFormula", + "dateIs", + "dateIsBefore", + "dateIsAfter", + "dateIsOnOrBefore", + "dateIsOnOrAfter", +] as const; + +export type ConditionalFormattingOperatorValues = (typeof cfOperators)[number]; export const availableConditionalFormatOperators: Set = - new Set([ - "containsText", - "notContainsText", - "isGreaterThan", - "isGreaterOrEqualTo", - "isLessThan", - "isLessOrEqualTo", - "isBetween", - "isNotBetween", - "beginsWithText", - "endsWithText", - "isNotEmpty", - "isEmpty", - "isNotEqual", - "isEqual", - "customFormula", - ]); + new Set(cfOperators); diff --git a/packages/o-spreadsheet-engine/src/xlsx/functions/conditional_formatting.ts b/packages/o-spreadsheet-engine/src/xlsx/functions/conditional_formatting.ts index cad18bacf7..83d9edea47 100644 --- a/packages/o-spreadsheet-engine/src/xlsx/functions/conditional_formatting.ts +++ b/packages/o-spreadsheet-engine/src/xlsx/functions/conditional_formatting.ts @@ -1,4 +1,5 @@ import { ICON_SETS, IconSetType } from "../../components/icons/icons"; +import { parseLiteral } from "../../helpers/cells/cell_evaluation"; import { colorNumberToHex } from "../../helpers/color"; import { CellIsRule, @@ -12,6 +13,7 @@ import { IconThreshold, ThresholdType, } from "../../types/conditional_formatting"; +import { DEFAULT_LOCALE } from "../../types/locale"; import { ExcelIconSet, XLSXDxf, XMLAttributes, XMLString } from "../../types/xlsx"; import { XLSX_ICONSET_MAP } from "../constants"; import { toXlsxHexColor } from "../helpers/colors"; @@ -116,6 +118,50 @@ function cellRuleFormula(ranges: string[], rule: CellIsRule): string[] { case "isBetween": case "isNotBetween": return [values[0], values[1]]; + case "dateIs": + switch (rule.dateValue || "exactDate") { + case "exactDate": { + const value = values[0].startsWith("=") + ? values[0].slice(1) + : (parseLiteral(values[0], DEFAULT_LOCALE) || "").toString(); + const roundedValue = `ROUNDDOWN(${value},0)`; + return [`AND(${firstCell}>=${roundedValue},${firstCell}<${roundedValue}+1)`]; + } + case "today": + return [`AND(${firstCell}>=TODAY(),${firstCell}=TODAY()-1,${firstCell}=TODAY()+1,${firstCell}=TODAY()-7,${firstCell}=EDATE(TODAY(),-1),${firstCell}=EDATE(TODAY(),-12),${firstCell} { }); }); + test("can edit a date CellIsRule", async () => { + await click(fixture.querySelectorAll(selectors.listPreview)[0]); + await nextTick(); + + await changeRuleOperatorType(fixture, "dateIs"); + expect(".o-composer").toHaveClass("active"); + editStandaloneComposer(selectors.ruleEditor.editor.valueInput, "10/10/2025"); + + await click(fixture, selectors.ruleEditor.editor.bold); + await click(fixture, selectors.buttonSave); + + const sheetId = model.getters.getActiveSheetId(); + const cf = model.getters.getConditionalFormats(sheetId).find((c) => c.id === "1"); + expect(cf?.rule).toEqual({ + operator: "dateIs", + dateValue: "exactDate", + style: { bold: true, fillColor: "#FF0000" }, + type: "CellIsRule", + values: ["10/10/2025"], + }); + }); + test("Can cycle on reference (with F4) in a CellIsRule editor input", async () => { await click(fixture.querySelectorAll(selectors.listPreview)[0]); await changeRuleOperatorType(fixture, "beginsWithText"); diff --git a/tests/conditional_formatting/conditional_formatting_plugin.test.ts b/tests/conditional_formatting/conditional_formatting_plugin.test.ts index bedd1fdccc..193933d812 100644 --- a/tests/conditional_formatting/conditional_formatting_plugin.test.ts +++ b/tests/conditional_formatting/conditional_formatting_plugin.test.ts @@ -6,6 +6,7 @@ import { import { CommandResult, ConditionalFormattingOperatorValues, UID } from "../../src/types"; import { activateSheet, + addCfRule, addColumns, addRows, changeCFPriority, @@ -1089,18 +1090,11 @@ describe("conditional formats types", () => { ["highway to hell", true], [`="highway to hell"`, true], ])("a string %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "beginsWithText", - values: ["hi"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "beginsWithText", + values: ["hi"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1113,18 +1107,11 @@ describe("conditional formats types", () => { ["422", true], ["=422", true], ])("a number %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "beginsWithText", - values: ["42"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "beginsWithText", + values: ["42"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1133,18 +1120,11 @@ describe("conditional formats types", () => { }); test("Operator isBetween", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isBetween", - values: ["1", "3"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isBetween", + values: ["1", "3"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "0"); @@ -1179,18 +1159,11 @@ describe("conditional formats types", () => { ["ahi", true], [`="ahi"`, true], ])("a string %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "containsText", - values: ["hi"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "containsText", + values: ["hi"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1204,18 +1177,11 @@ describe("conditional formats types", () => { ["2422", true], [`="2422"`, true], ])("a number %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "containsText", - values: ["42"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "containsText", + values: ["42"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1224,18 +1190,11 @@ describe("conditional formats types", () => { test("applies conditional formatting correctly when formula returns a 1x1 matrix", () => { setCellContent(model, "A1", "test"); - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "containsText", - values: ['=IF(TRUE, $A$1, "something else")'], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1:A2"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "containsText", + values: ['=IF(TRUE, $A$1, "something else")'], + style: { fillColor: "#ff0f0f" }, }); expect(getStyle(model, "A1")).toEqual({ fillColor: "#ff0f0f" }); @@ -1251,18 +1210,11 @@ describe("conditional formats types", () => { ["hi", true], ["ahi", true], ])("a string %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "endsWithText", - values: ["hi"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "endsWithText", + values: ["hi"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1275,18 +1227,11 @@ describe("conditional formats types", () => { ["442", true], ["=442", true], ])("a number %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "endsWithText", - values: ["42"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "endsWithText", + values: ["42"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1295,18 +1240,11 @@ describe("conditional formats types", () => { }); test("Operator GreaterThan", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isGreaterThan", - values: ["12"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isGreaterThan", + values: ["12"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "5"); expect(getStyle(model, "A1")).toEqual({}); @@ -1321,18 +1259,11 @@ describe("conditional formats types", () => { }); test("Operator GreaterThan with simple reference", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isGreaterThan", - values: ["=A2"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1:B2"), - sheetId, + addCfRule(model, "A1:B2", { + type: "CellIsRule", + operator: "isGreaterThan", + values: ["=A2"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "1"); setCellContent(model, "B1", "2"); @@ -1345,18 +1276,11 @@ describe("conditional formats types", () => { }); test("Operator GreaterThan with full-fixed simple reference", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isGreaterThan", - values: ["=$A$2"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1:B2"), - sheetId, + addCfRule(model, "A1:B2", { + type: "CellIsRule", + operator: "isGreaterThan", + values: ["=$A$2"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "3"); setCellContent(model, "B1", "1"); @@ -1369,18 +1293,11 @@ describe("conditional formats types", () => { }); test("Operator GreaterThan with column-fixed simple reference", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isGreaterThan", - values: ["=$A2"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1:B2"), - sheetId, + addCfRule(model, "A1:B2", { + type: "CellIsRule", + operator: "isGreaterThan", + values: ["=$A2"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "3"); setCellContent(model, "B1", "3"); @@ -1393,18 +1310,11 @@ describe("conditional formats types", () => { }); test("Operator GreaterThan with row-fixed simple reference", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isGreaterThan", - values: ["=A$2"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1:B2"), - sheetId, + addCfRule(model, "A1:B2", { + type: "CellIsRule", + operator: "isGreaterThan", + values: ["=A$2"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "3"); setCellContent(model, "B1", "1"); @@ -1417,18 +1327,11 @@ describe("conditional formats types", () => { }); test("Operator GreaterThan with formula", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isGreaterThan", - values: ["=SUM(A1:B2)/4"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1:B2"), - sheetId, + addCfRule(model, "A1:B2", { + type: "CellIsRule", + operator: "isGreaterThan", + values: ["=SUM(A1:B2)/4"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "1"); setCellContent(model, "B1", "2"); @@ -1443,18 +1346,11 @@ describe("conditional formats types", () => { }); test("Operator GreaterThan with formula and fixed row/col", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isGreaterThan", - values: ["=SUM($A$1:$B$2)/4"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1:B2"), - sheetId, + addCfRule(model, "A1:B2", { + type: "CellIsRule", + operator: "isGreaterThan", + values: ["=SUM($A$1:$B$2)/4"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "1"); setCellContent(model, "B1", "2"); @@ -1471,36 +1367,22 @@ describe("conditional formats types", () => { }); test("CF with spreading formula is disabled", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "notContainsText", - values: ["=MUNIT(3)"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "notContainsText", + values: ["=MUNIT(3)"], + style: { fillColor: "#ff0f0f" }, }); expect(getStyle(model, "A1")).toEqual({}); }); test("Operator GreaterThanOrEqual", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isGreaterOrEqualTo", - values: ["12"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isGreaterOrEqualTo", + values: ["12"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "5"); @@ -1518,18 +1400,11 @@ describe("conditional formats types", () => { }); test("Operator LessThan", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isLessThan", - values: ["10"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isLessThan", + values: ["10"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "11"); @@ -1545,18 +1420,11 @@ describe("conditional formats types", () => { }); test("Operator LessThanOrEqual", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isLessOrEqualTo", - values: ["10"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isLessOrEqualTo", + values: ["10"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "11"); @@ -1574,18 +1442,11 @@ describe("conditional formats types", () => { }); test("Operator isNotBetween", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isNotBetween", - values: ["5", "10"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isNotBetween", + values: ["5", "10"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "4"); @@ -1611,18 +1472,11 @@ describe("conditional formats types", () => { }); test("Operator textNotContains", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "notContainsText", - values: ["qsdf"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "notContainsText", + values: ["qsdf"], + style: { fillColor: "#ff0f0f" }, }); expect(getStyle(model, "A1")).toEqual({ fillColor: "#ff0f0f", @@ -1646,18 +1500,11 @@ describe("conditional formats types", () => { ["highway to hell", true], [`="highway to hell"`, true], ])("a string %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "beginsWithText", - values: ["hi"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "beginsWithText", + values: ["hi"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1670,18 +1517,11 @@ describe("conditional formats types", () => { ["422", true], ["=422", true], ])("a number %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "beginsWithText", - values: ["42"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "beginsWithText", + values: ["42"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1696,18 +1536,11 @@ describe("conditional formats types", () => { ["aaa", true], ["42", true], ])("a string %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isNotEqual", - values: ["hi"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isNotEqual", + values: ["hi"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1721,18 +1554,11 @@ describe("conditional formats types", () => { ["aaa", true], ["422", true], ])("a number %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isNotEqual", - values: ["42"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isNotEqual", + values: ["42"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1746,18 +1572,11 @@ describe("conditional formats types", () => { ["aaa", true], ["42", true], ])("a date %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isNotEqual", - values: ["12/12/2021"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isNotEqual", + values: ["12/12/2021"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1772,18 +1591,11 @@ describe("conditional formats types", () => { ["aaa", false], ["42", false], ])("a string %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isEqual", - values: ["hi"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isEqual", + values: ["hi"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1797,18 +1609,11 @@ describe("conditional formats types", () => { ["aaa", false], ["422", false], ])("a number %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isEqual", - values: ["42"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isEqual", + values: ["42"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1822,18 +1627,11 @@ describe("conditional formats types", () => { ["aaa", false], ["42", false], ])("a date %s", (cellContent, shouldMatch) => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isEqual", - values: ["12/12/2021"], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isEqual", + values: ["12/12/2021"], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", cellContent); const computedStyle = shouldMatch ? { fillColor: "#ff0f0f" } : {}; @@ -1841,18 +1639,11 @@ describe("conditional formats types", () => { }); test("With a formula value that can be parsed as a number", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isEqual", - values: ['="42"'], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isEqual", + values: ['="42"'], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", "42"); expect(getStyle(model, "A1")).toEqual({}); @@ -1862,18 +1653,11 @@ describe("conditional formats types", () => { }); test("Operator IsEmpty", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isEmpty", - values: [], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isEmpty", + values: [], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", ""); expect(getStyle(model, "A1")).toEqual({ @@ -1904,18 +1688,11 @@ describe("conditional formats types", () => { }); test("Operator IsNotEmpty", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isNotEmpty", - values: [], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isNotEmpty", + values: [], + style: { fillColor: "#ff0f0f" }, }); setCellContent(model, "A1", ""); expect(getStyle(model, "A1")).toEqual({}); @@ -1929,6 +1706,102 @@ describe("conditional formats types", () => { }); }); + test("Operator dateIs", () => { + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "dateIs", + dateValue: "exactDate", + values: ["10/10/2022"], + style: { fillColor: "#ff0f0f" }, + }); + + setCellContent(model, "A1", "11/10/2022"); + expect(getStyle(model, "A1")).toEqual({}); + + setCellContent(model, "A1", "10/10/2022"); + expect(getStyle(model, "A1")).toEqual({ fillColor: "#ff0f0f" }); + }); + + test("Operator dateIsAfter", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("01/01/2021 12:00:00")); + + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "dateIsAfter", + dateValue: "today", + values: ["10/10/2022"], + style: { fillColor: "#ff0f0f" }, + }); + + setCellContent(model, "A1", "01/01/2021"); + expect(getStyle(model, "A1")).toEqual({}); + + setCellContent(model, "A1", "01/02/2021"); + expect(getStyle(model, "A1")).toEqual({ fillColor: "#ff0f0f" }); + jest.useRealTimers(); + }); + + test("Operator dateIsBefore", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("01/01/2021 12:00:00")); + + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "dateIsBefore", + dateValue: "lastYear", + values: ["10/10/2022"], + style: { fillColor: "#ff0f0f" }, + }); + + setCellContent(model, "A1", "01/01/2020"); + expect(getStyle(model, "A1")).toEqual({}); + + setCellContent(model, "A1", "12/31/2019"); + expect(getStyle(model, "A1")).toEqual({ fillColor: "#ff0f0f" }); + jest.useRealTimers(); + }); + + test("Operator dateIsOnOrBefore", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("01/01/2021 12:00:00")); + + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "dateIsOnOrBefore", + dateValue: "today", + values: ["10/10/2022"], + style: { fillColor: "#ff0f0f" }, + }); + + setCellContent(model, "A1", "01/02/2021"); + expect(getStyle(model, "A1")).toEqual({}); + + setCellContent(model, "A1", "01/01/2021"); + expect(getStyle(model, "A1")).toEqual({ fillColor: "#ff0f0f" }); + jest.useRealTimers(); + }); + + test("Operator dateIsOnOrAfter", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("01/01/2021 12:00:00")); + + addCfRule(model, "A1", { + type: "CellIsRule", + operator: "dateIsOnOrAfter", + dateValue: "lastYear", + values: ["10/10/2022"], + style: { fillColor: "#ff0f0f" }, + }); + + setCellContent(model, "A1", "12/31/2019"); + expect(getStyle(model, "A1")).toEqual({}); + + setCellContent(model, "A1", "01/01/2020"); + expect(getStyle(model, "A1")).toEqual({ fillColor: "#ff0f0f" }); + jest.useRealTimers(); + }); + test.each([ ["isEmpty", ["", ""]], ["isEmpty", []], @@ -1955,18 +1828,11 @@ describe("conditional formats types", () => { ["isBetween", ["1", "1"]], ["isNotBetween", ["1", "1"]], ])("%s operator with valid number of arguments: %s", (operator: string, values: string[]) => { - const result = model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: operator as ConditionalFormattingOperatorValues, - values: values, - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + const result = addCfRule(model, "A1", { + type: "CellIsRule", + operator: operator as ConditionalFormattingOperatorValues, + values: values, + style: { fillColor: "#ff0f0f" }, }); expect(result).toBeSuccessfullyDispatched(); }); @@ -1996,18 +1862,11 @@ describe("conditional formats types", () => { ["isBetween", ["", "1"]], ["isNotBetween", ["", "1"]], ])("%s operator with missing first argument %s", (operator: string, values: string[]) => { - const result = model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: operator as ConditionalFormattingOperatorValues, - values: values, - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + const result = addCfRule(model, "A1", { + type: "CellIsRule", + operator: operator as ConditionalFormattingOperatorValues, + values: values, + style: { fillColor: "#ff0f0f" }, }); expect(result).toBeCancelledBecause(CommandResult.FirstArgMissing); }); @@ -2015,18 +1874,11 @@ describe("conditional formats types", () => { test.each(["=$c:$2", "=suùù("])( "Invalid formula ('%s') cannot be set as CF value", (formula: string) => { - const result = model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: "isGreaterThan", - values: [formula], - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + const result = addCfRule(model, "A1", { + type: "CellIsRule", + operator: "isGreaterThan", + values: [formula], + style: { fillColor: "#ff0f0f" }, }); expect(result).toBeCancelledBecause(CommandResult.ValueCellIsInvalidFormula); } @@ -2038,18 +1890,11 @@ describe("conditional formats types", () => { ["isNotBetween", ["1"]], ["isNotBetween", ["1", ""]], ])("%s operator with missing second argument %s", (operator: string, values: string[]) => { - const result = model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: operator as ConditionalFormattingOperatorValues, - values: values, - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + const result = addCfRule(model, "A1", { + type: "CellIsRule", + operator: operator as ConditionalFormattingOperatorValues, + values: values, + style: { fillColor: "#ff0f0f" }, }); expect(result).toBeCancelledBecause(CommandResult.SecondArgMissing); }); @@ -2057,18 +1902,11 @@ describe("conditional formats types", () => { ["isBetween", ["", ""]], ["isNotBetween", ["", ""]], ])("%s operator with both arguments missing %s", (operator: string, values: string[]) => { - const result = model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - type: "CellIsRule", - operator: operator as ConditionalFormattingOperatorValues, - values: values, - style: { fillColor: "#ff0f0f" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + const result = addCfRule(model, "A1", { + type: "CellIsRule", + operator: operator as ConditionalFormattingOperatorValues, + values: values, + style: { fillColor: "#ff0f0f" }, }); expect(result).toBeCancelledBecause( CommandResult.FirstArgMissing, @@ -2077,18 +1915,11 @@ describe("conditional formats types", () => { }); test("CF with cell referencing empty cell is treated as zero", () => { - model.dispatch("ADD_CONDITIONAL_FORMAT", { - cf: { - rule: { - values: ["0"], - operator: "isEqual", - type: "CellIsRule", - style: { fillColor: "#FF0FFF" }, - }, - id: "11", - }, - ranges: toRangesData(sheetId, "A1"), - sheetId, + addCfRule(model, "A1", { + values: ["0"], + operator: "isEqual", + type: "CellIsRule", + style: { fillColor: "#FF0FFF" }, }); setCellContent(model, "A1", "=B1"); expect(getStyle(model, "A1")).toEqual({ diff --git a/tests/test_helpers/commands_helpers.ts b/tests/test_helpers/commands_helpers.ts index ed78fb5826..d08c5dd5b2 100644 --- a/tests/test_helpers/commands_helpers.ts +++ b/tests/test_helpers/commands_helpers.ts @@ -22,6 +22,7 @@ import { ChartWithDataSetDefinition, ClipboardPasteOptions, Color, + ConditionalFormatRule, CreateFigureCommand, CreateSheetCommand, CreateTableStyleCommand, @@ -1512,6 +1513,20 @@ export function setSheetviewSize(model: Model, height: Pixel, width: Pixel, hasH }); } +export function addCfRule( + model: Model, + xc: string, + rule: ConditionalFormatRule, + cfId: UID = "cfId", + sheetId: UID = model.getters.getActiveSheetId() +) { + return model.dispatch("ADD_CONDITIONAL_FORMAT", { + cf: { rule, id: cfId }, + ranges: toRangesData(sheetId, xc), + sheetId, + }); +} + export function addEqualCf( model: Model, xc: string, diff --git a/tests/xlsx/xlsx_export.test.ts b/tests/xlsx/xlsx_export.test.ts index 5274b69e2e..be5ead607d 100644 --- a/tests/xlsx/xlsx_export.test.ts +++ b/tests/xlsx/xlsx_export.test.ts @@ -7,11 +7,12 @@ import { hexaToInt } from "@odoo/o-spreadsheet-engine/xlsx/conversion"; import { adaptFormulaToExcel } from "@odoo/o-spreadsheet-engine/xlsx/functions/cells"; import { escapeXml, parseXML } from "@odoo/o-spreadsheet-engine/xlsx/helpers/xml_helpers"; import { buildSheetLink, toXC } from "../../src/helpers"; -import { CustomizedDataSet, Dimension } from "../../src/types"; +import { ConditionalFormatRule, CustomizedDataSet, Dimension } from "../../src/types"; import { arg } from "@odoo/o-spreadsheet-engine/functions/arguments"; import { functionRegistry } from "@odoo/o-spreadsheet-engine/functions/function_registry"; import { + addCfRule, createChart, createGaugeChart, createImage, @@ -690,6 +691,80 @@ describe("Test XLSX export", () => { expect(await exportPrettifiedXlsx(model)).toMatchSnapshot(); }); + describe("Date conditional formats", () => { + const dateRule: ConditionalFormatRule = { + type: "CellIsRule", + operator: "dateIs", + values: [], + style: { fillColor: "#B6D7A8" }, + dateValue: "exactDate", + }; + + test.each([ + [{ dateValue: "today" }, "AND(A1>=TODAY(),A1=TODAY()-1,A1=TODAY()+1,A1=TODAY()-7,A1=EDATE(TODAY(),-1),A1=EDATE(TODAY(),-12),A1=ROUNDDOWN(11,0),A1=ROUNDDOWN(A1+1,0),A1 { + const model = new Model(); + addCfRule(model, "A1", { ...dateRule, ...rule } as ConditionalFormatRule, "cf1"); + + const exportedXlsx = await model.exportXLSX(); + const sheet = exportedXlsx.files.find((f) => f["contentType"] === "sheet")!["content"]; + const xml = parseXML(sheet); + + const rules = xml.querySelectorAll("conditionalFormatting > cfRule"); + expect(rules.length).toBe(1); + expect(rules[0].getAttribute("type")).toBe("expression"); + expect(rules[0].querySelector("formula")?.textContent).toBe(expectedFormula); + }); + + test.each([ + [{ dateValue: "today", operator: "dateIsBefore" }, "lessThan", "TODAY()"], + [{ dateValue: "tomorrow", operator: "dateIsOnOrBefore" }, "lessThanOrEqual", "TODAY()+1"], + [{ dateValue: "yesterday", operator: "dateIsAfter" }, "greaterThan", "TODAY()-1"], + [{ dateValue: "lastWeek", operator: "dateIsOnOrAfter" }, "greaterThanOrEqual", "TODAY()-7"], + [{ dateValue: "lastMonth", operator: "dateIsAfter" }, "greaterThan", "EDATE(TODAY(),-1)"], + [{ dateValue: "lastYear", operator: "dateIsAfter" }, "greaterThan", "EDATE(TODAY(),-12)"], + [ + { dateValue: "exactDate", operator: "dateIsBefore", values: ["01/10/1900"] }, + "lessThan", + "11", // 01/10/1900 in date number + ], + [ + { dateValue: "exactDate", operator: "dateIsBefore", values: ["=DATE(2025,12,30)"] }, + "lessThan", + "DATE(2025,12,30)", + ], + ])( + "Can export a other date conditional formats %s", + async (rule, expectedOperator, expectedFormula) => { + const model = new Model(); + addCfRule(model, "A1", { ...dateRule, ...rule } as ConditionalFormatRule, "cf1"); + + const exportedXlsx = await model.exportXLSX(); + const sheet = exportedXlsx.files.find((f) => f["contentType"] === "sheet")!["content"]; + const xml = parseXML(sheet); + + const rules = xml.querySelectorAll("conditionalFormatting > cfRule"); + expect(rules.length).toBe(1); + expect(rules[0].getAttribute("type")).toBe("cellIs"); + expect(rules[0].getAttribute("operator")).toBe(expectedOperator); + expect(rules[0].querySelector("formula")?.textContent).toBe(expectedFormula); + } + ); + }); + test("Data validation", async () => { const model = new Model({ sheets: [