Browse Source

Feature: Add custom CalendarDatePicker date parsing to allow for multiple user input formats (#21193)

* Add new CDPicker props for custom text conversion

* Use TextConverter if not null for date parsing

* Fix comment on CustomDateFormatString

* Add unit test for custom date parsing

* CDPicker: When text set ensure right format

* Add test for invalid date input

* Fix failure on new test for invalid date input

* Swap Convert and ConvertBack

See review comment: https://github.com/AvaloniaUI/Avalonia/pull/21193#discussion_r3265891798

* Add failing test for TextConverter.Convert

* Tweak Convert test based on InvokeAsync used in prop update

* Use TextConverter if avail in DateTimeToString

https://github.com/AvaloniaUI/Avalonia/pull/21193#discussion_r3265887941

* Add clarifying comment

* Rename converter for clarity

* Fix failing tests due to DateTimeToString change

* Update docs for CalendarDatePicker.TextConverter

https://github.com/AvaloniaUI/Avalonia/pull/21193#discussion_r3265904591

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/21434/head
Deadpikle 4 months ago
committed by GitHub
parent
commit
f1aa820986
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 31
      src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.Properties.cs
  2. 23
      src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs
  3. 97
      tests/Avalonia.Controls.UnitTests/CalendarDatePickerTests.cs

31
src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.Properties.cs

@ -1,6 +1,7 @@
using System;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Avalonia.Layout;
namespace Avalonia.Controls
@ -74,6 +75,14 @@ namespace Avalonia.Controls
defaultValue: "d",
validate: IsValidDateFormatString);
/// <summary>
/// Defines the <see cref="TextConverter"/> property.
/// </summary>
public static readonly StyledProperty<IValueConverter?> TextConverterProperty =
AvaloniaProperty.Register<CalendarDatePicker, IValueConverter?>(
nameof(TextConverter),
defaultBindingMode: BindingMode.OneWay);
/// <summary>
/// Defines the <see cref="Text"/> property.
/// </summary>
@ -265,11 +274,33 @@ namespace Avalonia.Controls
set => SetValue(SelectedDateFormatProperty, value);
}
/// <summary>
/// Gets or sets the custom date format used for display (and/or parsing)
/// the date displayed to the user. Not used unless
/// <see cref="SelectedDateFormat"/> is <see cref="CalendarDatePickerFormat.Custom" />.
/// </summary>
/// <value>
/// The default is "d".
/// </value>
public string CustomDateFormatString
{
get => GetValue(CustomDateFormatStringProperty);
set => SetValue(CustomDateFormatStringProperty, value);
}
/// <summary>
/// Gets or sets the custom bidirectional Text-Value converter for custom date
/// string formatting (e.g. for converting user input formats to DateTime or
/// displaying a DateTime in a custom string format).
/// If set, has priority for formatting over <see cref="CustomDateFormatString"/>.
/// Convert: DateTime -> string
/// ConvertBack: string -> DateTIme
/// </summary>
public IValueConverter? TextConverter
{
get => GetValue(TextConverterProperty);
set => SetValue(TextConverterProperty, value);
}
/// <summary>
/// Gets or sets the text that is displayed by the <see cref="CalendarDatePicker" />.

23
src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs

@ -716,15 +716,22 @@ namespace Avalonia.Controls
/// </returns>
private DateTime? ParseText(string text)
{
DateTime newSelectedDate;
DateTime? newSelectedDate;
// TryParse is not used in order to be able to pass the exception to
// the TextParseError event
try
{
newSelectedDate = SelectedDateFormat == CalendarDatePickerFormat.Custom && !string.IsNullOrEmpty(CustomDateFormatString) ?
DateTime.ParseExact(text, CustomDateFormatString, DateTimeHelper.GetCurrentDateFormat()) :
DateTime.Parse(text, DateTimeHelper.GetCurrentDateFormat());
if (TextConverter != null)
{
newSelectedDate = TextConverter.ConvertBack(text, typeof(DateTime?), null, CultureInfo.CurrentCulture) as DateTime?;
}
else
{
newSelectedDate = SelectedDateFormat == CalendarDatePickerFormat.Custom && !string.IsNullOrEmpty(CustomDateFormatString) ?
DateTime.ParseExact(text, CustomDateFormatString, DateTimeHelper.GetCurrentDateFormat()) :
DateTime.Parse(text, DateTimeHelper.GetCurrentDateFormat());
}
if (Calendar.IsValidDateSelection(this._calendar!, newSelectedDate))
{
@ -757,6 +764,10 @@ namespace Avalonia.Controls
private string? DateTimeToString(DateTime d)
{
if (TextConverter != null)
{
return TextConverter.Convert(d, typeof(string), null, CultureInfo.CurrentCulture) as string;
}
DateTimeFormatInfo dtfi = DateTimeHelper.GetCurrentDateFormat();
switch (SelectedDateFormat)
@ -856,7 +867,9 @@ namespace Avalonia.Controls
DateTime? d = ParseText(s);
if (d != null)
{
SetValue(TextProperty, s);
// make sure displayed text is reformatted to correct date format
string? newtext = DateTimeToString((DateTime)d);
SetValue(TextProperty, newtext);
return d;
}
else

97
tests/Avalonia.Controls.UnitTests/CalendarDatePickerTests.cs

@ -2,6 +2,7 @@ using System;
using System.Linq;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Data.Converters;
using Avalonia.Input;
using Avalonia.Platform;
using Avalonia.UnitTests;
@ -98,6 +99,102 @@ namespace Avalonia.Controls.UnitTests
}
}
private class CalendarDatePickerTextConverter : IValueConverter
{
// date to text
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is DateTime d)
return d.ToString("yyyy-MM-dd"); // always return a single format (for this test)
return AvaloniaProperty.UnsetValue;
}
// text to date
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var str = value?.ToString();
if (str == null)
return AvaloniaProperty.UnsetValue;
// allow for a few different date formats
string[] formats = ["yyyy-MM-dd", "MM dd yyyy", "dd.MM.yyyy"];
if (DateTime.TryParseExact(str, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime dateValue))
{
return dateValue;
}
return AvaloniaProperty.UnsetValue;
}
}
[Fact]
public void Setting_Date_Manually_Uses_Text_Converter()
{
CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
using (UnitTestApplication.Start(Services))
{
CalendarDatePicker datePicker = CreateControl();
datePicker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
datePicker.CustomDateFormatString = "dd.MM.yyyy";
datePicker.TextConverter = new CalendarDatePickerTextConverter();
var tb = GetTextBox(datePicker);
datePicker.SelectedDate = new DateTime(2024, 2, 13);
// DateTimeToString called async so need to let that complete before testing value
Threading.Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
Assert.Equal("2024-02-13", datePicker.Text);
Assert.True(CompareDates(datePicker.SelectedDate!.Value, new DateTime(2024, 2, 13)));
// null input results in empty string for text
datePicker.SelectedDate = null;
Assert.Equal("", datePicker.Text);
Assert.Null(datePicker.SelectedDate);
}
}
[Fact]
public void Setting_Date_String_Manually_Can_Accept_Multiple_Formats()
{
CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
using (UnitTestApplication.Start(Services))
{
CalendarDatePicker datePicker = CreateControl();
datePicker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
datePicker.CustomDateFormatString = "dd.MM.yyyy";
datePicker.TextConverter = new CalendarDatePickerTextConverter();
var tb = GetTextBox(datePicker);
// parser can work with same format as CustomDateFormatString (but TextConverter must handle it)
tb.Clear();
RaiseTextEvent(tb, "17.10.2024");
RaiseKeyEvent(tb, Key.Enter, KeyModifiers.None);
Assert.Equal("2024-10-17", datePicker.Text);
Assert.True(CompareDates(datePicker.SelectedDate!.Value, new DateTime(2024, 10, 17)));
// can also handle parsing other formats that the user enters, too
tb.Clear();
RaiseTextEvent(tb, "2024-02-13");
RaiseKeyEvent(tb, Key.Enter, KeyModifiers.None);
Assert.Equal("2024-02-13", datePicker.Text);
Assert.True(CompareDates(datePicker.SelectedDate.Value, new DateTime(2024, 2, 13)));
tb.Clear();
RaiseTextEvent(tb, "04 22 2026");
RaiseKeyEvent(tb, Key.Enter, KeyModifiers.None);
Assert.Equal("2026-04-22", datePicker.Text);
Assert.True(CompareDates(datePicker.SelectedDate.Value, new DateTime(2026, 4, 22)));
// invalid input results in going back to last known (valid) date
tb.Clear();
RaiseTextEvent(tb, "Not A Valid Date");
RaiseKeyEvent(tb, Key.Enter, KeyModifiers.None);
Assert.Equal("2026-04-22", datePicker.Text);
Assert.True(CompareDates(datePicker.SelectedDate.Value, new DateTime(2026, 4, 22)));
}
}
private static TestServices Services => TestServices.MockThreadingInterface.With(
standardCursorFactory: Mock.Of<ICursorFactory>());

Loading…
Cancel
Save