diff --git a/src/Framework/Core/ViewModel/IDotvvmPrimitiveType.cs b/src/Framework/Core/ViewModel/IDotvvmPrimitiveType.cs new file mode 100644 index 0000000000..58752d780d --- /dev/null +++ b/src/Framework/Core/ViewModel/IDotvvmPrimitiveType.cs @@ -0,0 +1,8 @@ +namespace DotVVM.Framework.ViewModel; + +/// +/// Marker interface instructing DotVVM to treat the type as a primitive type. +/// The type is required to have a static TryParse(string, [IFormatProvider,] out T) method and expected to implement ToString() method which is compatible with the TryParse method. +/// Primitive types are then serialized as string in client-side view models. +/// +public interface IDotvvmPrimitiveType { } diff --git a/src/Framework/Framework/Compilation/Javascript/CustomPrimitiveTypesConversionTranslator.cs b/src/Framework/Framework/Compilation/Javascript/CustomPrimitiveTypesConversionTranslator.cs new file mode 100644 index 0000000000..d5052c1446 --- /dev/null +++ b/src/Framework/Framework/Compilation/Javascript/CustomPrimitiveTypesConversionTranslator.cs @@ -0,0 +1,27 @@ +using System.Reflection; +using DotVVM.Framework.Compilation.Javascript.Ast; +using DotVVM.Framework.Utils; + +namespace DotVVM.Framework.Compilation.Javascript +{ + public class CustomPrimitiveTypesConversionTranslator : IJavascriptMethodTranslator + { + public JsExpression? TryTranslateCall(LazyTranslatedExpression? context, LazyTranslatedExpression[] arguments, MethodInfo method) + { + var type = context?.OriginalExpression.Type ?? method.DeclaringType!; + type = type.UnwrapNullableType(); + if (method.Name is "ToString" or "Parse" && ReflectionUtils.IsCustomPrimitiveType(type)) + { + if (method.Name == "ToString" && arguments.Length == 0 && context is {}) + { + return context.JsExpression(); + } + else if (method.Name == "Parse" && arguments.Length == 1 && context is null) + { + return arguments[0].JsExpression(); + } + } + return null; + } + } +} diff --git a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs index ab4145bb40..098be7b437 100644 --- a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs +++ b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs @@ -248,6 +248,7 @@ public JavascriptTranslatorConfiguration() { Translators.Add(MethodCollection = new JavascriptTranslatableMethodCollection()); Translators.Add(new DelegateInvokeMethodTranslator()); + Translators.Add(new CustomPrimitiveTypesConversionTranslator()); } public JsExpression? TryTranslateCall(LazyTranslatedExpression? context, LazyTranslatedExpression[] arguments, MethodInfo method) => diff --git a/src/Framework/Framework/Configuration/CustomPrimitiveTypeRegistration.cs b/src/Framework/Framework/Configuration/CustomPrimitiveTypeRegistration.cs new file mode 100644 index 0000000000..ee4fb33e77 --- /dev/null +++ b/src/Framework/Framework/Configuration/CustomPrimitiveTypeRegistration.cs @@ -0,0 +1,77 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using DotVVM.Framework.Routing; +using DotVVM.Framework.Utils; +using DotVVM.Framework.ViewModel; + +namespace DotVVM.Framework.Configuration +{ + public sealed class CustomPrimitiveTypeRegistration + { + public Type Type { get; } + + public Func TryParseMethod { get; } + + public Func ToStringMethod { get; } + + internal CustomPrimitiveTypeRegistration(Type type) + { + if (ReflectionUtils.IsCollection(type) || ReflectionUtils.IsDictionary(type)) + { + throw new DotvvmConfigurationException($"The type {type} implements {nameof(IDotvvmPrimitiveType)}, but it cannot be used as a custom primitive type. Custom primitive types cannot be collections, dictionaries, and cannot be primitive types already supported by DotVVM."); + } + + Type = type; + + TryParseMethod = ResolveTryParseMethod(type); + ToStringMethod = typeof(IFormattable).IsAssignableFrom(type) + ? obj => ((IFormattable)obj).ToString(null, CultureInfo.InvariantCulture) + : obj => obj.ToString()!; + } + + internal static Func ResolveTryParseMethod(Type type) + { + var tryParseMethod = type.GetMethod("TryParse", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy, null, + new[] { typeof(string), typeof(IFormatProvider), type.MakeByRefType() }, null) + ?? type.GetMethod("TryParse", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy, null, + new[] { typeof(string), type.MakeByRefType() }, null) + ?? throw new DotvvmConfigurationException($"The type {type} implements {nameof(IDotvvmPrimitiveType)} but it does not contain a public static method TryParse(string, IFormatProvider, out {type}) or TryParse(string, out {type})!"); + + var inputParameter = Expression.Parameter(typeof(string), "arg"); + var resultVariable = Expression.Variable(type, "result"); + + var arguments = new Expression?[] + { + inputParameter, + tryParseMethod.GetParameters().Length == 3 + ? Expression.Constant(CultureInfo.InvariantCulture) + : null, + resultVariable + } + .Where(a => a != null) + .Cast() + .ToArray(); + var call = Expression.Call(tryParseMethod, arguments); + + var body = Expression.Block( + new[] { resultVariable }, + Expression.Condition( + Expression.IsTrue(call), + Expression.New(typeof(ParseResult).GetConstructor(new[] { typeof(object) })!, Expression.Convert(resultVariable, typeof(object))), + Expression.Constant(ParseResult.Failed) + ) + ); + return Expression.Lambda>(body, inputParameter).Compile(); + } + + public record ParseResult(object? Result = null) + { + public bool Successful { get; init; } = true; + + public static readonly ParseResult Failed = new ParseResult() { Successful = false }; + } + } +} diff --git a/src/Framework/Framework/Configuration/DefaultSerializerSettingsProvider.cs b/src/Framework/Framework/Configuration/DefaultSerializerSettingsProvider.cs index 4f60311a81..d66ed10b8e 100644 --- a/src/Framework/Framework/Configuration/DefaultSerializerSettingsProvider.cs +++ b/src/Framework/Framework/Configuration/DefaultSerializerSettingsProvider.cs @@ -31,7 +31,8 @@ private JsonSerializerSettings CreateSettings() new DotvvmTimeOnlyConverter(), new StringEnumConverter(), new DotvvmDictionaryConverter(), - new DotvvmByteArrayConverter() + new DotvvmByteArrayConverter(), + new DotvvmCustomPrimitiveTypeConverter() }, MaxDepth = defaultMaxSerializationDepth }; @@ -53,5 +54,7 @@ private DefaultSerializerSettingsProvider() JsonConvert.DefaultSettings = () => new JsonSerializerSettings() { MaxDepth = defaultMaxSerializationDepth }; Settings = CreateSettings(); } + + public static JsonSerializer CreateJsonSerializer() => JsonSerializer.Create(Instance.Settings); } } diff --git a/src/Framework/Framework/Configuration/DotvvmExperimentalFeaturesConfiguration.cs b/src/Framework/Framework/Configuration/DotvvmExperimentalFeaturesConfiguration.cs index e40edf078f..cc292d6547 100644 --- a/src/Framework/Framework/Configuration/DotvvmExperimentalFeaturesConfiguration.cs +++ b/src/Framework/Framework/Configuration/DotvvmExperimentalFeaturesConfiguration.cs @@ -40,11 +40,15 @@ public class DotvvmExperimentalFeaturesConfiguration [JsonProperty("knockoutDeferUpdates")] public DotvvmFeatureFlag KnockoutDeferUpdates { get; private set; } = new DotvvmFeatureFlag("KnockoutDeferUpdates"); + [JsonProperty("useDotvvmSerializationForStaticCommandArguments")] + public DotvvmGlobalFeatureFlag UseDotvvmSerializationForStaticCommandArguments { get; private set; } = new DotvvmGlobalFeatureFlag("UseDotvvmSerializationForStaticCommandArguments"); + public void Freeze() { LazyCsrfToken.Freeze(); ServerSideViewModelCache.Freeze(); ExplicitAssemblyLoading.Freeze(); + UseDotvvmSerializationForStaticCommandArguments.Freeze(); } } diff --git a/src/Framework/Framework/Controls/HtmlGenericControl.cs b/src/Framework/Framework/Controls/HtmlGenericControl.cs index b2b807914b..0c8ed73525 100644 --- a/src/Framework/Framework/Controls/HtmlGenericControl.cs +++ b/src/Framework/Framework/Controls/HtmlGenericControl.cs @@ -13,6 +13,7 @@ using System.Text; using DotVVM.Framework.Compilation.Javascript; using FastExpressionCompiler; +using DotVVM.Framework.ViewModel; namespace DotVVM.Framework.Controls { @@ -405,6 +406,10 @@ private static string AttributeValueToString(object? value) => Enum enumValue => ReflectionUtils.ToEnumString(enumValue.GetType(), enumValue.ToString()), Guid guid => guid.ToString(), _ when ReflectionUtils.IsNumericType(value.GetType()) => Convert.ToString(value, CultureInfo.InvariantCulture) ?? "", + IDotvvmPrimitiveType => value switch { + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? "" + }, System.Collections.IEnumerable => throw new NotSupportedException($"Attribute value of type '{value.GetType().ToCode(stripNamespace: true)}' is not supported. Consider concatenating the values into a string or use the HtmlGenericControl.AttributeList if you need to pass multiple values."), _ => diff --git a/src/Framework/Framework/Hosting/StaticCommandExecutor.cs b/src/Framework/Framework/Hosting/StaticCommandExecutor.cs index 0b21851b38..d9a4d51727 100644 --- a/src/Framework/Framework/Hosting/StaticCommandExecutor.cs +++ b/src/Framework/Framework/Hosting/StaticCommandExecutor.cs @@ -10,6 +10,7 @@ using DotVVM.Framework.Utils; using DotVVM.Framework.ViewModel; using DotVVM.Framework.ViewModel.Validation; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace DotVVM.Framework.Hosting @@ -21,6 +22,7 @@ public class StaticCommandExecutor private readonly IViewModelProtector viewModelProtector; private readonly IStaticCommandArgumentValidator validator; private readonly DotvvmConfiguration configuration; + private readonly JsonSerializer jsonDeserializer; public StaticCommandExecutor(IStaticCommandServiceLoader serviceLoader, IViewModelProtector viewModelProtector, IStaticCommandArgumentValidator validator, DotvvmConfiguration configuration) { @@ -28,6 +30,14 @@ public StaticCommandExecutor(IStaticCommandServiceLoader serviceLoader, IViewMod this.viewModelProtector = viewModelProtector; this.validator = validator; this.configuration = configuration; + if (configuration.ExperimentalFeatures.UseDotvvmSerializationForStaticCommandArguments.Enabled) + { + this.jsonDeserializer = DefaultSerializerSettingsProvider.CreateJsonSerializer(); + } + else + { + this.jsonDeserializer = JsonSerializer.Create(); + } } #pragma warning restore CS0618 @@ -56,7 +66,7 @@ IDotvvmRequestContext context { var (value, path) = a.Type switch { StaticCommandParameterType.Argument => - ((object?)arguments.Dequeue().ToObject((Type)a.Arg!), argumentValidationPaths?.Dequeue()), + ((object?)arguments.Dequeue().ToObject((Type)a.Arg!, this.jsonDeserializer), argumentValidationPaths?.Dequeue()), StaticCommandParameterType.Constant or StaticCommandParameterType.DefaultValue => (a.Arg, null), StaticCommandParameterType.Inject => diff --git a/src/Framework/Framework/Utils/ReflectionUtils.cs b/src/Framework/Framework/Utils/ReflectionUtils.cs index d69347e182..9af973509f 100644 --- a/src/Framework/Framework/Utils/ReflectionUtils.cs +++ b/src/Framework/Framework/Utils/ReflectionUtils.cs @@ -22,6 +22,11 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using RecordExceptions; +using System.ComponentModel; +using DotVVM.Framework.Compilation; +using DotVVM.Framework.Routing; +using DotVVM.Framework.ViewModel; +using System.Diagnostics; namespace DotVVM.Framework.Utils { @@ -231,6 +236,15 @@ public static bool IsAssignableToGenericType(this Type givenType, Type genericTy // convert try { + // custom primitive types + if (TryGetCustomPrimitiveTypeRegistration(type) is { } registration) + { + var result = registration.TryParseMethod(Convert.ToString(value, CultureInfo.InvariantCulture)!); + return result.Successful + ? result.Result + : throw new TypeConvertException(value, type, new Exception("The TryParse method of a custom primitive type failed to parse the value.")); + } + return Convert.ChangeType(value, type, CultureInfo.InvariantCulture); } catch (Exception e) @@ -293,6 +307,8 @@ public record TypeConvertException(object Value, Type Type, Exception InnerExcep typeof (double), typeof (decimal) }; + // mapping of server-side types to their client-side representation + private static readonly ConcurrentDictionary CustomPrimitiveTypes = new(); public static IEnumerable GetNumericTypes() { @@ -348,19 +364,42 @@ public static bool IsCollection(Type type) return type != typeof(string) && IsEnumerable(type) && !IsDictionary(type); } - public static bool IsPrimitiveType(Type type) + /// Returns true if the type is a primitive type natively supported by DotVVM. "Primitive" means that it is serialized as a JavaScript primitive (not object nor array) + public static bool IsDotvvmNativePrimitiveType(Type type) { return PrimitiveTypes.Contains(type) - || (IsNullableType(type) && IsPrimitiveType(type.UnwrapNullableType())) + || (IsNullableType(type) && IsDotvvmNativePrimitiveType(type.UnwrapNullableType())) || type.IsEnum; } - public static bool IsSerializationSupported(this Type type, bool includeNullables) + /// Returns true if the type is a custom primitive type. + public static bool IsCustomPrimitiveType(Type type) + { + return typeof(IDotvvmPrimitiveType).IsAssignableFrom(type); + } + + /// Returns a custom primitive type registration for the given type, or null if the type is not a custom primitive type. + public static CustomPrimitiveTypeRegistration? TryGetCustomPrimitiveTypeRegistration(Type type) + { + if (IsCustomPrimitiveType(type)) + return CustomPrimitiveTypes.GetOrAdd(type, DiscoverCustomPrimitiveType); + else + return null; + } + + /// Returns true the type is serialized as a JavaScript primitive (not object nor array) + public static bool IsPrimitiveType(Type type) { - if (includeNullables) - return IsPrimitiveType(type); + return PrimitiveTypes.Contains(type) + || (IsNullableType(type) && IsPrimitiveType(type.UnwrapNullableType())) + || type.IsEnum + || IsCustomPrimitiveType(type); + } - return PrimitiveTypes.Contains(type); + private static CustomPrimitiveTypeRegistration DiscoverCustomPrimitiveType(Type type) + { + Debug.Assert(typeof(IDotvvmPrimitiveType).IsAssignableFrom(type)); + return new CustomPrimitiveTypeRegistration(type); } public static bool IsNullableType(Type type) diff --git a/src/Framework/Framework/ViewModel/Serialization/CustomPrimitiveTypeJsonConverter.cs b/src/Framework/Framework/ViewModel/Serialization/CustomPrimitiveTypeJsonConverter.cs new file mode 100644 index 0000000000..377bf56a5d --- /dev/null +++ b/src/Framework/Framework/ViewModel/Serialization/CustomPrimitiveTypeJsonConverter.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using DotVVM.Framework.Utils; +using DotVVM.Framework.ViewModel; +using Newtonsoft.Json; + +namespace DotVVM.Framework.ViewModel.Serialization +{ + public class DotvvmCustomPrimitiveTypeConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return ReflectionUtils.IsCustomPrimitiveType(objectType); + } + + public override object? ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType is JsonToken.String + or JsonToken.Boolean + or JsonToken.Integer + or JsonToken.Float + or JsonToken.Date) + { + var registration = ReflectionUtils.TryGetCustomPrimitiveTypeRegistration(objectType)!; + var parseResult = registration.TryParseMethod(Convert.ToString(reader.Value, CultureInfo.InvariantCulture)!); + if (!parseResult.Successful) + { + throw new JsonSerializationException($"The value '{reader.Value}' cannot be deserialized as {objectType} because its TryParse method wasn't able to parse the value!"); + } + return parseResult.Result; + } + else if (reader.TokenType == JsonToken.Null) + { + return null; + } + else + { + throw new JsonSerializationException($"Token {reader.TokenType} cannot be deserialized as {objectType}! Primitive value in JSON was expected."); + } + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + } + else + { + var registration = ReflectionUtils.TryGetCustomPrimitiveTypeRegistration(value.GetType())!; + writer.WriteValue(registration.ToStringMethod(value)); + } + } + + + } +} diff --git a/src/Framework/Framework/ViewModel/Serialization/DotvvmDictionaryConverter.cs b/src/Framework/Framework/ViewModel/Serialization/DotvvmDictionaryConverter.cs index be529f1b08..ecd16b9500 100644 --- a/src/Framework/Framework/ViewModel/Serialization/DotvvmDictionaryConverter.cs +++ b/src/Framework/Framework/ViewModel/Serialization/DotvvmDictionaryConverter.cs @@ -69,7 +69,7 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s if (value is null) throw new Exception($"Could not deserialize object with path '{reader.Path}' as IEnumerable."); foreach (var item in value) { - dict.Add(keyProp.GetValue(item)!, valueProp.GetValue(item)); + dict[keyProp.GetValue(item)!] = valueProp.GetValue(item); } return dict; } diff --git a/src/Framework/Framework/ViewModel/Serialization/ViewModelTypeMetadataSerializer.cs b/src/Framework/Framework/ViewModel/Serialization/ViewModelTypeMetadataSerializer.cs index 3cc4fc524e..c70c202aa1 100644 --- a/src/Framework/Framework/ViewModel/Serialization/ViewModelTypeMetadataSerializer.cs +++ b/src/Framework/Framework/ViewModel/Serialization/ViewModelTypeMetadataSerializer.cs @@ -148,8 +148,21 @@ private ObjectMetadataWithDependencies BuildObjectTypeMetadata(ViewModelSerializ internal JToken GetTypeIdentifier(Type type, HashSet dependentObjectTypes, HashSet dependentEnumTypes) { - if (type.IsSerializationSupported(includeNullables: false)) + if (type.IsEnum) { + dependentEnumTypes.Add(type); + return GetEnumTypeName(type); + } + else if (ReflectionUtils.IsNullable(type)) + { + return GetNullableTypeIdentifier(type, dependentObjectTypes, dependentEnumTypes); + } + else if (ReflectionUtils.IsPrimitiveType(type)) // we intentionally detect this after handling enums and nullable types + { + if (ReflectionUtils.TryGetCustomPrimitiveTypeRegistration(type) is {}) + { + return GetPrimitiveTypeName(typeof(string)); + } return GetPrimitiveTypeName(type); } else if (type == typeof(object)) @@ -162,15 +175,6 @@ internal JToken GetTypeIdentifier(Type type, HashSet dependentObjectTypes, var keyValuePair = typeof(KeyValuePair<,>).MakeGenericType(attrs); return new JArray(GetTypeIdentifier(keyValuePair, dependentObjectTypes, dependentEnumTypes)); } - else if (type.IsEnum) - { - dependentEnumTypes.Add(type); - return GetEnumTypeName(type); - } - else if (ReflectionUtils.IsNullable(type)) - { - return GetNullableTypeIdentifier(type, dependentObjectTypes, dependentEnumTypes); - } else if (ReflectionUtils.IsCollection(type)) { return new JArray(GetTypeIdentifier(ReflectionUtils.GetEnumerableType(type)!, dependentObjectTypes, dependentEnumTypes)); diff --git a/src/Framework/Framework/ViewModel/Validation/StaticCommandArgumentValidator.cs b/src/Framework/Framework/ViewModel/Validation/StaticCommandArgumentValidator.cs index 989e114d13..2d2ecf7a50 100644 --- a/src/Framework/Framework/ViewModel/Validation/StaticCommandArgumentValidator.cs +++ b/src/Framework/Framework/ViewModel/Validation/StaticCommandArgumentValidator.cs @@ -52,7 +52,7 @@ public StaticCommandArgumentValidator(IViewModelValidator viewModelValidator, IV } } // validate data annotations in the object - if (argument is {} && ReflectionUtils.IsComplexType(argument.GetType())) + if (argument is {} && !ReflectionUtils.IsDotvvmNativePrimitiveType(argument.GetType())) { var errors = this.viewModelValidator.ValidateViewModel(args[i]); foreach (var e in errors) diff --git a/src/Framework/Framework/ViewModel/Validation/ViewModelValidator.cs b/src/Framework/Framework/ViewModel/Validation/ViewModelValidator.cs index a952027888..ad7e0388cd 100644 --- a/src/Framework/Framework/ViewModel/Validation/ViewModelValidator.cs +++ b/src/Framework/Framework/ViewModel/Validation/ViewModelValidator.cs @@ -43,11 +43,10 @@ private IEnumerable ValidateViewModel(object? viewMode } if (alreadyValidated.Contains(viewModel)) yield break; var viewModelType = viewModel.GetType(); - if (ReflectionUtils.IsPrimitiveType(viewModelType) || ReflectionUtils.IsNullableType(viewModelType)) + if (ReflectionUtils.IsDotvvmNativePrimitiveType(viewModelType)) { yield break; } - alreadyValidated.Add(viewModel); if (ReflectionUtils.IsEnumerable(viewModelType)) @@ -81,7 +80,9 @@ private IEnumerable ValidateViewModel(object? viewMode var propertyResult = rule.SourceValidationAttribute?.GetValidationResult(value, context); if (propertyResult != ValidationResult.Success) { - yield return new ViewModelValidationError(rule.ErrorMessage, pathPrefix + property.Name, rootObject); + var propertyPath = + viewModel is IDotvvmPrimitiveType ? pathPrefix.TrimEnd('/') : pathPrefix + property.Name; + yield return new ViewModelValidationError(rule.ErrorMessage, propertyPath, rootObject); } } } @@ -89,7 +90,7 @@ private IEnumerable ValidateViewModel(object? viewMode // inspect objects if (value != null) { - if (ReflectionUtils.IsComplexType(property.Type)) + if (!ReflectionUtils.IsDotvvmNativePrimitiveType(property.Type)) { // complex objects foreach (var error in ValidateViewModel(value, alreadyValidated, rootObject, pathPrefix + property.Name + "/")) diff --git a/src/Framework/Testing/DotvvmTestHelper.cs b/src/Framework/Testing/DotvvmTestHelper.cs index 018e9a22fe..dd3bc7d07c 100644 --- a/src/Framework/Testing/DotvvmTestHelper.cs +++ b/src/Framework/Testing/DotvvmTestHelper.cs @@ -101,6 +101,7 @@ public static void RegisterMoqServices(IServiceCollection services) private static Lazy _defaultConfig = new Lazy(() => { var config = CreateConfiguration(); + config.ExperimentalFeatures.UseDotvvmSerializationForStaticCommandArguments.Enable(); config.RouteTable.Add("TestRoute", "TestRoute", "TestView.dothtml"); config.Freeze(); return config; diff --git a/src/Samples/ApplicationInsights.Owin/Web.config b/src/Samples/ApplicationInsights.Owin/Web.config index a7b2698ee1..6063647d33 100644 --- a/src/Samples/ApplicationInsights.Owin/Web.config +++ b/src/Samples/ApplicationInsights.Owin/Web.config @@ -106,4 +106,4 @@ - \ No newline at end of file + diff --git a/src/Samples/Common/Controls/ControlWithCustomPrimitiveTypeProperties.cs b/src/Samples/Common/Controls/ControlWithCustomPrimitiveTypeProperties.cs new file mode 100644 index 0000000000..2b513d6a87 --- /dev/null +++ b/src/Samples/Common/Controls/ControlWithCustomPrimitiveTypeProperties.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using DotVVM.Framework.Binding; +using DotVVM.Framework.Binding.Expressions; +using DotVVM.Framework.Controls; +using DotVVM.Framework.ViewModel; + +namespace DotVVM.Samples.Common.Controls +{ + public class ControlWithCustomPrimitiveTypeProperties : CompositeControl + { + public DotvvmControl GetContents( + HtmlCapability htmlCapability, + Point pointValue, + Point pointValue2, + IValueBinding pointBinding, + ValueOrBinding pointValueOrBinding, + ValueOrBinding pointValueOrBinding2, + ValueOrBinding pointValueOrBinding3, + ValueOrBinding pointValueOrBinding4) + { + return new HtmlGenericControl("ul", htmlCapability) + .AppendChildren( + new HtmlGenericControl("li") + .AppendChildren(new Literal(pointValue.ToString(null, null))), + new HtmlGenericControl("li") + .AppendChildren(new Literal(pointValue2.ToString(null, null))), + new HtmlGenericControl("li") + .AppendChildren(new Literal(pointBinding)), + new HtmlGenericControl("li") + .AppendChildren(new Literal(pointValueOrBinding)), + new HtmlGenericControl("li") + .AppendChildren(new Literal(pointValueOrBinding2)), + new HtmlGenericControl("li") + .AppendChildren(new Literal(pointValueOrBinding3)), + new HtmlGenericControl("li") + .AppendChildren(new Literal(pointValueOrBinding4)) + ); + } + + } + + public struct Point : IFormattable, IDotvvmPrimitiveType + { + public int X { get; set; } + public int Y { get; set; } + + public static bool TryParse(string value, IFormatProvider formatProvider, out Point result) + { + var parts = value.Split(','); + if (int.TryParse(parts[0], out var x) && int.TryParse(parts[1], out var y)) + { + result = new Point { X = x, Y = y }; + return true; + } + else + { + result = default; + return false; + } + } + + public static Point Parse(string value) => TryParse(value, CultureInfo.InvariantCulture, out var result) ? result : throw new FormatException(); + + // note: both implementations of ToString must return the same result - they should not depend on the current culture + public override string ToString() => $"{X},{Y}"; + + public string ToString(string format, IFormatProvider formatProvider) => $"{X},{Y}"; + } +} diff --git a/src/Samples/Common/DotVVM.Samples.Common.csproj b/src/Samples/Common/DotVVM.Samples.Common.csproj index 22023293b8..4602ac2f7a 100644 --- a/src/Samples/Common/DotVVM.Samples.Common.csproj +++ b/src/Samples/Common/DotVVM.Samples.Common.csproj @@ -94,6 +94,10 @@ + + + + diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index 8bd8555114..71e666731f 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -31,6 +31,7 @@ using DotVVM.Samples.Common.ViewModels.FeatureSamples.BindingVariables; using DotVVM.Samples.Common.Views.ControlSamples.TemplateHost; using DotVVM.Framework.ResourceManagement; +using DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes; namespace DotVVM.Samples.BasicSamples { @@ -60,6 +61,7 @@ public void Configure(DotvvmConfiguration config, string applicationPath) { config.DefaultCulture = "en-US"; } + config.ExperimentalFeatures.UseDotvvmSerializationForStaticCommandArguments.Enable(); AddControls(config); AddStyles(config); @@ -98,6 +100,15 @@ public void Configure(DotvvmConfiguration config, string applicationPath) config.AssertConfigurationIsValid(); config.RouteTable.Add("Errors_Routing_NonExistingView", "Errors/Routing/NonExistingView", "Views/Errors/Routing/NonExistingView.dothml"); + + config.Markup.JavascriptTranslator.MethodCollection + .AddPropertyGetterTranslator(typeof(ITypeId), nameof(ITypeId.IdValue), + new GenericMethodCompiler(args => args[0]) + ); + config.Markup.JavascriptTranslator.MethodCollection + .AddMethodTranslator(typeof(SampleId), nameof(ToString), + new GenericMethodCompiler(args => args[0]) + ); } private void LoadSampleConfiguration(DotvvmConfiguration config, string applicationPath) @@ -214,6 +225,7 @@ private static void AddRoutes(DotvvmConfiguration config) config.RouteTable.Add("FeatureSamples_ParameterBinding_OptionalParameterBinding2", "FeatureSamples/ParameterBinding/OptionalParameterBinding2/{Id?}", "Views/FeatureSamples/ParameterBinding/OptionalParameterBinding.dothtml", new { Id = 300 }); config.RouteTable.Add("FeatureSamples_Validation_Localization", "FeatureSamples/Validation/Localization", "Views/FeatureSamples/Validation/Localization.dothtml", presenterFactory: LocalizablePresenter.BasedOnQuery("lang")); config.RouteTable.Add("FeatureSamples_Localization_Globalize", "FeatureSamples/Localization/Globalize", "Views/FeatureSamples/Localization/Globalize.dothtml", presenterFactory: LocalizablePresenter.BasedOnQuery("lang")); + config.RouteTable.Add("FeatureSamples_CustomPrimitiveTypes_Basic", "FeatureSamples/CustomPrimitiveTypes/Basic/{Id?}", "Views/FeatureSamples/CustomPrimitiveTypes/Basic.dothtml"); config.RouteTable.AutoDiscoverRoutes(new DefaultRouteStrategy(config)); diff --git a/src/Samples/Common/ViewModels/FeatureSamples/CompositeControls/BasicSampleViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/CompositeControls/BasicSampleViewModel.cs index 7ba58f7dc9..ebff37cfe4 100644 --- a/src/Samples/Common/ViewModels/FeatureSamples/CompositeControls/BasicSampleViewModel.cs +++ b/src/Samples/Common/ViewModels/FeatureSamples/CompositeControls/BasicSampleViewModel.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text; using DotVVM.Framework.ViewModel; +using DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes; namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CompositeControls { diff --git a/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/BasicViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/BasicViewModel.cs new file mode 100644 index 0000000000..b026083166 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/BasicViewModel.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Core.Storage; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.ViewModel.Validation; +using Newtonsoft.Json; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes +{ + public class BasicViewModel : DotvvmViewModelBase + { + + [FromRoute("id")] + public SampleId IdInRoute { get; set; } + + [FromQuery("id")] + public SampleId? IdInQuery { get; set; } + + [Required] + public SampleId SelectedItemId { get; set; } + + [Required] + public SampleId? SelectedItemNullableId { get; set; } + + public List Items { get; set; } = new List + { + new SampleItem() { Id = SampleId.CreateExisting(new Guid("96c37b99-5fd5-448c-8a64-977ae11b8b8b")), Text = "Item 1" }, + new SampleItem() { Id = SampleId.CreateExisting(new Guid("c2654a1f-3781-49a8-911b-c7346db166e0")), Text = "Item 2" }, + new SampleItem() { Id = SampleId.CreateExisting(new Guid("e467a201-9ab7-4cd5-adbf-66edd03f6ae1")), Text = "Item 3" }, + }; + + public SampleId StaticCommandResult { get; set; } + + [AllowStaticCommand] + public SampleId StaticCommandWithSampleId(SampleId? current) + { + if (!Items.Any(i => i.Id == current)) + { + throw new Exception("The 'current' parameter didn't deserialize correctly."); + } + return SampleId.CreateExisting(new Guid("54162c7e-cdcc-4585-aa92-2e78be3f0c75")); + } + + public void CommandWithSampleId(SampleId current) + { + if (!Items.Any(i => i.Id == current)) + { + throw new Exception("The 'current' parameter didn't deserialize correctly."); + } + if (current == Items[0].Id) + { + this.AddModelError(vm => vm.SelectedItemId, "Valid property path"); + this.AddModelError(vm => vm.SelectedItemId.IdValue, "Invalid property path"); + Context.FailOnInvalidModelState(); + } + } + + } +} + diff --git a/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/ITypeId.cs b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/ITypeId.cs new file mode 100644 index 0000000000..205da1b3a8 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/ITypeId.cs @@ -0,0 +1,11 @@ +using System; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes +{ + public interface ITypeId + { + Guid IdValue { get; } + } + +} + diff --git a/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/RouteLinkViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/RouteLinkViewModel.cs new file mode 100644 index 0000000000..49d8dff42a --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/RouteLinkViewModel.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes +{ + public class RouteLinkViewModel : DotvvmViewModelBase + { + + public SampleId Id1 { get; set; } = new SampleId(new Guid("D7682DE1-B985-4B4B-B2BF-C349192AD9C9")); + + public SampleId Id2 { get; set; } = new SampleId(new Guid("6F5E8011-BD12-477D-9E82-A7A1CE836773")); + + public SampleId Null { get; set; } + + public void ChangeIds() + { + Null = Id1; + Id1 = Id2; + Id2 = null; + } + } +} + diff --git a/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/SampleId.cs b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/SampleId.cs new file mode 100644 index 0000000000..ae9b2a155d --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/SampleId.cs @@ -0,0 +1,14 @@ +using System; +using System.Security.Cryptography; +using DotVVM.Framework.ViewModel; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes +{ + public record SampleId : TypeId, IDotvvmPrimitiveType + { + public SampleId(Guid idValue) : base(idValue) + { + } + } +} + diff --git a/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/SampleItem.cs b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/SampleItem.cs new file mode 100644 index 0000000000..b2f7dc8a0e --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/SampleItem.cs @@ -0,0 +1,10 @@ +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes +{ + public class SampleItem + { + public SampleId Id { get; set; } + public string Text { get; set; } + } + +} + diff --git a/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/TextBoxViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/TextBoxViewModel.cs new file mode 100644 index 0000000000..529019d2c2 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/TextBoxViewModel.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; +using DotVVM.Samples.Common.Controls; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes +{ + public class TextBoxViewModel : DotvvmViewModelBase + { + + public Point Point { get; set; } = new Point() { X = 15, Y = 32 }; + + [Required] + [RegularExpression(@"^\d+,\d+$")] + public Point Null { get; set; } + + } +} + diff --git a/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/TypeId.cs b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/TypeId.cs new file mode 100644 index 0000000000..081b1775ab --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/TypeId.cs @@ -0,0 +1,55 @@ +using System; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes +{ + public abstract record TypeId : ITypeId + where TId : TypeId + { + public Guid IdValue { get; } + + protected TypeId(Guid idValue) + { + if (idValue == default) throw new ArgumentException(nameof(idValue)); + IdValue = idValue; + } + + public static TId CreateNew() + { + var guid = Guid.NewGuid(); + return (TId)Activator.CreateInstance(typeof(TId), args: guid)!; + } + + public static TId CreateExisting(Guid idValue) + { + if (idValue == default) throw new ArgumentException(nameof(idValue)); + return (TId)Activator.CreateInstance(typeof(TId), args: idValue)!; + } + + public static TId Parse(object? value) + { + if (value is string stringValue) + { + return CreateExisting(new Guid(stringValue)); + } + else if (value is Guid guidValue) + { + return CreateExisting(guidValue); + } + else if (value == null) + { + return null; + } + else + { + throw new NotSupportedException($"Cannot parse TypeId from {value.GetType()}!"); + } + } + + public static bool TryParse(string id, out TId result) + => (result = Guid.TryParse(id, out var r) ? CreateExisting(r) : null) is not null; + + public sealed override string ToString() => IdValue.ToString(); + } + +} + diff --git a/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/UsedInControlsViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/UsedInControlsViewModel.cs new file mode 100644 index 0000000000..29a6e722f9 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/CustomPrimitiveTypes/UsedInControlsViewModel.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; +using DotVVM.Samples.Common.Controls; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes +{ + public class UsedInControlsViewModel : DotvvmViewModelBase + { + + public Point Point { get; set; } = new Point() { X = 1, Y = 2 }; + + } +} + diff --git a/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/Basic.dothtml b/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/Basic.dothtml new file mode 100644 index 0000000000..4f2f1123c4 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/Basic.dothtml @@ -0,0 +1,63 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes.BasicViewModel, DotVVM.Samples.Common + + + + + + + Custom primitive types + + +

+ Selected item ID: {{value: SelectedItemId}} +
+ + +

+ +

+ Selected nullable item ID: {{value: SelectedItemNullableId}} +
+ + +

+ +

+ Route parameter: {{value: IdInRoute}} +
+ Query parameter: {{value: IdInQuery}} +

+

+ +

+ +

+ +
+ +
+ Static command result: {{value: StaticCommandResult}} +

+ +

+ Binding with JS translation: {{value: $"My id values are {SelectedItemId.IdValue.ToString()} and {SelectedItemNullableId.IdValue.ToString().ToUpper()}"}} +

+ + + diff --git a/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/RouteLink.dothtml b/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/RouteLink.dothtml new file mode 100644 index 0000000000..d8b6a07510 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/RouteLink.dothtml @@ -0,0 +1,49 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes.RouteLinkViewModel, DotVVM.Samples.Common + + + + + + + + + + +

RouteLink

+ +

Client

+ + + +

Server

+ + + + + + + + + diff --git a/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/TextBox.dothtml b/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/TextBox.dothtml new file mode 100644 index 0000000000..561aafb630 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/TextBox.dothtml @@ -0,0 +1,30 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes.TextBoxViewModel, DotVVM.Samples.Common + + + + + + + + + +

TextBox

+ + + + + + +
    +
  • Point X: {{resource: Point.X}}
  • +
  • Point Y: {{resource: Point.Y}}
  • +
  • Null X: {{resource: Null.X}}
  • +
  • Null Y: {{resource: Null.Y}}
  • +
+ + + + + + + diff --git a/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/UsedInControls.dothtml b/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/UsedInControls.dothtml new file mode 100644 index 0000000000..0878d2cba7 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/CustomPrimitiveTypes/UsedInControls.dothtml @@ -0,0 +1,29 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes.UsedInControlsViewModel, DotVVM.Samples.Common + + + + + + + + + +

Custom primitive types used in properties

+ + + +
{{value: Point.ToString()}}
+
{{value: Point}}
+ + + + + diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index 43cd0c94b0..d1c5806bce 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -237,6 +237,10 @@ public partial class SamplesRouteUrls public const string FeatureSamples_CompositeControls_BasicSample = "FeatureSamples/CompositeControls/BasicSample"; public const string FeatureSamples_CompositeControls_ControlPropertyNamingConflict = "FeatureSamples/CompositeControls/ControlPropertyNamingConflict"; public const string FeatureSamples_ConditionalCssClasses_ConditionalCssClasses = "FeatureSamples/ConditionalCssClasses/ConditionalCssClasses"; + public const string FeatureSamples_CustomPrimitiveTypes_Basic = "FeatureSamples/CustomPrimitiveTypes/Basic"; + public const string FeatureSamples_CustomPrimitiveTypes_RouteLink = "FeatureSamples/CustomPrimitiveTypes/RouteLink"; + public const string FeatureSamples_CustomPrimitiveTypes_TextBox = "FeatureSamples/CustomPrimitiveTypes/TextBox"; + public const string FeatureSamples_CustomPrimitiveTypes_UsedInControls = "FeatureSamples/CustomPrimitiveTypes/UsedInControls"; public const string FeatureSamples_CustomResponseProperties_SimpleExceptionFilter = "FeatureSamples/CustomResponseProperties/SimpleExceptionFilter"; public const string FeatureSamples_DateTimeSerialization_DateTimeSerialization = "FeatureSamples/DateTimeSerialization/DateTimeSerialization"; public const string FeatureSamples_DependencyInjection_ViewModelScopedService = "FeatureSamples/DependencyInjection/ViewModelScopedService"; diff --git a/src/Samples/Tests/Tests/Feature/CustomPrimitiveTypesTests.cs b/src/Samples/Tests/Tests/Feature/CustomPrimitiveTypesTests.cs new file mode 100644 index 0000000000..1d05584bc1 --- /dev/null +++ b/src/Samples/Tests/Tests/Feature/CustomPrimitiveTypesTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Samples.Tests.Base; +using DotVVM.Testing.Abstractions; +using OpenQA.Selenium; +using Riganti.Selenium.Core; +using Riganti.Selenium.DotVVM; +using Xunit.Abstractions; +using Xunit; + +namespace DotVVM.Samples.Tests.Feature +{ + public class CustomPrimitiveTypesTests : AppSeleniumTest + { + public CustomPrimitiveTypesTests(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData("", "", "")] + [InlineData("/96c37b99-5fd5-448c-8a64-977ae11b8b8b?Id=c2654a1f-3781-49a8-911b-c7346db166e0", "96c37b99-5fd5-448c-8a64-977ae11b8b8b", "c2654a1f-3781-49a8-911b-c7346db166e0")] + public void Feature_CustomPrimitiveTypes_Basic(string urlSuffix, string expectedRouteParam, string expectedQueryParam) + { + RunInAllBrowsers(browser => + { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_CustomPrimitiveTypes_Basic + urlSuffix); + + var selectedItem = browser.Single("selected-item", SelectByDataUi); + var selectedItemCombo = browser.Single("selected-item-combo", SelectByDataUi); + var selectedItemValidator = browser.Single("selected-item-validator", SelectByDataUi); + var selectedItemNullable = browser.Single("selected-item-nullable", SelectByDataUi); + var selectedItemNullableCombo = browser.Single("selected-item-nullable-combo", SelectByDataUi); + var selectedItemNullableValidator = browser.Single("selected-item-nullable-validator", SelectByDataUi); + var idFromRoute = browser.Single("id-from-route", SelectByDataUi); + var idFromQuery = browser.Single("id-from-query", SelectByDataUi); + var routeLink = browser.Single("routelink", SelectByDataUi); + var command = browser.Single("command", SelectByDataUi); + var staticCommand = browser.Single("static-command", SelectByDataUi); + var staticCommandResult = browser.Single("static-command-result", SelectByDataUi); + var binding = browser.Single("binding", SelectByDataUi); + + // check route link + AssertUI.TextEquals(idFromRoute, expectedRouteParam); + AssertUI.TextEquals(idFromQuery, expectedQueryParam); + AssertUI.Attribute(routeLink, "href", v => v.Contains(urlSuffix)); + + // select in first list + AssertUI.TextEquals(binding, "My id values are and"); + AssertUI.TextEquals(selectedItem, ""); + selectedItemCombo.Select(0); + AssertUI.TextEquals(selectedItem, "96c37b99-5fd5-448c-8a64-977ae11b8b8b"); + selectedItemCombo.Select(1); + AssertUI.TextEquals(selectedItem, "c2654a1f-3781-49a8-911b-c7346db166e0"); + AssertUI.TextEquals(binding, "My id values are c2654a1f-3781-49a8-911b-c7346db166e0 and"); + + // select in second list + AssertUI.TextEquals(selectedItemNullable, ""); + selectedItemNullableCombo.Select(3); + AssertUI.TextEquals(selectedItemNullable, "e467a201-9ab7-4cd5-adbf-66edd03f6ae1"); + AssertUI.TextEquals(binding, "My id values are c2654a1f-3781-49a8-911b-c7346db166e0 and E467A201-9AB7-4CD5-ADBF-66EDD03F6AE1"); + selectedItemNullableCombo.Select(0); + AssertUI.TextEquals(selectedItemNullable, ""); + + // command and validation + AssertUI.IsNotDisplayed(selectedItemValidator); + AssertUI.IsNotDisplayed(selectedItemNullableValidator); + command.Click(); + + AssertUI.IsNotDisplayed(selectedItemValidator); + AssertUI.IsDisplayed(selectedItemNullableValidator); + AssertUI.TextEquals(selectedItemNullableValidator, "The SelectedItemNullableId field is required."); + selectedItemCombo.Select(0); + selectedItemNullableCombo.Select(1); + command.Click(); + + AssertUI.IsDisplayed(selectedItemValidator); + AssertUI.IsNotDisplayed(selectedItemNullableValidator); + AssertUI.TextEquals(selectedItemValidator, "Valid property path Invalid property path"); + selectedItemCombo.Select(1); + command.Click(); + + AssertUI.IsNotDisplayed(selectedItemValidator); + AssertUI.IsNotDisplayed(selectedItemNullableValidator); + + // static command + staticCommand.Click(); + AssertUI.TextEquals(staticCommandResult, "54162c7e-cdcc-4585-aa92-2e78be3f0c75"); + }); + } + + [Fact] + public void Feature_CustomPrimitiveTypes_RouteLink() + { + RunInAllBrowsers(browser => + { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_CustomPrimitiveTypes_RouteLink); + + var links = browser.FindElements("a").ThrowIfDifferentCountThan(4); + + AssertUI.Attribute(links[0], "href", u => u.EndsWith("/FeatureSamples/CustomPrimitiveTypes/Basic/d7682de1-b985-4b4b-b2bf-c349192ad9c9?Id=6f5e8011-bd12-477d-9e82-a7a1ce836773")); + AssertUI.Attribute(links[1], "href", u => u.EndsWith("/FeatureSamples/CustomPrimitiveTypes/Basic/d7682de1-b985-4b4b-b2bf-c349192ad9c9?Id=6f5e8011-bd12-477d-9e82-a7a1ce836773")); + AssertUI.Attribute(links[2], "href", u => u.EndsWith("/FeatureSamples/CustomPrimitiveTypes/Basic/d7682de1-b985-4b4b-b2bf-c349192ad9c9?Id=6f5e8011-bd12-477d-9e82-a7a1ce836773")); + AssertUI.Attribute(links[3], "href", u => u.EndsWith("/FeatureSamples/CustomPrimitiveTypes/Basic/d7682de1-b985-4b4b-b2bf-c349192ad9c9?Id=6f5e8011-bd12-477d-9e82-a7a1ce836773")); + + browser.Single("input[type=button]").Click(); + + AssertUI.Attribute(links[0], "href", u => u.EndsWith("/FeatureSamples/CustomPrimitiveTypes/Basic/6f5e8011-bd12-477d-9e82-a7a1ce836773?Null=d7682de1-b985-4b4b-b2bf-c349192ad9c9")); + AssertUI.Attribute(links[2], "href", u => u.EndsWith("/FeatureSamples/CustomPrimitiveTypes/Basic/6f5e8011-bd12-477d-9e82-a7a1ce836773?Null=d7682de1-b985-4b4b-b2bf-c349192ad9c9")); + }); + } + + + [Fact] + public void Feature_CustomPrimitiveTypes_TextBox() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_CustomPrimitiveTypes_TextBox); + + var textboxes = browser.FindElements("input[type=text]").ThrowIfDifferentCountThan(2); + + AssertUI.Value(textboxes[0], "15,32"); + AssertUI.Value(textboxes[1], "0,0"); + + textboxes[0].Clear().SendKeys("1,2"); + browser.Single("input[type=button]").Click(); + + AssertUI.Value(textboxes[0], "1,2"); + + var items = browser.FindElements(".results li").ThrowIfDifferentCountThan(4); + AssertUI.TextEquals(items[0], "Point X: 1"); + AssertUI.TextEquals(items[1], "Point Y: 2"); + AssertUI.TextEquals(items[2], "Null X: 0"); + AssertUI.TextEquals(items[3], "Null Y: 0"); + + textboxes[1].Clear().SendKeys("xxx"); + browser.Single("input[type=button]").Click(); + + browser.FindElements(".validation li").ThrowIfSequenceEmpty(); + }); + } + + [Fact] + public void Feature_CustomPrimitiveTypes_UsedInControls() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_CustomPrimitiveTypes_UsedInControls); + + var items = browser.FindElements("li").ThrowIfDifferentCountThan(7); + AssertUI.TextEquals(items[0], "12,13"); + AssertUI.TextEquals(items[1], "1,2"); + AssertUI.TextEquals(items[2], "1,2"); + AssertUI.TextEquals(items[3], "1,34"); + AssertUI.TextEquals(items[4], "1,2"); + AssertUI.TextEquals(items[5], "1,2"); + AssertUI.TextEquals(items[6], "12,3"); + + var ul = browser.Single("ul"); + AssertUI.Attribute(ul, "data-value", "1,2"); + AssertUI.Attribute(ul, "data-resource", "1,2"); + + AssertUI.TextEquals(browser.Single(".tostring"), "1,2"); + AssertUI.TextEquals(browser.Single(".implicit-tostring"), "1,2"); + }); + } + } +} diff --git a/src/Tests/Binding/BindingCompilationTests.cs b/src/Tests/Binding/BindingCompilationTests.cs index d607114948..48c3bc9e0d 100755 --- a/src/Tests/Binding/BindingCompilationTests.cs +++ b/src/Tests/Binding/BindingCompilationTests.cs @@ -22,6 +22,7 @@ using System.Runtime.Serialization; using CheckTestOutput; using DotVVM.Framework.Tests.Runtime; +using System.ComponentModel.DataAnnotations; namespace DotVVM.Framework.Tests.Binding { @@ -1199,6 +1200,8 @@ class TestViewModel public uint UIntProp { get; set; } = 3_000_000_000; public double? NullableDoubleProp { get; set; } + public VehicleNumber? VehicleNumber { get; set; } + public ReadOnlyCollection ReadOnlyCollection => new ReadOnlyCollection(new[] { 1, 2, 3 }); public string SetStringProp(string a, int b) @@ -1258,6 +1261,29 @@ public async Task GetStringPropAsync() public int MethodWithOverloads(int a, int b) => a + b; } + + record struct VehicleNumber( + [property: Range(100, 999)] + int Value + ): IDotvvmPrimitiveType + { + public override string ToString() => Value.ToString(); + public static bool TryParse(string s, out VehicleNumber result) + { + if (int.TryParse(s, out var i)) + { + result = new VehicleNumber(i); + return true; + } + else + { + result = default!; + return false; + } + } + public static VehicleNumber Parse(string s) => new VehicleNumber(int.Parse(s)); + } + class TestLambdaCompilation { public string StringProp { get; set; } diff --git a/src/Tests/Binding/JavascriptCompilationTests.cs b/src/Tests/Binding/JavascriptCompilationTests.cs index 193b4103d3..81de562a92 100644 --- a/src/Tests/Binding/JavascriptCompilationTests.cs +++ b/src/Tests/Binding/JavascriptCompilationTests.cs @@ -1277,6 +1277,20 @@ public void JavascriptCompilation_StringFunctions(string input, string expected) var result = CompileBinding(input, new[] { new NamespaceImport("DotVVM.Framework.Binding.HelperNamespace") }, typeof(TestViewModel)); Assert.AreEqual(expected, result); } + + [TestMethod] + public void JavascriptCompilation_CustomPrimitiveToString() + { + var result = CompileBinding("VehicleNumber.ToString()", typeof(TestViewModel)); + Assert.AreEqual("VehicleNumber", result); + } + + [TestMethod] + public void JavascriptCompilation_CustomPrimitiveParse() + { + var result = CompileBinding("VehicleNumber == DotVVM.Framework.Tests.Binding.VehicleNumber.Parse('123')", typeof(TestViewModel)); + Assert.AreEqual("VehicleNumber()==\"123\"", result); + } } public class TestExtensionParameterConflictViewModel @@ -1321,4 +1335,5 @@ public enum TestEnum public TestEnum Enum { get; set; } public string String { get; set; } } + } diff --git a/src/Tests/Binding/StaticCommandExecutorTests.cs b/src/Tests/Binding/StaticCommandExecutorTests.cs index db7a158e6f..754c02f595 100644 --- a/src/Tests/Binding/StaticCommandExecutorTests.cs +++ b/src/Tests/Binding/StaticCommandExecutorTests.cs @@ -43,7 +43,7 @@ StaticCommandInvocationPlan CreatePlan(Expression methodExpr) async Task Invoke(StaticCommandInvocationPlan plan, params (object value, string path)[] arguments) { var context = DotvvmTestHelper.CreateContext(config, requestType: DotvvmRequestType.StaticCommand); - var a = arguments.Select(t => JToken.FromObject(t.value)); + var a = arguments.Select(t => JToken.FromObject(t.value, DefaultSerializerSettingsProvider.CreateJsonSerializer())); var p = arguments.Select(t => t.path); return await executor.Execute(plan, a, p, context); } @@ -226,5 +226,26 @@ internal static void AutomaticValidation(ViewModelInstance viewModel, [RegularEx ms.AddArgumentError(() => viewModel.Property, "manual-error"); ms.FailOnInvalidModelState(); } + + [TestMethod] + public async Task Validation_CustomPrimitives() + { + var vm = new TestViewModel { VehicleNumber = new(1) }; + var plan = CreatePlan(() => CustomPrimitivesValidation(vm, new VehicleNumber(1))); + var modelState = await InvokeExpectingErrors(plan, (vm, "/VM"), (new VehicleNumber(321), "/Argument")); + + Assert.AreEqual(2, modelState.Errors.Count, $"Unexpected errors: {string.Join(", ", modelState.Errors)}"); + Assert.IsTrue(modelState.Errors[0].IsResolved); + Assert.IsTrue(modelState.Errors[1].IsResolved); + Assert.AreEqual("The field Value must be between 100 and 999.", modelState.Errors[0].ErrorMessage); + Assert.AreEqual("Vehicle must have lucky number.", modelState.Errors[1].ErrorMessage); + Assert.AreEqual("/VM/VehicleNumber", modelState.Errors[0].PropertyPath); + Assert.AreEqual("/Argument", modelState.Errors[1].PropertyPath); + } + + [AllowStaticCommand(StaticCommandValidation.Automatic)] + internal static void CustomPrimitivesValidation(TestViewModel viewModel, [RegularExpression(@"\d\d7", ErrorMessage = "Vehicle must have lucky number.")] VehicleNumber vehicle) + { + } } }