Files
radzen-blazor/Radzen.Blazor/Documents/Spreadsheet/DataValidationRule.cs
Atanas Korchev 609622692e Add culture-aware editing, display, and formula entry to RadzenSpreadsheet
The spreadsheet engine parsed typed input with the invariant culture while
displaying values with the current culture, breaking the edit round-trip on
non-en-US hosts and making comma-decimal entry (10,50) impossible.

Workbook gains a Culture property (defaults to CurrentCulture) which the
component stamps from its inherited Culture parameter. It drives:

- Cell input parsing (CellData type inference), including day-month date
  handling for cultures where the group and date separators collide (de-DE)
- Edit text and display rendering (GetValue, GetValueAsString, GetDisplayText)
- Number format rendering - format codes stay canonical invariant tokens while
  separators, month names, and AM/PM designators follow the culture
- Formula entry and display via the new FormulaLocalizer (Excel FormulaLocal
  semantics: ';' argument separators and ',' decimals in comma-decimal
  cultures, lenient comma acceptance where unambiguous, canonical invariant
  storage)
- Dialog input (data validation, conditional format, filter) via shared
  conversion helpers on SpreadsheetDialogBase

XLSX and CSV files read and write canonical invariant values regardless of
the workbook culture, including autofit column widths. Malformed formulas now
surface as error trees instead of an unhandled lexer exception. Number format
parsing is cached (hard-bounded) since CellView reparsed per render.

Includes localization demos for the Spreadsheet and Document Processing
sections and culture test suites.

Note: headless code on non-en-US hosts now parses string values with the
host culture; set Workbook.Culture explicitly (or to InvariantCulture) for
host-independent processing.
2026-07-08 14:54:55 +03:00

395 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace Radzen.Documents.Spreadsheet;
#nullable enable
/// <summary>
/// Specifies the type of data validation to apply.
/// </summary>
public enum DataValidationType
{
/// <summary>Whole number validation.</summary>
WholeNumber,
/// <summary>Decimal number validation.</summary>
Decimal,
/// <summary>List validation with predefined values.</summary>
List,
/// <summary>Date validation.</summary>
Date,
/// <summary>Time validation.</summary>
Time,
/// <summary>Text length validation.</summary>
TextLength,
/// <summary>Custom formula validation.</summary>
Custom
}
/// <summary>
/// Specifies the comparison operator for data validation.
/// </summary>
public enum DataValidationOperator
{
/// <summary>Value must be between two values.</summary>
Between,
/// <summary>Value must not be between two values.</summary>
NotBetween,
/// <summary>Value must be equal to a value.</summary>
Equal,
/// <summary>Value must not be equal to a value.</summary>
NotEqual,
/// <summary>Value must be greater than a value.</summary>
GreaterThan,
/// <summary>Value must be less than a value.</summary>
LessThan,
/// <summary>Value must be greater than or equal to a value.</summary>
GreaterThanOrEqual,
/// <summary>Value must be less than or equal to a value.</summary>
LessThanOrEqual
}
/// <summary>
/// Specifies the error style for data validation.
/// </summary>
public enum DataValidationErrorStyle
{
/// <summary>Stop - prevents entry of invalid data.</summary>
Stop,
/// <summary>Warning - warns the user but allows entry.</summary>
Warning,
/// <summary>Information - displays information but allows entry.</summary>
Information
}
/// <summary>
/// Represents a data validation rule that can be applied to a range of cells.
/// </summary>
public class DataValidationRule : ICellValidator
{
/// <summary>
/// Gets or sets the type of data validation.
/// </summary>
public DataValidationType Type { get; set; }
/// <summary>
/// Gets or sets the comparison operator.
/// </summary>
public DataValidationOperator Operator { get; set; }
/// <summary>
/// Gets or sets the first formula or value for comparison.
/// </summary>
public string? Formula1 { get; set; }
/// <summary>
/// Gets or sets the second formula or value for comparison (used with Between/NotBetween).
/// </summary>
public string? Formula2 { get; set; }
/// <summary>
/// Gets or sets whether blank cells are allowed.
/// </summary>
public bool AllowBlank { get; set; } = true;
/// <summary>
/// Gets or sets the error dialog title.
/// </summary>
public string? ErrorTitle { get; set; }
/// <summary>
/// Gets or sets the error message.
/// </summary>
public string Error { get; set; } = "The value you entered is not valid.";
/// <summary>
/// Gets or sets the input message title.
/// </summary>
public string? InputTitle { get; set; }
/// <summary>
/// Gets or sets the input message.
/// </summary>
public string? InputMessage { get; set; }
/// <summary>
/// Gets or sets whether to show the input message.
/// </summary>
public bool ShowInputMessage { get; set; }
/// <summary>
/// Gets or sets whether to show the error message.
/// </summary>
public bool ShowErrorMessage { get; set; } = true;
/// <summary>
/// Gets or sets the error style.
/// </summary>
public DataValidationErrorStyle ErrorStyle { get; set; } = DataValidationErrorStyle.Stop;
/// <summary>
/// Gets the list items for List type validation, parsed from Formula1.
/// </summary>
public IReadOnlyList<string> ListItems => GetListItems(null);
/// <summary>
/// Gets the list items for List type validation, resolving cell range references from the given sheet.
/// </summary>
public IReadOnlyList<string> GetListItems(Worksheet? sheet)
{
if (Type != DataValidationType.List || string.IsNullOrEmpty(Formula1))
{
return [];
}
if (Formula1.StartsWith('='))
{
return sheet is not null ? ResolveListFromRange(sheet) : [];
}
return Formula1.Split(',').Select(s => s.Trim().Trim('"')).ToList();
}
private IReadOnlyList<string> ResolveListFromRange(Worksheet sheet)
{
try
{
var rangeText = Formula1![1..]; // strip leading '='
var range = RangeRef.Parse(rangeText);
var items = new List<string>();
for (var row = range.Start.Row; row <= range.End.Row; row++)
{
for (var col = range.Start.Column; col <= range.End.Column; col++)
{
if (sheet.Cells.TryGet(row, col, out var cell) && cell.Value is not null)
{
items.Add(cell.GetValueAsString() ?? "");
}
else
{
items.Add("");
}
}
}
return items;
}
catch (ArgumentException)
{
return [];
}
catch (FormatException)
{
return [];
}
}
/// <inheritdoc/>
public bool Validate(Cell cell)
{
ArgumentNullException.ThrowIfNull(cell);
return Validate(cell, cell.Address);
}
/// <summary>
/// Validates the specified cell, adjusting formula references relative to the given range start.
/// </summary>
public bool Validate(Cell cell, CellRef rangeStart)
{
ArgumentNullException.ThrowIfNull(cell);
if (AllowBlank && (cell.Value is null || (cell.Value is string s && string.IsNullOrEmpty(s)) || cell.ValueType == CellDataType.Empty))
{
return true;
}
return Type switch
{
DataValidationType.WholeNumber => ValidateWholeNumber(cell),
DataValidationType.Decimal => ValidateDecimal(cell),
DataValidationType.List => ValidateList(cell),
DataValidationType.Date => ValidateDate(cell),
DataValidationType.Time => ValidateTime(cell),
DataValidationType.TextLength => ValidateTextLength(cell),
DataValidationType.Custom => ValidateCustom(cell, rangeStart),
_ => true
};
}
private bool ValidateWholeNumber(Cell cell)
{
if (cell.ValueType != CellDataType.Number)
{
return false;
}
var value = Convert.ToDouble(cell.Value, CultureInfo.InvariantCulture);
if (value != Math.Floor(value))
{
return false;
}
return CompareValue(value);
}
private bool ValidateDecimal(Cell cell)
{
if (cell.ValueType != CellDataType.Number)
{
return false;
}
var value = Convert.ToDouble(cell.Value, CultureInfo.InvariantCulture);
return CompareValue(value);
}
private bool ValidateList(Cell cell)
{
var items = GetListItems(cell.Worksheet);
// Must match the culture used to render the list items in GetListItems.
var cellValue = cell.GetValueAsString() ?? "";
return items.Any(item => string.Equals(item, cellValue, StringComparison.OrdinalIgnoreCase));
}
private bool ValidateDate(Cell cell)
{
if (cell.Value is DateTime dateValue)
{
var serialDate = dateValue.ToNumber();
return CompareValue(serialDate);
}
if (cell.ValueType == CellDataType.Number)
{
var value = Convert.ToDouble(cell.Value, CultureInfo.InvariantCulture);
return CompareValue(value);
}
return false;
}
private bool ValidateTime(Cell cell)
{
if (cell.ValueType == CellDataType.Number)
{
var value = Convert.ToDouble(cell.Value, CultureInfo.InvariantCulture);
return CompareValue(value);
}
return false;
}
private bool ValidateTextLength(Cell cell)
{
var text = cell.Value?.ToString() ?? "";
return CompareValue(text.Length);
}
private bool ValidateCustom(Cell cell, CellRef rangeStart)
{
if (string.IsNullOrEmpty(Formula1))
{
return true;
}
try
{
var formula = Formula1.StartsWith('=') ? Formula1 : "=" + Formula1;
var rowDelta = cell.Address.Row - rangeStart.Row;
var colDelta = cell.Address.Column - rangeStart.Column;
if (rowDelta != 0 || colDelta != 0)
{
formula = Worksheet.AdjustFormulaForCopy(formula, rowDelta, colDelta);
}
var tree = FormulaParser.Parse(formula);
if (tree.Errors.Count > 0)
{
return false;
}
var evaluator = new FormulaEvaluator(cell.Worksheet, cell);
var result = evaluator.Evaluate(tree.Root);
if (result.Value is bool boolValue)
{
return boolValue;
}
if (result.Type == CellDataType.Number)
{
return result.GetValueOrDefault<double>() != 0;
}
return false;
}
catch (ArgumentException)
{
return false;
}
catch (FormatException)
{
return false;
}
catch (OverflowException)
{
return false;
}
catch (InvalidCastException)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
private bool CompareValue(double value)
{
var formula1Value = ParseDouble(Formula1);
var formula2Value = ParseDouble(Formula2);
return Operator switch
{
DataValidationOperator.Between => formula1Value.HasValue && formula2Value.HasValue && value >= formula1Value.Value && value <= formula2Value.Value,
DataValidationOperator.NotBetween => formula1Value.HasValue && formula2Value.HasValue && (value < formula1Value.Value || value > formula2Value.Value),
DataValidationOperator.Equal => formula1Value.HasValue && value == formula1Value.Value,
DataValidationOperator.NotEqual => formula1Value.HasValue && value != formula1Value.Value,
DataValidationOperator.GreaterThan => formula1Value.HasValue && value > formula1Value.Value,
DataValidationOperator.LessThan => formula1Value.HasValue && value < formula1Value.Value,
DataValidationOperator.GreaterThanOrEqual => formula1Value.HasValue && value >= formula1Value.Value,
DataValidationOperator.LessThanOrEqual => formula1Value.HasValue && value <= formula1Value.Value,
_ => true
};
}
private static double? ParseDouble(string? value)
{
if (string.IsNullOrEmpty(value))
{
return null;
}
if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
{
return result;
}
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateResult))
{
return dateResult.ToNumber();
}
return null;
}
}