diff --git a/src/migrations/data.ts b/src/migrations/data.ts index 84fa4aa208..294c07bbcb 100644 --- a/src/migrations/data.ts +++ b/src/migrations/data.ts @@ -282,6 +282,7 @@ export function createEmptySheet(sheetId: UID, name: string): SheetData { rows: {}, merges: [], conditionalFormats: [], + dataValidationRules: [], figures: [], tables: [], isVisible: true, diff --git a/src/plugins/core/data_validation.ts b/src/plugins/core/data_validation.ts index 0c15e8f2bd..f594dff494 100644 --- a/src/plugins/core/data_validation.ts +++ b/src/plugins/core/data_validation.ts @@ -15,6 +15,7 @@ import { CommandResult, CoreCommand, DataValidationRule, + ExcelWorkbookData, Range, Style, UID, @@ -263,6 +264,23 @@ export class DataValidationPlugin } } + exportForExcel(data: ExcelWorkbookData) { + 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, { useFixedReference: true }) + ), + }); + } + } + } + private checkCriterionTypeIsValid(cmd: AddDataValidationCommand): CommandResult { return dataValidationEvaluatorRegistry.contains(cmd.rule.criterion.type) ? CommandResult.Success diff --git a/src/plugins/core/sheet.ts b/src/plugins/core/sheet.ts index 8244c6872b..993e1b5701 100644 --- a/src/plugins/core/sheet.ts +++ b/src/plugins/core/sheet.ts @@ -286,6 +286,7 @@ export class SheetPlugin extends CorePlugin implements SheetState { formats: {}, borders: {}, conditionalFormats: [], + dataValidationRules: [], figures: [], tables: [], areGridLinesVisible: diff --git a/src/types/workbook_data.ts b/src/types/workbook_data.ts index c4e01a0609..3697c03d6c 100644 --- a/src/types/workbook_data.ts +++ b/src/types/workbook_data.ts @@ -45,7 +45,7 @@ export interface SheetData { cols: { [key: number]: HeaderData }; rows: { [key: number]: HeaderData }; conditionalFormats: ConditionalFormat[]; - dataValidationRules?: DataValidationRuleData[]; + dataValidationRules: DataValidationRuleData[]; tables: TableData[]; areGridLinesVisible?: boolean; isVisible: boolean; diff --git a/src/types/xlsx.ts b/src/types/xlsx.ts index 7402fc75ba..634bf226d2 100644 --- a/src/types/xlsx.ts +++ b/src/types/xlsx.ts @@ -51,6 +51,7 @@ import { ExcelFigureSize } from "./figure"; * - table column (XLSXTableCol) : §18.5.1.3 (tableColumn) * - table style (XLSXTableStyleInfo) : §18.5.1.5 (tableStyleInfo) * - theme color : §20.1.2.3.32 (srgbClr/sysClr) + * - data validation (XLSXDataValidation): §18.3.1.33 (dataValidations) * * [XLSX]: : * - cf rule (XLSXCfRule): §2.6.2 (CT_ConditionalFormatting) @@ -229,6 +230,7 @@ export interface XLSXWorksheet { cols: XLSXColumn[]; rows: XLSXRow[]; cfs: XLSXConditionalFormat[]; + dataValidations: XLSXDataValidation[]; sharedFormulas: string[]; merges: string[]; figures: XLSXFigure[]; @@ -442,6 +444,40 @@ export type XLSXCfOperatorType = | "notContains" | "notEqual"; +export type XLSXDataValidationOperatorType = + | "between" + | "notBetween" + | "equal" + | "notEqual" + | "greaterThan" + | "lessThan" + | "greaterThanOrEqual" + | "lessThanOrEqual"; + +export type XLSXDataValidationCompatibleDecimalCriterionType = + | "isBetween" + | "isNotBetween" + | "isEqual" + | "isNotEqual" + | "isGreaterThan" + | "isGreaterOrEqualTo" + | "isLessThan" + | "isLessOrEqualTo"; + +export type XLSXDataValidationDateOperatorType = Exclude< + XLSXDataValidationOperatorType, + "notEqual" +>; + +export type XLSXDataValidationCompatibleDateCriterionType = + | "dateIsBetween" + | "dateIsNotBetween" + | "dateIs" + | "dateIsAfter" + | "dateIsOnOrAfter" + | "dateIsBefore" + | "dateIsOnOrBefore"; + export type XLSXHorizontalAlignment = | "general" | "left" @@ -506,6 +542,22 @@ export interface XLSXCfRule { equalAverage?: boolean; } +export interface XLSXDataValidation { + type: string; + operator: XLSXDataValidationOperatorType; + sqref: string[]; + formula1: string; + formula2?: string; + errorStyle?: string; + showErrorMessage?: boolean; + errorTitle?: string; + error?: string; + showInputMessage?: boolean; + promptTitle?: string; + prompt?: string; + allowBlank?: boolean; +} + export interface XLSXSharedFormula { formula: string; refCellXc: string; diff --git a/src/xlsx/conversion/conversion_maps.ts b/src/xlsx/conversion/conversion_maps.ts index 7482d50570..4e61198a91 100644 --- a/src/xlsx/conversion/conversion_maps.ts +++ b/src/xlsx/conversion/conversion_maps.ts @@ -16,6 +16,10 @@ import { XLSXCfType, XLSXCfValueObjectType, XLSXChartType, + XLSXDataValidationCompatibleDateCriterionType, + XLSXDataValidationCompatibleDecimalCriterionType, + XLSXDataValidationDateOperatorType, + XLSXDataValidationOperatorType, XLSXHorizontalAlignment, } from "../../types/xlsx"; import { XLSXVerticalAlignment } from "./../../types/xlsx"; @@ -392,3 +396,30 @@ export const IMAGE_EXTENSION_TO_MIMETYPE_MAPPING = { webp: "image/webp", jpg: "image/jpeg", }; + +export const XLSX_DV_DECIMAL_OPERATOR_TO_DV_TYPE_MAPPING: Record< + XLSXDataValidationOperatorType, + XLSXDataValidationCompatibleDecimalCriterionType +> = { + between: "isBetween", + notBetween: "isNotBetween", + equal: "isEqual", + notEqual: "isNotEqual", + greaterThan: "isGreaterThan", + greaterThanOrEqual: "isGreaterOrEqualTo", + lessThan: "isLessThan", + lessThanOrEqual: "isLessOrEqualTo", +}; + +export const XLSX_DV_DATE_OPERATOR_TO_DV_TYPE_MAPPING: Record< + XLSXDataValidationDateOperatorType, + XLSXDataValidationCompatibleDateCriterionType +> = { + between: "dateIsBetween", + notBetween: "dateIsNotBetween", + equal: "dateIs", + greaterThan: "dateIsAfter", + greaterThanOrEqual: "dateIsOnOrAfter", + lessThan: "dateIsBefore", + lessThanOrEqual: "dateIsOnOrBefore", +}; diff --git a/src/xlsx/conversion/data_validation_conversion.ts b/src/xlsx/conversion/data_validation_conversion.ts new file mode 100644 index 0000000000..ddee660a43 --- /dev/null +++ b/src/xlsx/conversion/data_validation_conversion.ts @@ -0,0 +1,124 @@ +import { DataValidationRuleData } from "../../types"; +import { XLSXDataValidation } from "../../types/xlsx"; +import { WarningTypes, XLSXImportWarningManager } from "../helpers/xlsx_parser_error_manager"; +import { + XLSX_DV_DATE_OPERATOR_TO_DV_TYPE_MAPPING, + XLSX_DV_DECIMAL_OPERATOR_TO_DV_TYPE_MAPPING, +} from "./conversion_maps"; + +export function convertDataValidationRules( + xlsxDataValidations: XLSXDataValidation[], + warningManager: XLSXImportWarningManager +): DataValidationRuleData[] { + const dvRules: DataValidationRuleData[] = []; + let dvId = 1; + for (const dv of xlsxDataValidations) { + if (!dv) { + continue; + } + switch (dv.type) { + case "time": + warningManager.generateNotSupportedWarning(WarningTypes.TimeDataValidationNotSupported); + break; + case "textLength": + warningManager.generateNotSupportedWarning( + WarningTypes.TextLengthDataValidationNotSupported + ); + break; + case "whole": + warningManager.generateNotSupportedWarning( + WarningTypes.WholeNumberDataValidationNotSupported + ); + break; + case "decimal": + const decimalRule = convertDecimalRule(dvId++, dv); + dvRules.push(decimalRule); + break; + case "list": + const listRule = convertListrule(dvId++, dv); + dvRules.push(listRule); + break; + case "date": + if (dv.operator === "notEqual") { + warningManager.generateNotSupportedWarning( + WarningTypes.NotEqualDateDataValidationNotSupported + ); + break; + } + const dateRule = convertDateRule(dvId++, dv); + dvRules.push(dateRule); + break; + case "custom": + const customRule = convertCustomRule(dvId++, dv); + dvRules.push(customRule); + break; + } + } + return dvRules; +} + +function convertDecimalRule(id: number, dv: XLSXDataValidation): DataValidationRuleData { + const values = [dv.formula1.toString()]; + if (dv.formula2) { + values.push(dv.formula2.toString()); + } + return { + id: id.toString(), + ranges: dv.sqref, + isBlocking: dv.errorStyle !== "warning", + criterion: { + type: XLSX_DV_DECIMAL_OPERATOR_TO_DV_TYPE_MAPPING[dv.operator] ?? "isBetween", + values, + }, + }; +} + +function convertListrule(id: number, dv: XLSXDataValidation): DataValidationRuleData { + const values = [dv.formula1.toString()]; + if (dv.formula2) { + values.push(dv.formula2.toString()); + } + return { + id: id.toString(), + ranges: dv.sqref, + isBlocking: dv.errorStyle !== "warning", + criterion: { + type: "isValueInRange", + values: dv.formula1.split(","), + displayStyle: "arrow", + }, + }; +} + +function convertDateRule(id: number, dv: XLSXDataValidation): DataValidationRuleData { + const values = [dv.formula1.toString()]; + if (dv.formula2 && dv.formula2 !== "undefined") { + values.push(dv.formula2.toString()); + } + let criterion = { + type: XLSX_DV_DATE_OPERATOR_TO_DV_TYPE_MAPPING[dv.operator] ?? "dateIsBetween", + values: values, + }; + if (!dv.formula2 || dv.formula2 === "undefined") { + // @ts-ignore + criterion.dateValue = "exactDate"; + } + return { + id: id.toString(), + ranges: dv.sqref, + isBlocking: dv.errorStyle !== "warning", + criterion: criterion, + }; +} + +function convertCustomRule(id: number, dv: XLSXDataValidation): DataValidationRuleData { + return { + id: id.toString(), + ranges: dv.sqref, + isBlocking: dv.errorStyle !== "warning", + criterion: { + type: "customFormula", + values: [`=${dv.formula1.toString()}`], + }, + }; +} diff --git a/src/xlsx/conversion/sheet_conversion.ts b/src/xlsx/conversion/sheet_conversion.ts index e4fb3e51e6..546a87db9c 100644 --- a/src/xlsx/conversion/sheet_conversion.ts +++ b/src/xlsx/conversion/sheet_conversion.ts @@ -25,6 +25,7 @@ import { convertHeightFromExcel, convertWidthFromExcel } from "../helpers/conten import { WarningTypes, XLSXImportWarningManager } from "../helpers/xlsx_parser_error_manager"; import { convertConditionalFormats } from "./cf_conversion"; import { convertColor } from "./color_conversion"; +import { convertDataValidationRules } from "./data_validation_conversion"; import { convertFigures } from "./figure_conversion"; import { convertFormulasContent } from "./formula_conversion"; @@ -52,6 +53,7 @@ export function convertSheets( cols: convertCols(sheet, sheetDims[0], colHeaderGroups), rows: convertRows(sheet, sheetDims[1], rowHeaderGroups), conditionalFormats: convertConditionalFormats(sheet.cfs, data.dxfs, warningManager), + dataValidationRules: convertDataValidationRules(sheet.dataValidations, warningManager), figures: convertFigures(sheet), isVisible: sheet.isVisible, panes: sheetOptions diff --git a/src/xlsx/extraction/data_validation_extractor.ts b/src/xlsx/extraction/data_validation_extractor.ts new file mode 100644 index 0000000000..c0604a71ca --- /dev/null +++ b/src/xlsx/extraction/data_validation_extractor.ts @@ -0,0 +1,59 @@ +import { + XLSXDataValidation, + XLSXDataValidationOperatorType, + XLSXFileStructure, + XLSXImportFile, + XLSXTheme, +} from "../../types/xlsx"; +import { XLSXImportWarningManager } from "../helpers/xlsx_parser_error_manager"; +import { XlsxBaseExtractor } from "./base_extractor"; + +export class XlsxDataValidationExtractor extends XlsxBaseExtractor { + theme?: XLSXTheme; + + constructor( + sheetFile: XLSXImportFile, + xlsxStructure: XLSXFileStructure, + warningManager: XLSXImportWarningManager, + theme: XLSXTheme | undefined + ) { + super(sheetFile, xlsxStructure, warningManager); + this.theme = theme; + } + + public extractDataValidations(): XLSXDataValidation[] { + const dataValidations = this.mapOnElements( + { parent: this.rootFile.file.xml, query: "worksheet > dataValidations > dataValidation" }, + (dvElement): XLSXDataValidation => { + return { + type: this.extractAttr(dvElement, "type", { required: true }).asString(), + operator: this.extractAttr( + dvElement, + "operator" + )?.asString() as XLSXDataValidationOperatorType, + sqref: this.extractAttr(dvElement, "sqref", { required: true }).asString().split(" "), + errorStyle: this.extractAttr(dvElement, "errorStyle")?.asString(), + formula1: this.extractDataValidationFormula(dvElement, 1)[0], + formula2: this.extractDataValidationFormula(dvElement, 2)[0], + showErrorMessage: this.extractAttr(dvElement, "showErrorMessage")?.asBool(), + errorTitle: this.extractAttr(dvElement, "errorTitle")?.asString(), + error: this.extractAttr(dvElement, "error")?.asString(), + showInputMessage: this.extractAttr(dvElement, "showInputMessage")?.asBool(), + promptTitle: this.extractAttr(dvElement, "promptTitle")?.asString(), + prompt: this.extractAttr(dvElement, "prompt")?.asString(), + allowBlank: this.extractAttr(dvElement, "allowBlank")?.asBool(), + }; + } + ); + return dataValidations; + } + + private extractDataValidationFormula(dvElement: Element, index: number): string[] { + return this.mapOnElements( + { parent: dvElement, query: `formula${index}` }, + (cfFormulaElements): string => { + return this.extractTextContent(cfFormulaElements, { required: true }); + } + ); + } +} diff --git a/src/xlsx/extraction/sheet_extractor.ts b/src/xlsx/extraction/sheet_extractor.ts index 4ab35badd4..b95ef33e89 100644 --- a/src/xlsx/extraction/sheet_extractor.ts +++ b/src/xlsx/extraction/sheet_extractor.ts @@ -2,6 +2,7 @@ import { XLSXCell, XLSXColumn, XLSXConditionalFormat, + XLSXDataValidation, XLSXFigure, XLSXFileStructure, XLSXFormula, @@ -25,6 +26,7 @@ import { getRelativePath } from "../helpers/misc"; import { XLSXImportWarningManager } from "../helpers/xlsx_parser_error_manager"; import { XlsxBaseExtractor } from "./base_extractor"; import { XlsxCfExtractor } from "./cf_extractor"; +import { XlsxDataValidationExtractor } from "./data_validation_extractor"; import { XlsxFigureExtractor } from "./figure_extractor"; import { XlsxPivotExtractor } from "./pivot_extractor"; import { XlsxTableExtractor } from "./table_extractor"; @@ -57,6 +59,7 @@ export class XlsxSheetExtractor extends XlsxBaseExtractor { sharedFormulas: this.extractSharedFormulas(sheetElement), merges: this.extractMerges(sheetElement), cfs: this.extractConditionalFormats(), + dataValidations: this.extractDataValidations(), figures: this.extractFigures(sheetElement), hyperlinks: this.extractHyperLinks(sheetElement), tables: this.extractTables(sheetElement), @@ -157,6 +160,15 @@ export class XlsxSheetExtractor extends XlsxBaseExtractor { ).extractConditionalFormattings(); } + private extractDataValidations(): XLSXDataValidation[] { + return new XlsxDataValidationExtractor( + this.rootFile, + this.xlsxFileStructure, + this.warningManager, + this.theme + ).extractDataValidations(); + } + private extractFigures(worksheet: Element): XLSXFigure[] { const figures = this.mapOnElements( { parent: worksheet, query: "drawing" }, diff --git a/src/xlsx/functions/data_validation.ts b/src/xlsx/functions/data_validation.ts new file mode 100644 index 0000000000..268e8cef2b --- /dev/null +++ b/src/xlsx/functions/data_validation.ts @@ -0,0 +1,124 @@ +import { DataValidationRuleData } from "../../types"; +import { + XLSXDataValidationCompatibleDateCriterionType, + XLSXDataValidationCompatibleDecimalCriterionType, + XMLAttributes, + XMLString, +} from "../../types/xlsx"; +import { + convertDateCriterionTypeToExcelOperator, + convertDecimalCriterionTypeToExcelOperator, +} from "../helpers/content_helpers"; +import { escapeXml, formatAttributes } from "../helpers/xml_helpers"; +import { adaptFormulaToExcel } from "./cells"; + +export function addDataValidationRules(dataValidationRules: DataValidationRuleData[]): XMLString[] { + const dvRulesCount = dataValidationRules.length; + if (dvRulesCount === 0) { + return []; + } + const dvNodes: XMLString[] = [new XMLString(``)]; + for (const dvRule of dataValidationRules) { + switch (dvRule.criterion.type) { + case "dateIs": + case "dateIsBefore": + case "dateIsOnOrBefore": + case "dateIsAfter": + case "dateIsOnOrAfter": + case "dateIsBetween": + case "dateIsNotBetween": + dvNodes.push(addDateRule(dvRule)); + break; + case "isEqual": + case "isNotEqual": + case "isGreaterThan": + case "isGreaterOrEqualTo": + case "isLessThan": + case "isLessOrEqualTo": + case "isBetween": + case "isNotBetween": + dvNodes.push(addDecimalRule(dvRule)); + break; + case "isValueInRange": + dvNodes.push(addListRule(dvRule)); + break; + case "customFormula": + dvNodes.push(addCustomFormulaRule(dvRule)); + break; + default: + // @ts-ignore Typescript knows it will never happen at compile time + console.warn(`Data validation ${dvRule.criterion.type} not implemented.`); + break; + } + } + dvNodes.push(new XMLString("")); + return dvNodes; +} + +function addDateRule(dvRule: DataValidationRuleData): XMLString { + const rule = dvRule.criterion; + const formula1 = adaptFormulaToExcel(rule.values[0]); + const formula2 = rule.values[1] ? adaptFormulaToExcel(rule.values[1]) : undefined; + const operator = convertDateCriterionTypeToExcelOperator( + dvRule.criterion.type as XLSXDataValidationCompatibleDateCriterionType + ); + const attributes = commonDataValidationAttributes(dvRule); + attributes.push(["type", "date"], ["operator", operator]); + return escapeXml/*xml*/ ` + + ${formula1} + ${formula2} + + `; +} + +function addDecimalRule(dvRule: DataValidationRuleData): XMLString { + const rule = dvRule.criterion; + const formula1 = adaptFormulaToExcel(rule.values[0]); + const formula2 = rule.values[1] ? adaptFormulaToExcel(rule.values[1]) : undefined; + const operator = convertDecimalCriterionTypeToExcelOperator( + dvRule.criterion.type as XLSXDataValidationCompatibleDecimalCriterionType + ); + const attributes = commonDataValidationAttributes(dvRule); + attributes.push(["type", "decimal"], ["operator", operator]); + return escapeXml/*xml*/ ` + + ${formula1} + ${formula2} + + `; +} + +function addListRule(dvRule: DataValidationRuleData): XMLString { + const rule = dvRule.criterion; + const formula1 = adaptFormulaToExcel(rule.values[0]); + const attributes = commonDataValidationAttributes(dvRule); + attributes.push(["type", "list"]); + return escapeXml/*xml*/ ` + + ${formula1} + + `; +} + +function addCustomFormulaRule(dvRule: DataValidationRuleData): XMLString { + const rule = dvRule.criterion; + const formula1 = adaptFormulaToExcel(rule.values[0]); + const attributes = commonDataValidationAttributes(dvRule); + attributes.push(["type", "custom"]); + return escapeXml/*xml*/ ` + + ${formula1} + + `; +} + +function commonDataValidationAttributes(dvRule: DataValidationRuleData): XMLAttributes { + return [ + ["allowBlank", "1"], + ["showInputMessage", "1"], + ["showErrorMessage", "1"], + ["errorStyle", !dvRule.isBlocking ? "warning" : ""], + ["sqref", dvRule.ranges.join(" ")], + ]; +} diff --git a/src/xlsx/helpers/content_helpers.ts b/src/xlsx/helpers/content_helpers.ts index c1769786b6..37c51a20bd 100644 --- a/src/xlsx/helpers/content_helpers.ts +++ b/src/xlsx/helpers/content_helpers.ts @@ -11,6 +11,10 @@ import { } from "../../types"; import { ExtractedStyle, + XLSXDataValidationCompatibleDateCriterionType, + XLSXDataValidationCompatibleDecimalCriterionType, + XLSXDataValidationDateOperatorType, + XLSXDataValidationOperatorType, XLSXHorizontalAlignment, XLSXNumFormat, XLSXRel, @@ -26,7 +30,12 @@ import { HEIGHT_FACTOR, WIDTH_FACTOR, } from "../constants"; -import { V_ALIGNMENT_EXPORT_CONVERSION_MAP, XLSX_FORMAT_MAP } from "../conversion/conversion_maps"; +import { + V_ALIGNMENT_EXPORT_CONVERSION_MAP, + XLSX_DV_DATE_OPERATOR_TO_DV_TYPE_MAPPING, + XLSX_DV_DECIMAL_OPERATOR_TO_DV_TYPE_MAPPING, + XLSX_FORMAT_MAP, +} from "../conversion/conversion_maps"; // ------------------------------------- // CF HELPERS @@ -49,6 +58,33 @@ export function convertOperator(operator: ConditionalFormattingOperatorValues): } } +// ------------------------------------- +// Data Validation HELPERS +// ------------------------------------- +/** + * Convert the o-spreadsheet data validation decimal + * criterion type to the corresponding excel operator. + */ +export function convertDecimalCriterionTypeToExcelOperator( + operator: XLSXDataValidationCompatibleDecimalCriterionType +) { + return Object.keys(XLSX_DV_DECIMAL_OPERATOR_TO_DV_TYPE_MAPPING).find( + (key) => XLSX_DV_DECIMAL_OPERATOR_TO_DV_TYPE_MAPPING[key] === operator + ) as XLSXDataValidationOperatorType; +} + +/** + * Convert the o-spreadsheet data validation date + * criterion type to the corresponding excel operator. + */ +export function convertDateCriterionTypeToExcelOperator( + operator: XLSXDataValidationCompatibleDateCriterionType +) { + return Object.keys(XLSX_DV_DATE_OPERATOR_TO_DV_TYPE_MAPPING).find( + (key) => XLSX_DV_DATE_OPERATOR_TO_DV_TYPE_MAPPING[key] === operator + ) as XLSXDataValidationDateOperatorType; +} + // ------------------------------------- // WORKSHEET HELPERS // ------------------------------------- diff --git a/src/xlsx/helpers/xlsx_parser_error_manager.ts b/src/xlsx/helpers/xlsx_parser_error_manager.ts index 722abfd638..be86ce080a 100644 --- a/src/xlsx/helpers/xlsx_parser_error_manager.ts +++ b/src/xlsx/helpers/xlsx_parser_error_manager.ts @@ -16,6 +16,10 @@ export enum WarningTypes { CfIconSetEmptyIconNotSupported = "IconSets with empty icons", BadlyFormattedHyperlink = "Badly formatted hyperlink", NumFmtIdNotSupported = "Number format", + TimeDataValidationNotSupported = "Time data validation rules", + TextLengthDataValidationNotSupported = "Text length data validation rules", + WholeNumberDataValidationNotSupported = "Whole number data validation rules", + NotEqualDateDataValidationNotSupported = "Not equal date data validation rules", } export class XLSXImportWarningManager { diff --git a/src/xlsx/xlsx_writer.ts b/src/xlsx/xlsx_writer.ts index 095fddec49..76d34cbfd1 100644 --- a/src/xlsx/xlsx_writer.ts +++ b/src/xlsx/xlsx_writer.ts @@ -14,6 +14,7 @@ import { CONTENT_TYPES, NAMESPACE, RELATIONSHIP_NSR, XLSX_RELATION_TYPE } from " import { IMAGE_MIMETYPE_TO_EXTENSION_MAPPING } from "./conversion"; import { createChart } from "./functions/charts"; import { addConditionalFormatting } from "./functions/conditional_formatting"; +import { addDataValidationRules } from "./functions/data_validation"; import { createDrawing } from "./functions/drawings"; import { addBorders, @@ -198,6 +199,7 @@ function createWorksheets(data: ExcelWorkbookData, construct: XLSXStructure): XL ${addRows(construct, data, sheet)} ${addMerges(sheet.merges)} ${joinXmlNodes(addConditionalFormatting(construct.dxfs, sheet.conditionalFormats))} + ${joinXmlNodes(addDataValidationRules(sheet.dataValidationRules))} ${addHyperlinks(construct, data, sheetIndex)} ${drawingNode} ${tablesNode} diff --git a/tests/__xlsx__/xlsx_demo_data.xlsx b/tests/__xlsx__/xlsx_demo_data.xlsx index 60fe846a26..40187f622e 100644 Binary files a/tests/__xlsx__/xlsx_demo_data.xlsx and b/tests/__xlsx__/xlsx_demo_data.xlsx differ diff --git a/tests/model/data.test.ts b/tests/model/data.test.ts index 66009d4a06..cc01a1f40e 100644 --- a/tests/model/data.test.ts +++ b/tests/model/data.test.ts @@ -24,6 +24,7 @@ describe("load data", () => { rows: {}, merges: [], conditionalFormats: [], + dataValidationRules: [], figures: [], tables: [], isVisible: true, @@ -57,6 +58,7 @@ describe("load data", () => { rows: {}, merges: ["A1:B2"], conditionalFormats: [], + dataValidationRules: [], figures: [], tables: [], isVisible: true, @@ -88,6 +90,7 @@ describe("load data", () => { rows: {}, merges: ["A1:B2"], conditionalFormats: [], + dataValidationRules: [], figures: [], tables: [], isVisible: true, @@ -119,6 +122,7 @@ describe("load data", () => { rows: {}, merges: ["A1:B2"], conditionalFormats: [], + dataValidationRules: [], figures: [], tables: [], isVisible: true, @@ -136,6 +140,7 @@ describe("load data", () => { rows: {}, merges: ["C3:D4"], conditionalFormats: [], + dataValidationRules: [], figures: [], tables: [], isVisible: true, @@ -166,6 +171,7 @@ describe("load data", () => { rows: {}, merges: ["A1:B2"], conditionalFormats: [], + dataValidationRules: [], figures: [], tables: [], isVisible: true, diff --git a/tests/test_helpers/xlsx.ts b/tests/test_helpers/xlsx.ts index 2bf212001b..123b9d49c9 100644 --- a/tests/test_helpers/xlsx.ts +++ b/tests/test_helpers/xlsx.ts @@ -1,7 +1,12 @@ import { toCartesian, toXC, toZone } from "../../src/helpers"; import { Border, Color, ConditionalFormat, Style } from "../../src/types"; import { DEFAULT_CELL_HEIGHT, DEFAULT_CELL_WIDTH } from "./../../src/constants"; -import { CellData, SheetData, WorkbookData } from "./../../src/types/workbook_data"; +import { + CellData, + DataValidationRuleData, + SheetData, + WorkbookData, +} from "./../../src/types/workbook_data"; export function getWorkbookSheet(sheetName: string, data: WorkbookData): SheetData | undefined { return data.sheets.find((sheet) => sheet.name === sheetName); @@ -52,6 +57,22 @@ export function getCFBeginningAt(xc: string, sheetData: SheetData): ConditionalF ); } +export function getDataValidationBeginningAt( + xc: string, + sheetData: SheetData +): DataValidationRuleData | undefined { + const position = toCartesian(xc); + return sheetData.dataValidationRules.find((dv) => + dv.ranges.some((range) => { + const dvZone = toZone(range); + if (dvZone.left === position.col && dvZone.top === position.row) { + return true; + } + return false; + }) + ); +} + /** * Transform a color in a standard #RRGGBBAA representation */ diff --git a/tests/xlsx/__snapshots__/xlsx_export.test.ts.snap b/tests/xlsx/__snapshots__/xlsx_export.test.ts.snap index 191db54a29..5d373abc35 100644 --- a/tests/xlsx/__snapshots__/xlsx_export.test.ts.snap +++ b/tests/xlsx/__snapshots__/xlsx_export.test.ts.snap @@ -19590,6 +19590,176 @@ exports[`Test XLSX export Generic sheets (style, hidden, size, cf) Conditional f } `; +exports[`Test XLSX export Generic sheets (style, hidden, size, cf) Data validation 1`] = ` +{ + "files": [ + { + "content": " + + + +", + "contentType": "workbook", + "path": "xl/workbook.xml", + }, + { + "content": " + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A1:A5 + + + + + 10 + + + undefined + + + + + 1/1/2024 + + + 12/31/2024 + + + + + ISNUMBER(E1) + + + +", + "contentType": "sheet", + "path": "xl/worksheets/sheet0.xml", + }, + { + "content": " + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +", + "contentType": "styles", + "path": "xl/styles.xml", + }, + { + "content": " +", + "contentType": "sharedStrings", + "path": "xl/sharedStrings.xml", + }, + { + "content": " + + + +", + "contentType": undefined, + "path": "xl/_rels/workbook.xml.rels", + }, + { + "content": " + + + + + + + + + + + + + + +", + "contentType": undefined, + "path": "[Content_Types].xml", + }, + { + "content": " + +", + "contentType": undefined, + "path": "_rels/.rels", + }, + ], + "name": "my_spreadsheet.xlsx", +} +`; + exports[`Test XLSX export Generic sheets (style, hidden, size, cf) Merges 1`] = ` { "files": [ diff --git a/tests/xlsx/xlsx_export.test.ts b/tests/xlsx/xlsx_export.test.ts index f2ee9e0983..bfa95262e6 100644 --- a/tests/xlsx/xlsx_export.test.ts +++ b/tests/xlsx/xlsx_export.test.ts @@ -639,6 +639,50 @@ describe("Test XLSX export", () => { expect(await exportPrettifiedXlsx(model)).toMatchSnapshot(); }); + test("Data validation", async () => { + const model = new Model({ + sheets: [ + { + dataValidationRules: [ + { + ranges: ["A1:A5"], + criterion: { + type: "isValueInRange", + values: ["A1:A5"], + isBlocking: true, + }, + }, + { + ranges: ["B1:B5"], + criterion: { + type: "isGreaterThan", + values: ["10"], + isBlocking: false, + }, + }, + { + ranges: ["C1:C5"], + criterion: { + type: "dateIsNotBetween", + values: ["01/01/2024", "12/31/2024"], + isBlocking: true, + }, + }, + { + ranges: ["E1:E5"], + criterion: { + type: "customFormula", + values: ["=ISNUMBER(E1)"], + isBlocking: false, + }, + }, + ], + }, + ], + }); + expect(await exportPrettifiedXlsx(model)).toMatchSnapshot(); + }); + test("does not export quarter format", async () => { const model = new Model(); diff --git a/tests/xlsx/xlsx_import.test.ts b/tests/xlsx/xlsx_import.test.ts index 04df5d9d57..db06825266 100644 --- a/tests/xlsx/xlsx_import.test.ts +++ b/tests/xlsx/xlsx_import.test.ts @@ -35,6 +35,7 @@ import { getTextXlsxFiles } from "../__xlsx__/read_demo_xlsx"; import { getCFBeginningAt, getColPosition, + getDataValidationBeginningAt, getRowPosition, getWorkbookCell, getWorkbookCellBorder, @@ -319,6 +320,74 @@ describe("Import xlsx data", () => { expect((cf.rule as CellIsRule).values).toEqual(values); }); + test.each([ + ["number", "A1"], + ["decimal", "B1"], + ["list", "C1"], + ["date", "D1"], + ["time", "E1"], + ["textLength", "F1"], + ["date", "G1"], + ["custom", "H1"], + ])("Can import data validation rule %s", (ruleType, cellXc) => { + const testSheet = getWorkbookSheet("jestDataValidations", convertedData)!; + const dvRule = getDataValidationBeginningAt(cellXc, testSheet); + switch (ruleType) { + case "number": + case "time": + case "textLength": + // Unsupported data validation types + expect(dvRule).toBeUndefined(); + break; + case "decimal": + expect(dvRule).toMatchObject({ + criterion: { + type: "isBetween", + values: ["1", "100"], + }, + ranges: ["B1:B5"], + isBlocking: true, + }); + break; + case "list": + expect(dvRule).toMatchObject({ + criterion: { + type: "isValueInRange", + values: ["$C$1:$C$5"], + }, + ranges: ["C1:C5"], + isBlocking: false, + }); + break; + case "date": + if (cellXc === "D1") { + // Unsupported not equal date data validation + expect(dvRule).toBeUndefined(); + break; + } + expect(dvRule).toMatchObject({ + criterion: { + type: "dateIsAfter", + dateValue: "exactDate", + values: ["45261"], + }, + ranges: ["G1:G5"], + isBlocking: false, + }); + break; + case "custom": + expect(dvRule).toMatchObject({ + criterion: { + type: "customFormula", + values: ["=ISNUMBER(H1)"], + }, + ranges: ["H1:H5"], + isBlocking: false, + }); + break; + } + }); + test.each([ ["2 colors max", "H2"], ["3 colors max", "H3"], diff --git a/tests/xlsx/xlsx_import_export.test.ts b/tests/xlsx/xlsx_import_export.test.ts index e9af88f529..d66d07e373 100644 --- a/tests/xlsx/xlsx_import_export.test.ts +++ b/tests/xlsx/xlsx_import_export.test.ts @@ -211,6 +211,60 @@ describe("Export data to xlsx then import it", () => { ); }); + test.each([ + { + criterion: { + type: "isBetween", + values: ["1", "10"], + }, + ranges: ["A1:A3"], + isBlocking: true, + }, + { + criterion: { + type: "dateIsBefore", + values: ["10/10/2024"], + dateValue: "exactDate", + }, + ranges: ["B1:B3"], + isBlocking: false, + }, + { + criterion: { + type: "isValueInRange", + values: ["C1:C3"], + displayStyle: "arrow", + }, + ranges: ["C1:C3"], + isBlocking: true, + }, + { + criterion: { + type: "customFormula", + values: ["=ISNUMBER(D1)"], + }, + ranges: ["D1:D3"], + isBlocking: false, + }, + ])("Data validation rules %s", (dv: any) => { + const rule = { + id: "1", + criterion: dv.criterion, + isBlocking: dv.isBlocking, + }; + model.dispatch("ADD_DATA_VALIDATION_RULE", { + rule, + ranges: toRangesData(sheetId, dv.ranges[0]), + sheetId, + }); + const importedModel = exportToXlsxThenImport(model); + const sheetRules = importedModel.getters.getDataValidationRules(sheetId).map((rule) => ({ + ...rule, + ranges: rule.ranges.map((rule) => importedModel.getters.getRangeString(rule, sheetId)), + })); + expect(sheetRules).toMatchObject([dv]); + }); + test("figure", () => { createChart( model,