Skip to content

Commit 109549a

Browse files
committed
[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
1 parent 803975b commit 109549a

File tree

10 files changed

+507
-493
lines changed

10 files changed

+507
-493
lines changed

packages/o-spreadsheet-engine/src/helpers/locale.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,11 @@ function changeCFRuleLocale(
276276
case "isLessThan":
277277
case "isLessOrEqualTo":
278278
case "customFormula":
279+
case "dateIs":
280+
case "dateIsBefore":
281+
case "dateIsAfter":
282+
case "dateIsOnOrAfter":
283+
case "dateIsOnOrBefore":
279284
rule.values = rule.values.map((v) => changeContentLocale(v));
280285
return rule;
281286
case "beginsWithText":

packages/o-spreadsheet-engine/src/plugins/ui_core_views/evaluation_conditional_format.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
IconSetRule,
2222
IconThreshold,
2323
} from "../../types/conditional_formatting";
24+
import { EvaluatedCriterion, EvaluatedDateCriterion } from "../../types/generic_criterion";
2425
import { DEFAULT_LOCALE } from "../../types/locale";
2526
import { CellPosition, DataBarFill, HeaderIndex, Lazy, Style, UID, Zone } from "../../types/misc";
2627
import { CoreViewPlugin } from "../core_view_plugin";
@@ -373,9 +374,10 @@ export class EvaluationConditionalFormatPlugin extends CoreViewPlugin {
373374
return false;
374375
}
375376

376-
const evaluatedCriterion = {
377+
const evaluatedCriterion: EvaluatedCriterion | EvaluatedDateCriterion = {
377378
type: rule.operator,
378379
values: evaluatedCriterionValues.map(toScalar),
380+
dateValue: rule.dateValue || "exactDate",
379381
};
380382
return evaluator.isValueValid(cell.value ?? "", evaluatedCriterion, this.getters, sheetId);
381383
}

packages/o-spreadsheet-engine/src/types/conditional_formatting.ts

Lines changed: 28 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DateCriterionValue } from "./generic_criterion";
12
import { Style, UID } from "./misc";
23
import { Range } from "./range";
34

@@ -54,6 +55,7 @@ export interface CellIsRule extends SingleColorRule {
5455
operator: ConditionalFormattingOperatorValues;
5556
// can be one value for all operator except between, then it is 2 values
5657
values: string[];
58+
dateValue?: DateCriterionValue;
5759
}
5860
export interface ExpressionRule extends SingleColorRule {
5961
type: "ExpressionRule";
@@ -154,38 +156,31 @@ export interface Top10Rule extends SingleColorRule {
154156
}
155157
//https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.conditionalformattingoperatorvalues?view=openxml-2.8.1
156158
// Note: IsEmpty and IsNotEmpty does not exist on the specification
157-
export type ConditionalFormattingOperatorValues =
158-
| "beginsWithText"
159-
| "isBetween"
160-
| "containsText"
161-
| "isEmpty"
162-
| "isNotEmpty"
163-
| "endsWithText"
164-
| "isEqual"
165-
| "isGreaterThan"
166-
| "isGreaterOrEqualTo"
167-
| "isLessThan"
168-
| "isLessOrEqualTo"
169-
| "isNotBetween"
170-
| "notContainsText"
171-
| "isNotEqual"
172-
| "customFormula";
159+
160+
const cfOperators = [
161+
"containsText",
162+
"notContainsText",
163+
"isGreaterThan",
164+
"isGreaterOrEqualTo",
165+
"isLessThan",
166+
"isLessOrEqualTo",
167+
"isBetween",
168+
"isNotBetween",
169+
"beginsWithText",
170+
"endsWithText",
171+
"isNotEmpty",
172+
"isEmpty",
173+
"isNotEqual",
174+
"isEqual",
175+
"customFormula",
176+
"dateIs",
177+
"dateIsBefore",
178+
"dateIsAfter",
179+
"dateIsOnOrBefore",
180+
"dateIsOnOrAfter",
181+
] as const;
182+
183+
export type ConditionalFormattingOperatorValues = (typeof cfOperators)[number];
173184

174185
export const availableConditionalFormatOperators: Set<ConditionalFormattingOperatorValues> =
175-
new Set([
176-
"containsText",
177-
"notContainsText",
178-
"isGreaterThan",
179-
"isGreaterOrEqualTo",
180-
"isLessThan",
181-
"isLessOrEqualTo",
182-
"isBetween",
183-
"isNotBetween",
184-
"beginsWithText",
185-
"endsWithText",
186-
"isNotEmpty",
187-
"isEmpty",
188-
"isNotEqual",
189-
"isEqual",
190-
"customFormula",
191-
]);
186+
new Set(cfOperators);

packages/o-spreadsheet-engine/src/xlsx/functions/conditional_formatting.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ICON_SETS, IconSetType } from "../../components/icons/icons";
2+
import { parseLiteral } from "../../helpers/cells/cell_evaluation";
23
import { colorNumberToHex } from "../../helpers/color";
34
import {
45
CellIsRule,
@@ -12,6 +13,7 @@ import {
1213
IconThreshold,
1314
ThresholdType,
1415
} from "../../types/conditional_formatting";
16+
import { DEFAULT_LOCALE } from "../../types/locale";
1517
import { ExcelIconSet, XLSXDxf, XMLAttributes, XMLString } from "../../types/xlsx";
1618
import { XLSX_ICONSET_MAP } from "../constants";
1719
import { toXlsxHexColor } from "../helpers/colors";
@@ -116,6 +118,50 @@ function cellRuleFormula(ranges: string[], rule: CellIsRule): string[] {
116118
case "isBetween":
117119
case "isNotBetween":
118120
return [values[0], values[1]];
121+
case "dateIs":
122+
switch (rule.dateValue || "exactDate") {
123+
case "exactDate": {
124+
const value = values[0].startsWith("=")
125+
? values[0].slice(1)
126+
: (parseLiteral(values[0], DEFAULT_LOCALE) || "").toString();
127+
const roundedValue = `ROUNDDOWN(${value},0)`;
128+
return [`AND(${firstCell}>=${roundedValue},${firstCell}<${roundedValue}+1)`];
129+
}
130+
case "today":
131+
return [`AND(${firstCell}>=TODAY(),${firstCell}<TODAY()+1)`];
132+
case "yesterday":
133+
return [`AND(${firstCell}>=TODAY()-1,${firstCell}<TODAY())`];
134+
case "tomorrow":
135+
return [`AND(${firstCell}>=TODAY()+1,${firstCell}<TODAY()+2)`];
136+
case "lastWeek":
137+
return [`AND(${firstCell}>=TODAY()-7,${firstCell}<TODAY())`];
138+
case "lastMonth":
139+
return [`AND(${firstCell}>=EDATE(TODAY(),-1),${firstCell}<TODAY())`];
140+
case "lastYear":
141+
return [`AND(${firstCell}>=EDATE(TODAY(),-12),${firstCell}<TODAY())`];
142+
}
143+
case "dateIsBefore":
144+
case "dateIsAfter":
145+
case "dateIsOnOrAfter":
146+
case "dateIsOnOrBefore":
147+
switch (rule.dateValue || "exactDate") {
148+
case "exactDate":
149+
return values[0].startsWith("=")
150+
? [values[0].slice(1)]
151+
: [(parseLiteral(values[0], DEFAULT_LOCALE) || "").toString()];
152+
case "today":
153+
return ["TODAY()"];
154+
case "yesterday":
155+
return ["TODAY()-1"];
156+
case "tomorrow":
157+
return ["TODAY()+1"];
158+
case "lastWeek":
159+
return ["TODAY()-7"];
160+
case "lastMonth":
161+
return ["EDATE(TODAY(),-1)"];
162+
case "lastYear":
163+
return ["EDATE(TODAY(),-12)"];
164+
}
119165
}
120166
}
121167

@@ -141,7 +187,12 @@ function cellRuleTypeAttributes(rule: CellIsRule): XMLAttributes {
141187
case "isLessOrEqualTo":
142188
case "isBetween":
143189
case "isNotBetween":
190+
case "dateIsBefore":
191+
case "dateIsAfter":
192+
case "dateIsOnOrAfter":
193+
case "dateIsOnOrBefore":
144194
return [["type", "cellIs"]];
195+
case "dateIs":
145196
case "customFormula":
146197
return [["type", "expression"]];
147198
}

packages/o-spreadsheet-engine/src/xlsx/helpers/content_helpers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ export function convertOperator(operator: ConditionalFormattingOperatorValues):
7272
return "notEqual";
7373
case "customFormula":
7474
return "";
75+
case "dateIs":
76+
return "";
77+
case "dateIsBefore":
78+
return "lessThan";
79+
case "dateIsAfter":
80+
return "greaterThan";
81+
case "dateIsOnOrAfter":
82+
return "greaterThanOrEqual";
83+
case "dateIsOnOrBefore":
84+
return "lessThanOrEqual";
7585
}
7686
}
7787

src/components/side_panel/conditional_formatting/cf_editor/cf_editor.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
ConditionalFormattingOperatorValues,
2727
DataBarRule,
2828
GenericCriterion,
29+
GenericDateCriterion,
2930
IconSetRule,
3031
IconThreshold,
3132
ThresholdType,
@@ -328,6 +329,9 @@ export class ConditionalFormattingEditor extends Component<Props, SpreadsheetChi
328329

329330
editOperator(operator: ConditionalFormattingOperatorValues) {
330331
this.state.rules.cellIs.operator = operator;
332+
if (operator.includes("date") && !this.state.rules.cellIs.dateValue) {
333+
this.state.rules.cellIs.dateValue = "exactDate";
334+
}
331335
this.updateConditionalFormat({ rule: this.state.rules.cellIs, suppressErrors: true });
332336
this.closeMenus();
333337
}
@@ -347,16 +351,20 @@ export class ConditionalFormattingEditor extends Component<Props, SpreadsheetChi
347351
return criterionComponentRegistry.get(this.state.rules.cellIs.operator).component;
348352
}
349353

350-
get genericCriterion(): GenericCriterion {
354+
get genericCriterion(): GenericDateCriterion | GenericCriterion {
351355
return {
352356
type: this.state.rules.cellIs.operator,
353357
values: this.state.rules.cellIs.values,
358+
dateValue: this.state.rules.cellIs.dateValue,
354359
};
355360
}
356361

357362
onRuleValuesChanged(rule: CellIsRule) {
358363
this.state.rules.cellIs.values = rule.values;
359-
this.updateConditionalFormat({ rule: { ...this.state.rules.cellIs, values: rule.values } });
364+
this.state.rules.cellIs.dateValue = rule.dateValue;
365+
this.updateConditionalFormat({
366+
rule: { ...this.state.rules.cellIs, values: rule.values, dateValue: rule.dateValue },
367+
});
360368
}
361369

362370
/*****************************************************************************

tests/conditional_formatting/conditional_formatting_panel_component.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,28 @@ describe("UI of conditional formats", () => {
277277
});
278278
});
279279

280+
test("can edit a date CellIsRule", async () => {
281+
await click(fixture.querySelectorAll(selectors.listPreview)[0]);
282+
await nextTick();
283+
284+
await changeRuleOperatorType(fixture, "dateIs");
285+
expect(".o-composer").toHaveClass("active");
286+
editStandaloneComposer(selectors.ruleEditor.editor.valueInput, "10/10/2025");
287+
288+
await click(fixture, selectors.ruleEditor.editor.bold);
289+
await click(fixture, selectors.buttonSave);
290+
291+
const sheetId = model.getters.getActiveSheetId();
292+
const cf = model.getters.getConditionalFormats(sheetId).find((c) => c.id === "1");
293+
expect(cf?.rule).toEqual({
294+
operator: "dateIs",
295+
dateValue: "exactDate",
296+
style: { bold: true, fillColor: "#FF0000" },
297+
type: "CellIsRule",
298+
values: ["10/10/2025"],
299+
});
300+
});
301+
280302
test("Can cycle on reference (with F4) in a CellIsRule editor input", async () => {
281303
await click(fixture.querySelectorAll(selectors.listPreview)[0]);
282304
await changeRuleOperatorType(fixture, "beginsWithText");

0 commit comments

Comments
 (0)