diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 054358ef42..65304b74ee 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,6 +4,7 @@ on: push: branches: - 'main' + - 'main-*' - 'release/**' pull_request: workflow_dispatch: diff --git a/src/Framework/Core/Storage/UploadedFile.cs b/src/Framework/Core/Storage/UploadedFile.cs index 962dfc4e40..f28077f37c 100644 --- a/src/Framework/Core/Storage/UploadedFile.cs +++ b/src/Framework/Core/Storage/UploadedFile.cs @@ -4,16 +4,22 @@ namespace DotVVM.Core.Storage { public class UploadedFile { + /// A unique, randomly generated ID of the uploaded file. Use this ID to get the file from public Guid FileId { get; set; } + /// A user-specified name of the file. Use with caution, the user may specify this to be any string (for example ../../Web.config). public string? FileName { get; set; } + /// Length of the file in bytes. Use with caution, the user may manipulate with this property and it might not correspond to the file returned from . public FileSize FileSize { get; set; } = new FileSize(); + /// If the file type matched one of type MIME types or extensions in FileUpload.AllowedFileTypes. Use with caution, the user may manipulate with this property. public bool IsFileTypeAllowed { get; set; } = true; + /// If the file size is larger that the limit specified in FileUpload.MaxFileSize. Use with caution, the user may manipulate with this property. public bool IsMaxSizeExceeded { get; set; } = false; + /// If the file satisfies both allowed file types and the size limit. Use with caution, the user may manipulate with this property. public bool IsAllowed => IsFileTypeAllowed && !IsMaxSizeExceeded; } diff --git a/src/Framework/Framework/Binding/BindingHelper.cs b/src/Framework/Framework/Binding/BindingHelper.cs index f319609f2b..9e851eb2e2 100644 --- a/src/Framework/Framework/Binding/BindingHelper.cs +++ b/src/Framework/Framework/Binding/BindingHelper.cs @@ -64,11 +64,11 @@ public static string FormatKnockoutScript(this ParametrizedCode code, DotvvmBind /// Gets Internal.PathFragmentProperty or DataContext.KnockoutExpression. Returns null if none of these is set. /// public static string? GetDataContextPathFragment(this DotvvmBindableObject currentControl) => - (string?)currentControl.GetValue(Internal.PathFragmentProperty, inherit: false) ?? - (currentControl.GetBinding(DotvvmBindableObject.DataContextProperty, inherit: false) is IValueBinding binding ? + currentControl.properties.TryGet(Internal.PathFragmentProperty, out var pathFragment) && pathFragment is string pathFragmentStr ? pathFragmentStr : + currentControl.properties.TryGet(DotvvmBindableObject.DataContextProperty, out var dataContext) && dataContext is IValueBinding binding ? binding.GetProperty() .Code.FormatKnockoutScript(currentControl, binding) : - null); + null; // PERF: maybe safe last GetValue's target/binding to ThreadLocal variable, so the path does not have to be traversed twice diff --git a/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs b/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs index cc11591f26..be11c6556a 100644 --- a/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs +++ b/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs @@ -60,6 +60,50 @@ public ValueBindingExpression CreateValueBinding(string code, })); } + /// Compiles a new `{resource: ...code...}` binding which can be evaluated server-side. The result is cached. + public ResourceBindingExpression CreateResourceBinding(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) + { + return CreateCachedBinding("ResourceBinding:" + code, new object?[] { dataContext, parserOptions }, () => + new ResourceBindingExpression(compilationService, new object?[] { + dataContext, + new OriginalStringBindingProperty(code), + parserOptions + })); + } + + /// Compiles a new `{resource: ...code...}` binding which can be evaluated server-side. The result is implicitly converted to . The result is cached. + public ResourceBindingExpression CreateResourceBinding(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) + { + return CreateCachedBinding($"ResourceBinding<{typeof(TResult).ToCode()}>:{code}", new object?[] { dataContext, parserOptions }, () => + new ResourceBindingExpression(compilationService, new object?[] { + dataContext, + new OriginalStringBindingProperty(code), + parserOptions + })); + } + + /// Compiles a new `{command: ...code...}` binding which can be evaluated server-side and also client-side. The result is cached. Note that command bindings might be easier to create using the constructor. + public CommandBindingExpression CreateCommand(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) + { + return CreateCachedBinding($"Command:{code}", new object?[] { dataContext, parserOptions }, () => + new CommandBindingExpression(compilationService, new object?[] { + dataContext, + new OriginalStringBindingProperty(code), + parserOptions + })); + } + + /// Compiles a new `{command: ...code...}` binding which can be evaluated server-side and also client-side. The result is implicitly converted to . The result is cached. Note that command bindings might be easier to create using the constructor. + public CommandBindingExpression CreateCommand(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) + { + return CreateCachedBinding($"Command<{typeof(TResult).ToCode()}>:{code}", new object?[] { dataContext, parserOptions }, () => + new CommandBindingExpression(compilationService, new object?[] { + dataContext, + new OriginalStringBindingProperty(code), + parserOptions + })); + } + /// Compiles a new `{staticCommand: ...code...}` binding which can be evaluated server-side and also client-side. The result is cached. public StaticCommandBindingExpression CreateStaticCommand(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) { diff --git a/src/Framework/Framework/Binding/DotvvmProperty.cs b/src/Framework/Framework/Binding/DotvvmProperty.cs index 8eb7261768..aaa21563e1 100644 --- a/src/Framework/Framework/Binding/DotvvmProperty.cs +++ b/src/Framework/Framework/Binding/DotvvmProperty.cs @@ -115,7 +115,11 @@ internal void AddUsedInCapability(DotvvmCapabilityProperty? p) if (p is object) lock(this) { - UsedInCapabilities = UsedInCapabilities.Add(p); + if (UsedInCapabilities.Contains(p)) return; + + var newArray = UsedInCapabilities.Add(p); + Thread.MemoryBarrier(); // make sure the array is complete before we let other threads use it lock-free + UsedInCapabilities = newArray; } } diff --git a/src/Framework/Framework/Binding/Expressions/BindingExpression.cs b/src/Framework/Framework/Binding/Expressions/BindingExpression.cs index c84e512c8b..3c38a0229c 100644 --- a/src/Framework/Framework/Binding/Expressions/BindingExpression.cs +++ b/src/Framework/Framework/Binding/Expressions/BindingExpression.cs @@ -260,7 +260,7 @@ protected void AddNullResolvers() - string? toStringValue; + volatile string? toStringValue; public override string ToString() { if (toStringValue is null) diff --git a/src/Framework/Framework/Binding/Expressions/CommandBindingExpression.cs b/src/Framework/Framework/Binding/Expressions/CommandBindingExpression.cs index ee99725ee8..d1b26970f6 100644 --- a/src/Framework/Framework/Binding/Expressions/CommandBindingExpression.cs +++ b/src/Framework/Framework/Binding/Expressions/CommandBindingExpression.cs @@ -30,7 +30,7 @@ namespace DotVVM.Framework.Binding.Expressions [Options] public class CommandBindingExpression : BindingExpression, ICommandBinding { - public CommandBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) + public CommandBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { AddNullResolvers(); } @@ -172,6 +172,6 @@ public CommandBindingExpression(BindingCompilationService service, BindingDelega public class CommandBindingExpression : CommandBindingExpression, ICommandBinding { public new BindingDelegate BindingDelegate => base.BindingDelegate.ToGeneric(); - public CommandBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } + public CommandBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } } } diff --git a/src/Framework/Framework/Binding/Expressions/ResourceBindingExpression.cs b/src/Framework/Framework/Binding/Expressions/ResourceBindingExpression.cs index 8273dffd56..fb5da85b8b 100644 --- a/src/Framework/Framework/Binding/Expressions/ResourceBindingExpression.cs +++ b/src/Framework/Framework/Binding/Expressions/ResourceBindingExpression.cs @@ -18,7 +18,7 @@ namespace DotVVM.Framework.Binding.Expressions [Options] public class ResourceBindingExpression : BindingExpression, IStaticValueBinding { - public ResourceBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } + public ResourceBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } public BindingDelegate BindingDelegate => this.bindingDelegate.GetValueOrThrow(this); @@ -34,7 +34,7 @@ public class OptionsAttribute : BindingCompilationOptionsAttribute public class ResourceBindingExpression : ResourceBindingExpression, IStaticValueBinding { - public ResourceBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } + public ResourceBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } public new BindingDelegate BindingDelegate => base.BindingDelegate.ToGeneric(); } diff --git a/src/Framework/Framework/Compilation/ControlTree/DotvvmPropertyGroup.cs b/src/Framework/Framework/Compilation/ControlTree/DotvvmPropertyGroup.cs index 2fa03d4137..96c84f10cb 100644 --- a/src/Framework/Framework/Compilation/ControlTree/DotvvmPropertyGroup.cs +++ b/src/Framework/Framework/Compilation/ControlTree/DotvvmPropertyGroup.cs @@ -9,6 +9,7 @@ using DotVVM.Framework.Utils; using System.Runtime.CompilerServices; using System.Collections.Immutable; +using System.Threading; namespace DotVVM.Framework.Compilation.ControlTree { @@ -81,8 +82,11 @@ internal void AddUsedInCapability(DotvvmCapabilityProperty? p) if (p is object) lock(this) { - if (!UsedInCapabilities.Contains(p)) - UsedInCapabilities = UsedInCapabilities.Add(p); + if (UsedInCapabilities.Contains(p)) return; + + var newArray = UsedInCapabilities.Add(p); + Thread.MemoryBarrier(); + UsedInCapabilities = newArray; } } diff --git a/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs b/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs index 072186838f..6d633f2c8a 100644 --- a/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs +++ b/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs @@ -11,6 +11,7 @@ using DotVVM.Framework.Utils; using DotVVM.Framework.Binding.Properties; using DotVVM.Framework.Testing; +using Microsoft.Extensions.Logging; namespace DotVVM.Framework.Compilation { @@ -19,14 +20,16 @@ public class DotvvmViewCompilationService : IDotvvmViewCompilationService private readonly IControlBuilderFactory controlBuilderFactory; private readonly CompilationTracer tracer; private readonly IMarkupFileLoader markupFileLoader; + private readonly ILogger? log; private readonly DotvvmConfiguration dotvvmConfiguration; - public DotvvmViewCompilationService(DotvvmConfiguration dotvvmConfiguration, IControlBuilderFactory controlBuilderFactory, CompilationTracer tracer, IMarkupFileLoader markupFileLoader) + public DotvvmViewCompilationService(DotvvmConfiguration dotvvmConfiguration, IControlBuilderFactory controlBuilderFactory, CompilationTracer tracer, IMarkupFileLoader markupFileLoader, ILogger? log = null) { this.dotvvmConfiguration = dotvvmConfiguration; this.controlBuilderFactory = controlBuilderFactory; this.tracer = tracer; this.markupFileLoader = markupFileLoader; + this.log = log; masterPages = new Lazy>(InitMasterPagesCollection); controls = new Lazy>(InitControls); routes = new Lazy>(InitRoutes); @@ -108,7 +111,13 @@ public async Task CompileAll(bool buildInParallel = true, bool forceRecomp } } var discoveredMasterPages = new ConcurrentDictionary(); - + var maxParallelism = buildInParallel ? Environment.ProcessorCount : 1; + if (!dotvvmConfiguration.Debug && dotvvmConfiguration.Markup.ViewCompilation.Mode != ViewCompilationMode.DuringApplicationStart) + { + // in production when compiling after application start, only use half of the CPUs to leave room for handling requests + maxParallelism = (int)Math.Ceiling(maxParallelism * 0.5); + } + var sw = ValueStopwatch.StartNew(); var compilationTaskFactory = (DotHtmlFileInfo t) => () => { BuildView(t, forceRecompile, out var masterPage); @@ -117,15 +126,19 @@ public async Task CompileAll(bool buildInParallel = true, bool forceRecomp }; var compileTasks = filesToCompile.Select(compilationTaskFactory).ToArray(); - await ExecuteCompileTasks(compileTasks, buildInParallel); + var totalCompiledFiles = compileTasks.Length; + await ExecuteCompileTasks(compileTasks, maxParallelism); while (discoveredMasterPages.Any()) { compileTasks = discoveredMasterPages.Values.Select(compilationTaskFactory).ToArray(); + totalCompiledFiles += compileTasks.Length; discoveredMasterPages = new ConcurrentDictionary(); - await ExecuteCompileTasks(compileTasks, buildInParallel); + await ExecuteCompileTasks(compileTasks, maxParallelism); } + + log?.LogInformation("Compiled {0} DotHTML files on {1} threads in {2} s", totalCompiledFiles, maxParallelism, sw.ElapsedSeconds); } finally { @@ -135,11 +148,22 @@ public async Task CompileAll(bool buildInParallel = true, bool forceRecomp return !GetFilesWithFailedCompilation().Any(); } - private async Task ExecuteCompileTasks(Action[] compileTasks, bool buildInParallel) + private static async Task ExecuteCompileTasks(Action[] compileTasks, int maxParallelism) { - if (buildInParallel) + if (maxParallelism > 1) { - await Task.WhenAll(compileTasks.Select(Task.Run)); + var semaphore = new SemaphoreSlim(maxParallelism); + await Task.WhenAll(compileTasks.Select(async t => { + await semaphore.WaitAsync(); + try + { + await Task.Run(t); + } + finally + { + semaphore.Release(); + } + })); } else { diff --git a/src/Framework/Framework/Compilation/ExtensionMethodsCache.cs b/src/Framework/Framework/Compilation/ExtensionMethodsCache.cs index f40859f1da..282304f212 100644 --- a/src/Framework/Framework/Compilation/ExtensionMethodsCache.cs +++ b/src/Framework/Framework/Compilation/ExtensionMethodsCache.cs @@ -38,7 +38,6 @@ public IEnumerable GetExtensionsForNamespaces(string[] @namespaces) // it's most likely the same namespaces, so it won't help at all - only run into lock contention in System.Reflection lock (methodsCache) { - results = namespaces.Select(x => methodsCache.GetValueOrDefault(x)).ToArray(); var missingNamespaces = namespaces.Where(x => !methodsCache.ContainsKey(x)).ToArray(); var createdNamespaces = CreateExtensionsForNamespaces(missingNamespaces); diff --git a/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs b/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs index b1f9593e3f..11bdce52b8 100644 --- a/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs +++ b/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs @@ -19,6 +19,9 @@ public static JsExpression Invoke(this JsExpression target, IEnumerable new JsInvocationExpression(target, arguments); + public static JsExpression CallMethod(this JsExpression target, string methodName, params JsExpression?[] arguments) => + target.Member(methodName).Invoke(arguments); + public static JsExpression Indexer(this JsExpression target, JsExpression argument) { return new JsIndexerExpression(target, argument); diff --git a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs index c9516a745f..31a322f066 100644 --- a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs +++ b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs @@ -244,6 +244,8 @@ JsExpression dictionarySetIndexer(JsExpression[] args, MethodInfo method) => AddDefaultListTranslations(); AddDefaultMathTranslations(); AddDefaultDateTimeTranslations(); + AddDefaultDateOnlyTranslations(); + AddDefaultTimeOnlyTranslations(); AddDefaultConvertTranslations(); AddDataSetOptionsTranslations(); } @@ -783,10 +785,53 @@ JsExpression IncrementExpression(JsExpression left, int value) AddPropertyTranslator(() => DateTime.Now.Millisecond, new GenericMethodCompiler(args => new JsInvocationExpression(new JsIdentifierExpression("dotvvm").Member("serialization").Member("parseDate"), args[0]).Member("getMilliseconds").Invoke())); + AddPropertyTranslator(() => DateTime.Now, new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("serialization").CallMethod("serializeDate", new JsNewExpression("Date"), new JsLiteral(false)))); + AddPropertyTranslator(() => DateTime.UtcNow, new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("serialization").CallMethod("serializeDate", new JsNewExpression("Date"), new JsLiteral(true)))); + AddPropertyTranslator(() => DateTime.Today, new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("serialization").CallMethod("serializeDate", new JsNewExpression("Date"), new JsLiteral(false)) + .CallMethod("substring", new JsLiteral(0), new JsLiteral("0000-00-00".Length)) + .Binary(BinaryOperatorType.Plus, new JsLiteral("T00:00:00.000")))); + + AddMethodTranslator(() => DateTime.UtcNow.ToBrowserLocalTime(), new GenericMethodCompiler(args => new JsIdentifierExpression("dotvvm").Member("translations").Member("dateTime").Member("toBrowserLocalTime").Invoke(args[1].WithAnnotation(ShouldBeObservableAnnotation.Instance)).WithAnnotation(ResultIsObservableAnnotation.Instance))); AddMethodTranslator(() => default(Nullable).ToBrowserLocalTime(), new GenericMethodCompiler(args => new JsIdentifierExpression("dotvvm").Member("translations").Member("dateTime").Member("toBrowserLocalTime").Invoke(args[1].WithAnnotation(ShouldBeObservableAnnotation.Instance)).WithAnnotation(ResultIsObservableAnnotation.Instance))); + + } + + private void AddDefaultDateOnlyTranslations() + { + JsExpression parse(JsExpression arg) => + new JsIdentifierExpression("dotvvm").Member("serialization").CallMethod("parseDateOnly", arg); + AddPropertyTranslator(() => DateOnly.MinValue.Year, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getFullYear"))); + AddPropertyTranslator(() => DateOnly.MinValue.Month, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getMonth").Binary(BinaryOperatorType.Plus, new JsLiteral(1)))); + AddPropertyTranslator(() => DateOnly.MinValue.Day, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getDate"))); + + AddMethodTranslator(() => DateOnly.FromDateTime(DateTime.Now), new GenericMethodCompiler(args => + args[1].CallMethod("substring", new JsLiteral(0), new JsLiteral("0000-00-00".Length)))); + } + + private void AddDefaultTimeOnlyTranslations() + { + JsExpression parse(JsExpression arg) => + new JsIdentifierExpression("dotvvm").Member("serialization").CallMethod("parseTimeOnly", arg); + AddPropertyTranslator(() => TimeOnly.MinValue.Hour, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getHours"))); + AddPropertyTranslator(() => TimeOnly.MinValue.Minute, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getMinutes"))); + AddPropertyTranslator(() => TimeOnly.MinValue.Second, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getSeconds"))); + AddPropertyTranslator(() => TimeOnly.MinValue.Millisecond, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getMilliseconds"))); + + AddMethodTranslator(() => TimeOnly.FromDateTime(DateTime.Now), new GenericMethodCompiler(args => + args[1].CallMethod("substring", new JsLiteral("0000-00-00T".Length)))); } private void AddDefaultConvertTranslations() diff --git a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs index 83957e3fcf..cfbbac5ca8 100644 --- a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs +++ b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -146,12 +146,15 @@ public JsExpression CompileToJavascript(Expression binding, DataContextStack dat public static ParametrizedCode AdjustKnockoutScriptContext(ParametrizedCode expression, int dataContextLevel) { if (dataContextLevel == 0) return expression; - return expression.AssignParameters(o => - o is ViewModelSymbolicParameter vm ? GetKnockoutViewModelParameter(vm.ParentIndex + dataContextLevel).ToParametrizedCode() : - o is ContextSymbolicParameter context ? GetKnockoutContextParameter(context.ParentIndex + dataContextLevel).ToParametrizedCode() : - o == CommandBindingExpression.OptionalKnockoutContextParameter ? GetKnockoutContextParameter(dataContextLevel).ToParametrizedCode() : - default - ); + + // separate method to avoid closure allocation if dataContextLevel == 0 + return shift(expression, dataContextLevel); + static ParametrizedCode shift(ParametrizedCode expression, int dataContextLevel) => + expression.AssignParameters(o => + o is ViewModelSymbolicParameter vm ? GetKnockoutViewModelParameter(vm.ParentIndex + dataContextLevel, vm.ReturnObservable).ToParametrizedCode() : + o is ContextSymbolicParameter context ? GetKnockoutContextParameter(context.ParentIndex + dataContextLevel).ToParametrizedCode() : + o == CommandBindingExpression.OptionalKnockoutContextParameter ? GetKnockoutContextParameter(dataContextLevel).ToParametrizedCode() : + default); } /// @@ -164,14 +167,42 @@ public static string FormatKnockoutScript(JsExpression expression, bool allowDat /// public static string FormatKnockoutScript(ParametrizedCode expression, bool allowDataGlobal = true, int dataContextLevel = 0) { - // TODO(exyi): more symbolic parameters - var adjusted = AdjustKnockoutScriptContext(expression, dataContextLevel); - if (allowDataGlobal) - return adjusted.ToDefaultString(); - else - return adjusted.ToString(o => - o == KnockoutViewModelParameter ? CodeParameterAssignment.FromIdentifier("$data") : - default); + if (dataContextLevel == 0) + { + if (allowDataGlobal) + return expression.ToDefaultString(); + else + return expression.ToString(static o => + o == KnockoutViewModelParameter ? CodeParameterAssignment.FromIdentifier("$data") : + default); + + } + + // separate method to avoid closure allocation if dataContextLevel == 0 + return shiftToString(expression, dataContextLevel); + + static string shiftToString(ParametrizedCode expression, int dataContextLevel) => + expression.ToString(o => { + if (o is ViewModelSymbolicParameter vm) + { + var p = GetKnockoutViewModelParameter(vm.ParentIndex + dataContextLevel, vm.ReturnObservable).DefaultAssignment; + return new(p.Code!.ToDefaultString(), p.Code.OperatorPrecedence); + } + else if (o is ContextSymbolicParameter context) + { + var p = GetKnockoutContextParameter(context.ParentIndex + dataContextLevel).DefaultAssignment; + return new(p.Code!.ToDefaultString(), p.Code.OperatorPrecedence); + } + else if (o == CommandBindingExpression.OptionalKnockoutContextParameter) + { + var p = GetKnockoutContextParameter(dataContextLevel).DefaultAssignment; + return new(p.Code!.ToDefaultString(), p.Code.OperatorPrecedence); + } + else + { + return default; + } + }); } /// diff --git a/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs b/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs index 95a0941035..cfc4b51940 100644 --- a/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs +++ b/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using System.Text; @@ -64,26 +64,31 @@ public string ToString(Func para if (allIsDefault && this.evaluatedDefault != null) return evaluatedDefault; - var sb = new StringBuilder(codes.Sum((p) => p.code.Length) + stringParts.Sum(p => p.Length)); + var capacity = 0; + foreach (var c in codes) capacity += c.code.Length; + foreach (var s in stringParts) capacity += s.Length; + var sb = new StringBuilder(capacity); + sb.Append(stringParts[0]); for (int i = 0; i < codes.Length;) { - var isGlobalContext = codes[i].parameter.IsGlobalContext && parameters![i].IsSafeMemberAccess; - var needsParens = codes[i].parameter.Code!.OperatorPrecedence.NeedsParens(parameters![i].OperatorPrecedence); + var code = codes[i]; + var isGlobalContext = code.parameter.IsGlobalContext && parameters![i].IsSafeMemberAccess; + var needsParens = code.parameter.Code!.OperatorPrecedence.NeedsParens(parameters![i].OperatorPrecedence); if (isGlobalContext) - sb.Append(stringParts[++i], 1, stringParts[i].Length - 1); // skip `.` + sb.Append(stringParts[++i], startIndex: 1, count: stringParts[i].Length - 1); // skip `.` else { if (needsParens) - sb.Append("("); - else if (JsFormattingVisitor.NeedSpaceBetween(sb, codes[i].code)) - sb.Append(" "); - sb.Append(codes[i].code); + sb.Append('('); + else if (JsFormattingVisitor.NeedSpaceBetween(sb, code.code)) + sb.Append(' '); + sb.Append(code.code); i++; - if (needsParens) sb.Append(")"); + if (needsParens) sb.Append(')'); else if (JsFormattingVisitor.NeedSpaceBetween(sb, stringParts[i])) - sb.Append(" "); + sb.Append(' '); sb.Append(stringParts[i]); } } @@ -177,7 +182,7 @@ public void CopyTo(Builder builder) builder.Add(stringParts[i]); builder.Add(parameters[i]); } - builder.Add(stringParts.Last()); + builder.Add(stringParts[stringParts.Length - 1]); } } diff --git a/src/Framework/Framework/Compilation/Parser/Dothtml/Tokenizer/DothtmlTokenizer.cs b/src/Framework/Framework/Compilation/Parser/Dothtml/Tokenizer/DothtmlTokenizer.cs index 1f44e55897..6b3fe1f0e9 100644 --- a/src/Framework/Framework/Compilation/Parser/Dothtml/Tokenizer/DothtmlTokenizer.cs +++ b/src/Framework/Framework/Compilation/Parser/Dothtml/Tokenizer/DothtmlTokenizer.cs @@ -227,7 +227,14 @@ private ReadElementType ReadElement(bool wasOpenBraceRead = false) if (!char.IsLetterOrDigit(firstChar) & firstChar != '/' & firstChar != ':') { - CreateToken(DothtmlTokenType.Text, errorProvider: t => CreateTokenError(t, "'<' char is not allowed in normal text")); + if (char.IsWhiteSpace(firstChar)) + { + CreateToken(DothtmlTokenType.Text); + } + else + { + CreateToken(DothtmlTokenType.Text, errorProvider: t => CreateTokenError(t, "'<' char is not allowed in normal text")); + } return ReadElementType.Error; } diff --git a/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs b/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs index 24ac760cf6..e927a514d6 100644 --- a/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs +++ b/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs @@ -20,6 +20,7 @@ using DotVVM.Framework.Compilation.ViewCompiler; using DotVVM.Framework.Runtime; using FastExpressionCompiler; +using System.Threading; namespace DotVVM.Framework.Compilation.Styles { @@ -433,6 +434,7 @@ void InitializeChildren(IDotvvmRequestContext? context) var services = context?.Services ?? this.services; Children.Add((DotvvmControl)ResolvedControl.ToRuntimeControl(services)); + Thread.MemoryBarrier(); // make sure write to Children is done before we let other threads read without lock initialized = true; } } diff --git a/src/Framework/Framework/Controls/AlternateCultureLinks.cs b/src/Framework/Framework/Controls/AlternateCultureLinks.cs new file mode 100644 index 0000000000..167a78769b --- /dev/null +++ b/src/Framework/Framework/Controls/AlternateCultureLinks.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using DotVVM.Framework.Configuration; +using DotVVM.Framework.Hosting; +using DotVVM.Framework.Routing; + +namespace DotVVM.Framework.Controls +{ + /// + /// Renders a <link rel=alternate element for each localized route equivalent to the current route. + /// On non-localized routes, it renders nothing (the control is therefore safe to use in a master page). + /// The href must be an absolute URL, so it will only work correctly if Context.Request.Url contains the corrent domain. + /// + /// + /// + /// + public class AlternateCultureLinks : CompositeControl + { + /// The name of the route to generate alternate links for. If not set, the current route is used. + public IEnumerable GetContents(IDotvvmRequestContext context, string? routeName = null) + { + var route = routeName != null ? context.Configuration.RouteTable[routeName] : context.Route; + if (route is LocalizedDotvvmRoute localizedRoute) + { + var currentCultureRoute = localizedRoute.GetRouteForCulture(CultureInfo.CurrentUICulture); + + foreach (var alternateCultureRoute in localizedRoute.GetAllCultureRoutes()) + { + if (alternateCultureRoute.Value == currentCultureRoute) continue; + + var languageCode = alternateCultureRoute.Key == "" ? "x-default" : alternateCultureRoute.Key.ToLowerInvariant(); + var alternateUrl = context.TranslateVirtualPath(alternateCultureRoute.Value.BuildUrl(context.Parameters!)); + var absoluteAlternateUrl = BuildAbsoluteAlternateUrl(context, alternateUrl); + + yield return new HtmlGenericControl("link") + .SetAttribute("rel", "alternate") + .SetAttribute("hreflang", languageCode) + .SetAttribute("href", absoluteAlternateUrl); + } + + } + } + + protected virtual string BuildAbsoluteAlternateUrl(IDotvvmRequestContext context, string alternateUrl) + { + return new Uri(context.HttpContext.Request.Url, alternateUrl).AbsoluteUri; + } + } +} diff --git a/src/Framework/Framework/Controls/DotvvmBindableObject.cs b/src/Framework/Framework/Controls/DotvvmBindableObject.cs index cb45b1d0a6..cfe65eee3e 100644 --- a/src/Framework/Framework/Controls/DotvvmBindableObject.cs +++ b/src/Framework/Framework/Controls/DotvvmBindableObject.cs @@ -274,14 +274,27 @@ internal IEnumerable GetDataContextHierarchy() /// /// Gets the closest control binding target. Returns null if the control is not found. /// - public DotvvmBindableObject? GetClosestControlBindingTarget() => - GetClosestControlBindingTarget(out int numberOfDataContextChanges); + public DotvvmBindableObject? GetClosestControlBindingTarget() + { + var c = this; + while (c != null) + { + if (c.properties.TryGet(Internal.IsControlBindingTargetProperty, out var x) && (bool)x!) + { + return c; + } + c = c.Parent; + } + return null; + } /// /// Gets the closest control binding target and returns number of DataContext changes since the target. Returns null if the control is not found. /// public DotvvmBindableObject? GetClosestControlBindingTarget(out int numberOfDataContextChanges) => - (Parent ?? this).GetClosestWithPropertyValue(out numberOfDataContextChanges, (control, _) => (bool)control.GetValue(Internal.IsControlBindingTargetProperty)!); + (Parent ?? this).GetClosestWithPropertyValue( + out numberOfDataContextChanges, + (control, _) => control.properties.TryGet(Internal.IsControlBindingTargetProperty, out var x) && (bool)x!); /// /// Gets the closest control binding target and returns number of DataContext changes since the target. Returns null if the control is not found. @@ -403,24 +416,35 @@ public virtual IEnumerable GetLogicalChildren() /// The that holds the value of the /// The to which will be copied /// Determines whether to throw an exception if copying fails - protected void CopyProperty(DotvvmProperty sourceProperty, DotvvmBindableObject target, DotvvmProperty targetProperty, bool throwOnFailure = false) + protected internal void CopyProperty(DotvvmProperty sourceProperty, DotvvmBindableObject target, DotvvmProperty targetProperty, bool throwOnFailure = false) { - if (throwOnFailure && !targetProperty.MarkupOptions.AllowBinding && !targetProperty.MarkupOptions.AllowHardCodedValue) + var targetOptions = targetProperty.MarkupOptions; + if (throwOnFailure && !targetOptions.AllowBinding && !targetOptions.AllowHardCodedValue) { throw new DotvvmControlException(this, $"TargetProperty: {targetProperty.FullName} doesn't allow bindings nor hard coded values"); } - if (targetProperty.MarkupOptions.AllowBinding && HasBinding(sourceProperty)) - { - target.SetBinding(targetProperty, GetBinding(sourceProperty)); - } - else if (targetProperty.MarkupOptions.AllowHardCodedValue && IsPropertySet(sourceProperty)) + if (IsPropertySet(sourceProperty)) { - target.SetValue(targetProperty, GetValue(sourceProperty)); + var sourceValue = GetValueRaw(sourceProperty); + if ((targetOptions.AllowBinding || sourceValue is not IBinding) && + (targetOptions.AllowHardCodedValue || sourceValue is IBinding)) + { + target.SetValueRaw(targetProperty, sourceValue); + } + else if (targetOptions.AllowHardCodedValue) + { + target.SetValue(targetProperty, EvalPropertyValue(sourceProperty, sourceValue)); + } + else if (throwOnFailure) + { + throw new DotvvmControlException(this, $"Value of {sourceProperty.FullName} couldn't be copied to targetProperty: {targetProperty.FullName}, because {targetProperty.FullName} does not support hard coded values."); + } } + else if (throwOnFailure) { - throw new DotvvmControlException(this, $"Value of {sourceProperty.FullName} couldn't be copied to targetProperty: {targetProperty.FullName}, because {targetProperty.FullName} is not set."); + throw new DotvvmControlException(this, $"Value of {sourceProperty.FullName} couldn't be copied to targetProperty: {targetProperty.FullName}, because {sourceProperty.FullName} is not set."); } } } diff --git a/src/Framework/Framework/Controls/FormControls.cs b/src/Framework/Framework/Controls/FormControls.cs index d750b72cd0..60b4bcac3e 100644 --- a/src/Framework/Framework/Controls/FormControls.cs +++ b/src/Framework/Framework/Controls/FormControls.cs @@ -8,8 +8,10 @@ namespace DotVVM.Framework.Controls [ContainsDotvvmProperties] public sealed class FormControls { + /// Enables or disables all child form controls with an Enabled property (Buttons, TextBoxes, ...) [AttachedProperty(typeof(bool))] - public static DotvvmProperty EnabledProperty = DotvvmProperty.Register(() => EnabledProperty, true, true); + public static DotvvmProperty EnabledProperty = DotvvmProperty.Register(() => EnabledProperty, true, isValueInherited: true); + private FormControls() {} // the class can't be static, but no instance should exist } } diff --git a/src/Framework/Framework/Controls/GridViewCheckBoxColumn.cs b/src/Framework/Framework/Controls/GridViewCheckBoxColumn.cs index bedb7c9972..c67a92f6b3 100644 --- a/src/Framework/Framework/Controls/GridViewCheckBoxColumn.cs +++ b/src/Framework/Framework/Controls/GridViewCheckBoxColumn.cs @@ -22,6 +22,7 @@ public bool ValueBinding public static readonly DotvvmProperty ValueBindingProperty = DotvvmProperty.Register(c => c.ValueBinding); + /// Whether to automatically attach Validator.Value onto the TextBox or add a standalone Validator component. public ValidatorPlacement ValidatorPlacement { get { return (ValidatorPlacement)GetValue(ValidatorPlacementProperty)!; } diff --git a/src/Framework/Framework/Controls/GridViewTextColumn.cs b/src/Framework/Framework/Controls/GridViewTextColumn.cs index bc188a529d..476f8e316b 100644 --- a/src/Framework/Framework/Controls/GridViewTextColumn.cs +++ b/src/Framework/Framework/Controls/GridViewTextColumn.cs @@ -51,6 +51,7 @@ public IValueBinding? ValueBinding public static readonly DotvvmProperty ValueBindingProperty = DotvvmProperty.Register(c => c.ValueBinding); + /// Whether to automatically attach Validator.Value onto the TextBox or add a standalone Validator component. [MarkupOptions(AllowBinding = false)] public ValidatorPlacement ValidatorPlacement { diff --git a/src/Framework/Framework/Controls/HtmlWriter.cs b/src/Framework/Framework/Controls/HtmlWriter.cs index 7c97e590f9..94e7e1ac68 100644 --- a/src/Framework/Framework/Controls/HtmlWriter.cs +++ b/src/Framework/Framework/Controls/HtmlWriter.cs @@ -26,7 +26,7 @@ public class HtmlWriter : IHtmlWriter private readonly bool debug; private readonly bool enableWarnings; - private List<(string name, string? val, string? separator, bool allowAppending)> attributes = new List<(string, string?, string? separator, bool allowAppending)>(); + private readonly List<(string name, string? val, string? separator, bool allowAppending)> attributes = new List<(string, string?, string? separator, bool allowAppending)>(); private DotvvmBindableObject? errorContext; private OrderedDictionary dataBindAttributes = new OrderedDictionary(); private Stack openTags = new Stack(); @@ -322,11 +322,6 @@ private void WriteAttrWithTransformers(string name, string attributeName, string { WriteHtmlAttribute(attributeName, attributeValue); } - - if (this.enableWarnings && char.IsUpper(attributeName[0])) - { - Warn($"{attributeName} is used as an HTML attribute on element {name}, but it starts with an uppercase letter. Did you intent to use a DotVVM property instead? To silence this warning, just use all lowercase letters for standard HTML attributes."); - } } private string ConvertHtmlAttributeValue(object value) @@ -435,7 +430,6 @@ private int CountQuotesAndApos(string value) return result; } - private void WriteEncodedText(string input, bool escapeQuotes, bool escapeApos) { int index = 0; @@ -449,7 +443,6 @@ private void WriteEncodedText(string input, bool escapeQuotes, bool escapeApos) writer.Write(input); return; } - #if NoSpan writer.Write(input.Substring(startIndex)); #else @@ -464,81 +457,80 @@ private void WriteEncodedText(string input, bool escapeQuotes, bool escapeApos) #else writer.Write(input.AsSpan().Slice(startIndex, index - startIndex)); #endif - switch (input[index]) - { - case '<': - writer.Write("<"); - break; - case '>': - writer.Write(">"); - break; - case '"': - writer.Write("""); - break; - case '\'': - writer.Write("'"); - break; - case '&': - writer.Write("&"); - break; - default: - throw new Exception("Should not happen."); - } + var encoding = EncodingTable[input[index] - 34]; + Debug.Assert(encoding != null); + writer.Write(encoding); + index++; + if (index == input.Length) + return; } } } + static string?[] EncodingTable = new List(Enumerable.Repeat((string?)null, 63 - 34)) { + [34 - 34] = """, // " + [39 - 34] = "'", // ' + [60 - 34] = "<", // < + [62 - 34] = ">", // > + [38 - 34] = "&" // & + }.ToArray(); + private static char[] MinimalEscapeChars = new char[] { '<', '>', '&' }; + private static char[] DoubleQEscapeChars = new char[] { '<', '>', '&', '"' }; + private static char[] SingleQEscapeChars = new char[] { '<', '>', '&', '\'' }; + private static char[] BothQEscapeChars = new char[] { '<', '>', '&', '"', '\'' }; + private static int IndexOfHtmlEncodingChars(string input, int startIndex, bool escapeQuotes, bool escapeApos) { - for (int i = startIndex; i < input.Length; i++) + char[] breakChars; + if (escapeQuotes) + { + if (escapeApos) + breakChars = BothQEscapeChars; + else + breakChars = DoubleQEscapeChars; + } + else if (escapeApos) + { + breakChars = SingleQEscapeChars; + } + else { - char ch = input[i]; - if (ch <= '>') + breakChars = MinimalEscapeChars; + } + + int i = startIndex; + while (true) + { + var foundIndex = MemoryExtensions.IndexOfAny(input.AsSpan(start: i), breakChars); + if (foundIndex < 0) + return -1; + + i += foundIndex; + + if (input[i] == '&' && i + 1 < input.Length) { - switch (ch) - { - case '<': - case '>': - return i; - case '"': - if (escapeQuotes) - return i; - break; - case '\'': - if (escapeApos) - return i; - break; - case '&': - // HTML spec permits ampersands, if they are not ambiguous: - - // An ambiguous ampersand is a U+0026 AMPERSAND character (&) that is followed by one or more ASCII alphanumerics, followed by a U+003B SEMICOLON character (;), where these characters do not match any of the names given in the named character references section. - - // so if the next character is not alphanumeric, we can leave it there - if (i + 1 == input.Length) - return i; - var nextChar = input[i + 1]; - if (IsInRange(nextChar, 'a', 'z') || - IsInRange(nextChar, 'A', 'Z') || - IsInRange(nextChar, '0', '9') || - nextChar == '#') - return i; - break; - } + // HTML spec permits ampersands, if they are not ambiguous: + // (and unnecessarily quoting them makes JS less readable) + + // An ambiguous ampersand is a U+0026 AMPERSAND character (&) that is followed by one or more ASCII alphanumerics, followed by a U+003B SEMICOLON character (;), where these characters do not match any of the names given in the named character references section. + + // so if the next character is not alphanumeric, we can leave it there + var nextChar = input[i + 1]; + if (IsInRange(nextChar, 'a', 'z') | + IsInRange(nextChar, 'A', 'Z') | + IsInRange(nextChar, '0', '9') | + nextChar == '#') + return i; } - else if (char.IsSurrogate(ch)) + else { - // surrogates are fine, but they must not code for ASCII characters - - var value = Char.ConvertToUtf32(ch, input[i + 1]); - if (value < 256) - throw new InvalidOperationException("Encountered UTF16 surrogate coding for ASCII char, this is not allowed."); - - i++; + // all other characters are escaped unconditionaly + return i; } + + i++; } - - return -1; } private void ThrowIfAttributesArePresent([CallerMemberName] string operation = "Write") diff --git a/src/Framework/Framework/Controls/KnockoutHelper.cs b/src/Framework/Framework/Controls/KnockoutHelper.cs index 2d1bfb5a9f..6dc13aa580 100644 --- a/src/Framework/Framework/Controls/KnockoutHelper.cs +++ b/src/Framework/Framework/Controls/KnockoutHelper.cs @@ -374,33 +374,59 @@ private static JsExpression TransformOptionValueToExpression(DotvvmBindableObjec static string? GenerateConcurrencyModeHandler(string propertyName, DotvvmBindableObject obj) { - var mode = (obj.GetValue(PostBack.ConcurrencyProperty) as PostbackConcurrencyMode?) ?? PostbackConcurrencyMode.Default; + if (obj.GetValue(PostBack.ConcurrencyProperty) is not PostbackConcurrencyMode mode) + mode = PostbackConcurrencyMode.Default; // determine concurrency queue string? queueName = null; - var queueSettings = obj.GetValueRaw(PostBack.ConcurrencyQueueSettingsProperty) as ConcurrencyQueueSettingsCollection; + var queueSettings = obj.GetValueRaw(PostBack.ConcurrencyQueueSettingsProperty); if (queueSettings != null) { - queueName = queueSettings.FirstOrDefault(q => string.Equals(q.EventName, propertyName, StringComparison.OrdinalIgnoreCase))?.ConcurrencyQueue; + foreach (var q in (ConcurrencyQueueSettingsCollection)queueSettings) + { + if (string.Equals(q.EventName, propertyName, StringComparison.OrdinalIgnoreCase)) + { + queueName = q.ConcurrencyQueue; + break; + } + } + } + bool queueDefault; + if (queueName is null) + { + if (obj.GetValue(PostBack.ConcurrencyQueueProperty) is string queueValue) + { + queueName = queueValue; + queueDefault = "default".Equals(queueName, StringComparison.Ordinal); + } + else + { + queueDefault = true; + } } - if (queueName == null) + else { - queueName = obj.GetValue(PostBack.ConcurrencyQueueProperty) as string ?? "default"; + queueDefault = "default".Equals(queueName, StringComparison.Ordinal); } // return the handler script - if (mode == PostbackConcurrencyMode.Default && "default".Equals(queueName)) + if (mode == PostbackConcurrencyMode.Default && queueDefault) { return null; } - var handlerName = $"concurrency-{mode.ToString().ToLowerInvariant()}"; - if ("default".Equals(queueName)) + var handlerNameJson = mode switch { + PostbackConcurrencyMode.Default => "\"concurrency-default\"", + PostbackConcurrencyMode.Deny => "\"concurrency-deny\"", + PostbackConcurrencyMode.Queue => "\"concurrency-queue\"", + _ => throw new NotSupportedException() + }; + if (queueDefault) { - return JsonConvert.ToString(handlerName); + return handlerNameJson; } else { - return $"[{JsonConvert.ToString(handlerName)},{GenerateHandlerOptions(obj, new Dictionary { ["q"] = queueName })}]"; + return $"[{handlerNameJson},{{q:{JsonConvert.ToString(queueName)}}}]"; } } diff --git a/src/Framework/Framework/Controls/TextBox.cs b/src/Framework/Framework/Controls/TextBox.cs index ccc373d38b..5d5f9558f2 100644 --- a/src/Framework/Framework/Controls/TextBox.cs +++ b/src/Framework/Framework/Controls/TextBox.cs @@ -94,8 +94,9 @@ public string Text DotvvmProperty.Register(t => t.Text, ""); /// - /// Gets or sets the mode of the text field. + /// Gets or sets the mode of the text field (input/textarea and its type attribute) /// + /// To override the type attribute determined based on this property, you can explicitly specify the attribute value using the `html:type=YOUR_VALUE` syntax [MarkupOptions(AllowBinding = false)] public TextBoxType Type { diff --git a/src/Framework/Framework/Controls/TextBoxType.cs b/src/Framework/Framework/Controls/TextBoxType.cs index 82cf46cb59..acb588e7c1 100644 --- a/src/Framework/Framework/Controls/TextBoxType.cs +++ b/src/Framework/Framework/Controls/TextBoxType.cs @@ -6,18 +6,41 @@ namespace DotVVM.Framework.Controls { public enum TextBoxType { + /// The standard <input type=text text box. Normal, + /// The <input type=password text box which hides the written text. + /// Password, + /// The <textarea> element which allows writing multiple lines. MultiLine, + /// The <input type=tel text box. + /// Telephone, + /// The <input type=url text box which automatically validates whether the user to entered a valid URL. + /// Url, + /// The <input type=email text box which automatically validates whether the user to entered a valid email address. + /// Email, + /// The <input type=datetime element which typicaly shows a date picker (without time). + /// Date, + /// The <input type=datetime-local element which typicaly shows a time-of-day picker (without date). + /// Time, + /// The <input type=number element which typically shows an interactive color picker and stored its 7-character RGB color code in hexadecimal format into the bound view model property. + /// Color, + /// The <input type=range text box. + /// Search, + /// The <input type=range text box which only allows typing digits and typically has up/down arrows. + /// Number, + /// The <input type=range text box which allows the user to specify a year and month combination in the YYYY-MM format. + /// Month, + /// The <input type=range text box which allows the user to specify a date time in their local timezone. DotVVM can automatically convert it into a UTC timestamp using the two-way function. DateTimeLocal } } diff --git a/src/Framework/Framework/Controls/UploadedFilesCollection.cs b/src/Framework/Framework/Controls/UploadedFilesCollection.cs index 7539528c58..c2bc011032 100644 --- a/src/Framework/Framework/Controls/UploadedFilesCollection.cs +++ b/src/Framework/Framework/Controls/UploadedFilesCollection.cs @@ -3,6 +3,7 @@ namespace DotVVM.Framework.Controls { + /// A view model for the FileUpload control. public class UploadedFilesCollection { public UploadedFilesCollection() @@ -10,14 +11,19 @@ public UploadedFilesCollection() Files = new List(); } + /// If is true, this property contains the upload progress in percents (0-100). public int Progress { get; set; } + /// Indicates whether something is being uploaded at the moment. public bool IsBusy { get; set; } + /// List of all completely uploaded files. public List Files { get; set; } + /// Contains an error message indicating if there was a problem during the upload. public string? Error { get; set; } + /// Resets the viewmodel to the default state (no files, no error). public void Clear() { Progress = 0; diff --git a/src/Framework/Framework/Controls/Validation.cs b/src/Framework/Framework/Controls/Validation.cs index a8971683e2..83400ea6d2 100644 --- a/src/Framework/Framework/Controls/Validation.cs +++ b/src/Framework/Framework/Controls/Validation.cs @@ -14,10 +14,15 @@ namespace DotVVM.Framework.Controls [ContainsDotvvmProperties] public class Validation { + /// Controls whether automatic validation is enabled for command bindings on the control and its subtree. [AttachedProperty(typeof(bool))] [MarkupOptions(AllowBinding = false)] public static DotvvmProperty EnabledProperty = DotvvmProperty.Register(() => EnabledProperty, true, true); + /// + /// The object which is the primary target for the automatic validation based on data annotation attributes. + /// Note that data annotations of the property used in the target binding are not validated, only the rules inside its value. + /// [AttachedProperty(typeof(object))] [MarkupOptions(AllowHardCodedValue = false)] public static DotvvmProperty TargetProperty = DotvvmProperty.Register(() => TargetProperty, null, true); diff --git a/src/Framework/Framework/Controls/ValidatorPlacement.cs b/src/Framework/Framework/Controls/ValidatorPlacement.cs index a3b213404c..f3d0e9a641 100644 --- a/src/Framework/Framework/Controls/ValidatorPlacement.cs +++ b/src/Framework/Framework/Controls/ValidatorPlacement.cs @@ -5,8 +5,11 @@ namespace DotVVM.Framework.Controls [Flags] public enum ValidatorPlacement { + /// No validators are placed (automatically). None = 0, + /// Validator.Value is attached to the primary editor control (i.e. a TextBox in GridViewTextColumn) AttachToControl = 1, + /// A standalone Validator (span) control is placed after the editor control. Standalone = 2 } } diff --git a/src/Framework/Framework/Diagnostics/CompilationDiagnostic.dotcontrol b/src/Framework/Framework/Diagnostics/CompilationDiagnostic.dotcontrol index d76471315c..dfaab7490c 100644 --- a/src/Framework/Framework/Diagnostics/CompilationDiagnostic.dotcontrol +++ b/src/Framework/Framework/Diagnostics/CompilationDiagnostic.dotcontrol @@ -2,7 +2,7 @@
-
+
{{value: LineNumber}}: {{value: SourceLinePrefix}}{{value: SourceLineHighlight}}{{value: SourceLineSuffix}}
diff --git a/src/Framework/Framework/Diagnostics/CompilationDiagnosticRows.dotcontrol b/src/Framework/Framework/Diagnostics/CompilationDiagnosticRows.dotcontrol index 34cd0cdc8a..9a84822eb3 100644 --- a/src/Framework/Framework/Diagnostics/CompilationDiagnosticRows.dotcontrol +++ b/src/Framework/Framework/Diagnostics/CompilationDiagnosticRows.dotcontrol @@ -6,7 +6,7 @@ 0}> - + diff --git a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs index 913347ffb7..8bc46a5b7a 100644 --- a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs +++ b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs @@ -141,8 +141,8 @@ public static void RedirectToRoutePermanent(this IDotvvmRequestContext context, context.RedirectToUrlPermanent(url, replaceInHistory, allowSpaRedirect); } - public static void SetRedirectResponse(this IDotvvmRequestContext context, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false) => - context.Configuration.ServiceProvider.GetRequiredService().WriteRedirectResponse(context.HttpContext, url, statusCode, replaceInHistory, allowSpaRedirect); + public static void SetRedirectResponse(this IDotvvmRequestContext context, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false, string? downloadName = null) => + context.Configuration.ServiceProvider.GetRequiredService().WriteRedirectResponse(context.HttpContext, url, statusCode, replaceInHistory, allowSpaRedirect, downloadName); internal static Task SetCachedViewModelMissingResponse(this IDotvvmRequestContext context) { @@ -262,8 +262,10 @@ public static async Task ReturnFileAsync(this IDotvvmRequestContext context, Str AttachmentDispositionType = attachmentDispositionType ?? "attachment" }; + var downloadAttribute = attachmentDispositionType == "inline" ? null : fileName; + var generatedFileId = await returnedFileStorage.StoreFileAsync(stream, metadata).ConfigureAwait(false); - context.SetRedirectResponse(context.TranslateVirtualPath("~/dotvvmReturnedFile?id=" + generatedFileId)); + context.SetRedirectResponse(context.TranslateVirtualPath("~/dotvvmReturnedFile?id=" + generatedFileId), downloadName: downloadAttribute); throw new DotvvmInterruptRequestExecutionException(InterruptReason.ReturnFile, fileName); } diff --git a/src/Framework/Framework/Hosting/HttpRedirectService.cs b/src/Framework/Framework/Hosting/HttpRedirectService.cs index d1aef7f12f..c29281b6c7 100644 --- a/src/Framework/Framework/Hosting/HttpRedirectService.cs +++ b/src/Framework/Framework/Hosting/HttpRedirectService.cs @@ -25,14 +25,13 @@ namespace DotVVM.Framework.Hosting { public interface IHttpRedirectService { - void WriteRedirectResponse(IHttpContext httpContext, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false); + void WriteRedirectResponse(IHttpContext httpContext, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false, string? downloadName = null); } public class DefaultHttpRedirectService: IHttpRedirectService { - public void WriteRedirectResponse(IHttpContext httpContext, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false) + public void WriteRedirectResponse(IHttpContext httpContext, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false, string? downloadName = null) { - if (DotvvmRequestContext.DetermineRequestType(httpContext) is DotvvmRequestType.Navigate) { httpContext.Response.Headers["Location"] = url; @@ -43,7 +42,7 @@ public void WriteRedirectResponse(IHttpContext httpContext, string url, int stat httpContext.Response.StatusCode = 200; httpContext.Response.ContentType = "application/json"; httpContext.Response - .WriteAsync(DefaultViewModelSerializer.GenerateRedirectActionResponse(url, replaceInHistory, allowSpaRedirect)) + .WriteAsync(DefaultViewModelSerializer.GenerateRedirectActionResponse(url, replaceInHistory, allowSpaRedirect, downloadName)) .GetAwaiter().GetResult(); // ^ we just wait for this Task. Redirect API never was async and the response size is small enough that we can't quite safely wait for the result // .GetAwaiter().GetResult() preserves stack traces across async calls, thus I like it more that .Wait() diff --git a/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts b/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts index 6abad6881b..1d1d1a5eba 100644 --- a/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts +++ b/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts @@ -69,13 +69,19 @@ export function getStateManager(): StateManager { return getCoreS let initialViewModelWrapper: any; +function isBackForwardNavigation() { + return (performance.getEntriesByType?.("navigation").at(-1) as PerformanceNavigationTiming)?.type == "back_forward"; +} + export function initCore(culture: string): void { if (currentCoreState) { throw new Error("DotVVM is already loaded"); } // load the viewmodel - const thisViewModel = initialViewModelWrapper = JSON.parse(getViewModelStorageElement().value); + const thisViewModel = initialViewModelWrapper = + (isBackForwardNavigation() ? history.state?.viewModel : null) ?? + JSON.parse(getViewModelStorageElement().value); resourceLoader.registerResources(thisViewModel.renderedResources) @@ -124,8 +130,10 @@ const getViewModelStorageElement = () => document.getElementById("__dot_viewmodel_root") function persistViewModel() { - const viewModel = getState() - const persistedViewModel = { ...initialViewModelWrapper, viewModel }; - - getViewModelStorageElement().value = JSON.stringify(persistedViewModel); + history.replaceState({ + ...history.state, + viewModel: { ...initialViewModelWrapper, viewModel: getState() } + }, "") + // avoid storing the viewmodel hidden field, as Firefox would also reuse it on page reloads + getViewModelStorageElement()?.remove() } diff --git a/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts b/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts index c783bf3398..27e9db6717 100644 --- a/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts +++ b/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts @@ -4,7 +4,7 @@ import * as spa from "./spa/spa" import * as validation from './validation/validation' import { postBack } from './postback/postback' import { serialize } from './serialization/serialize' -import { serializeDate, parseDate } from './serialization/date' +import { serializeDate, parseDate, parseDateOnly, parseTimeOnly } from './serialization/date' import { deserialize } from './serialization/deserialize' import registerBindingHandlers from './binding-handlers/register' import * as evaluator from './utils/evaluator' @@ -99,6 +99,8 @@ const dotvvmExports = { serialize, serializeDate, parseDate, + parseDateOnly, + parseTimeOnly, deserialize }, metadata: { diff --git a/src/Framework/Framework/Resources/Scripts/postback/redirect.ts b/src/Framework/Framework/Resources/Scripts/postback/redirect.ts index bad3b21184..fb28132976 100644 --- a/src/Framework/Framework/Resources/Scripts/postback/redirect.ts +++ b/src/Framework/Framework/Resources/Scripts/postback/redirect.ts @@ -1,17 +1,23 @@ import * as events from '../events'; import * as magicNavigator from '../utils/magic-navigator' import { handleSpaNavigationCore } from "../spa/spa"; +import { delay } from '../utils/promise'; + export function performRedirect(url: string, replace: boolean, allowSpa: boolean): Promise { if (replace) { location.replace(url); - return Promise.resolve(); } else if (compileConstants.isSpa && allowSpa) { return handleSpaNavigationCore(url); } else { magicNavigator.navigate(url); - return Promise.resolve(); } + + // When performing redirect, we pretend that the request takes additional X second to avoid + // double submit with Postback.Concurrency=Deny or Queue. + // We do not want to block the page forever, as the redirect might just return a file (or HTTP 204/205), + // and the page will continue to live. + return delay(5_000); } export async function handleRedirect(options: PostbackOptions, resultObject: any, response: Response, replace: boolean = false): Promise { @@ -28,7 +34,12 @@ export async function handleRedirect(options: PostbackOptions, resultObject: any } events.redirect.trigger(redirectArgs); - await performRedirect(url, replace, resultObject.allowSpa); + const downloadFileName = resultObject.download + if (downloadFileName != null) { + magicNavigator.navigate(url, downloadFileName) + } else { + await performRedirect(url, replace, resultObject.allowSpa); + } return redirectArgs; } diff --git a/src/Framework/Framework/Resources/Scripts/tests/eventArgs.test.ts b/src/Framework/Framework/Resources/Scripts/tests/eventArgs.test.ts index 097fe5c478..d6c29aa268 100644 --- a/src/Framework/Framework/Resources/Scripts/tests/eventArgs.test.ts +++ b/src/Framework/Framework/Resources/Scripts/tests/eventArgs.test.ts @@ -174,7 +174,8 @@ const fetchDefinitions = { postbackRedirect: async (url: string, init: RequestInit) => { return { action: "redirect", - url: "/newUrl" + url: "/newUrl", + download: "some-file" // say it's a file, so that DotVVM does not block postback queue after the test } as any; }, @@ -213,7 +214,8 @@ const fetchDefinitions = { return { action: "redirect", url: "/newUrl", - allowSpa: true + allowSpa: true, + download: "some-file" } as any; }, spaNavigateError: async (url: string, init: RequestInit) => { diff --git a/src/Framework/Framework/Resources/Scripts/utils/magic-navigator.ts b/src/Framework/Framework/Resources/Scripts/utils/magic-navigator.ts index 60e3486ab1..b97ca9dc46 100644 --- a/src/Framework/Framework/Resources/Scripts/utils/magic-navigator.ts +++ b/src/Framework/Framework/Resources/Scripts/utils/magic-navigator.ts @@ -1,10 +1,15 @@ let fakeAnchor: HTMLAnchorElement | undefined; -export function navigate(url: string) { +export function navigate(url: string, downloadName: string | null | undefined = null) { if (!fakeAnchor) { fakeAnchor = document.createElement("a"); fakeAnchor.style.display = "none"; document.body.appendChild(fakeAnchor); } + if (downloadName == null) { + fakeAnchor.removeAttribute("download"); + } else { + fakeAnchor.download = downloadName + } fakeAnchor.href = url; fakeAnchor.click(); } diff --git a/src/Framework/Framework/Resources/Scripts/utils/promise.ts b/src/Framework/Framework/Resources/Scripts/utils/promise.ts index a860c54669..c702c2f86e 100644 --- a/src/Framework/Framework/Resources/Scripts/utils/promise.ts +++ b/src/Framework/Framework/Resources/Scripts/utils/promise.ts @@ -1,2 +1,3 @@ /** Runs the callback in the next event loop cycle */ export const defer = (callback: () => T) => Promise.resolve().then(callback) +export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) diff --git a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs index 5c82ae6c78..be29042772 100644 --- a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs +++ b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs @@ -98,6 +98,8 @@ public DotvvmRoute GetRouteForCulture(CultureInfo culture) : throw new NotSupportedException("Invalid localized route - no default route found!"); } + public IReadOnlyDictionary GetAllCultureRoutes() => localizedRoutes; + public static void ValidateCultureName(string cultureIdentifier) { if (!AvailableCultureNames.Contains(cultureIdentifier)) diff --git a/src/Framework/Framework/Runtime/Caching/SimpleLruDictionary.cs b/src/Framework/Framework/Runtime/Caching/SimpleLruDictionary.cs index c227fefac6..5d7f39e5bb 100644 --- a/src/Framework/Framework/Runtime/Caching/SimpleLruDictionary.cs +++ b/src/Framework/Framework/Runtime/Caching/SimpleLruDictionary.cs @@ -20,9 +20,9 @@ public class SimpleLruDictionary { // concurrencyLevel: 1, we don't write in parallel anyway // new generation - private ConcurrentDictionary hot = new ConcurrentDictionary(concurrencyLevel: 1, capacity: 1); + private volatile ConcurrentDictionary hot = new ConcurrentDictionary(concurrencyLevel: 1, capacity: 1); // old generation - private ConcurrentDictionary cold = new ConcurrentDictionary(concurrencyLevel: 1, capacity: 1); + private volatile ConcurrentDictionary cold = new ConcurrentDictionary(concurrencyLevel: 1, capacity: 1); // free to take for GC. however, if the GC does not want to collect, we can still use it private readonly ConcurrentDictionary> dead = new ConcurrentDictionary>(concurrencyLevel: 1, capacity: 1); private TimeSpan lastCleanupTime = TimeSpan.MinValue; @@ -148,6 +148,12 @@ public bool Remove(TKey key, out TValue oldValue) oldValue = deadValue; r = true; } + if (hot.TryRemove(key, out hotValue)) + { + // hot again, it could have been added back in the meantime + oldValue = hotValue; + r = true; + } return r; } } diff --git a/src/Framework/Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs b/src/Framework/Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs index eb35dc99f4..db51e3f283 100644 --- a/src/Framework/Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs +++ b/src/Framework/Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs @@ -258,7 +258,7 @@ public JObject BuildResourcesJson(IDotvvmRequestContext context, Func /// Serializes the redirect action. ///
- public static string GenerateRedirectActionResponse(string url, bool replace, bool allowSpa) + public static string GenerateRedirectActionResponse(string url, bool replace, bool allowSpa, string? downloadName) { // create result object var result = new JObject(); @@ -266,6 +266,7 @@ public static string GenerateRedirectActionResponse(string url, bool replace, bo result["action"] = "redirect"; if (replace) result["replace"] = true; if (allowSpa) result["allowSpa"] = true; + if (downloadName is object) result["download"] = downloadName; return result.ToString(Formatting.None); } diff --git a/src/Samples/Common/ViewModels/FeatureSamples/Redirect/RedirectPostbackConcurrencyViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/Redirect/RedirectPostbackConcurrencyViewModel.cs new file mode 100644 index 0000000000..75f53ba0b1 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/Redirect/RedirectPostbackConcurrencyViewModel.cs @@ -0,0 +1,65 @@ +using System.Diagnostics.Metrics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using DotVVM.Core.Storage; +using DotVVM.Framework.ViewModel; + +namespace DotVVM.Samples.BasicSamples.ViewModels.FeatureSamples.Redirect +{ + class RedirectPostbackConcurrencyViewModel : DotvvmViewModelBase + { + public static int GlobalCounter = 0; + private readonly IReturnedFileStorage returnedFileStorage; + + [Bind(Direction.ServerToClient)] + public int Counter { get; set; } = GlobalCounter; + + public int MiniCounter { get; set; } = 0; + + [FromQuery("empty")] + public bool IsEmptyPage { get; set; } = false; + [FromQuery("loadDelay")] + public int LoadDelay { get; set; } = 0; + + public RedirectPostbackConcurrencyViewModel(IReturnedFileStorage returnedFileStorage) + { + this.returnedFileStorage = returnedFileStorage; + } + public override async Task Init() + { + await Task.Delay(LoadDelay); // delay to enable user to click DelayIncrement button between it succeeding and loading the next page + await base.Init(); + } + + public async Task DelayIncrement() + { + await Task.Delay(1000); + + Interlocked.Increment(ref GlobalCounter); + + Context.RedirectToRoute(Context.Route.RouteName, query: new { empty = true, loadDelay = 2000 }); + } + + public async Task GetFileStandard() + { + await Context.ReturnFileAsync("test file"u8.ToArray(), "test.txt", "text/plain"); + } + + public async Task GetFileCustom() + { + var metadata = new ReturnedFileMetadata() + { + FileName = "test_custom.txt", + MimeType = "text/plain", + AttachmentDispositionType = "attachment" + }; + + var stream = new MemoryStream("test custom file"u8.ToArray()); + var generatedFileId = await returnedFileStorage.StoreFileAsync(stream, metadata).ConfigureAwait(false); + + var url = Context.TranslateVirtualPath("~/dotvvmReturnedFile?id=" + generatedFileId); + Context.RedirectToUrl(url); + } + } +} diff --git a/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateOnlyTranslations.dothtml b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateOnlyTranslations.dothtml new file mode 100644 index 0000000000..e35bb76f45 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateOnlyTranslations.dothtml @@ -0,0 +1,36 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.JavascriptTranslation.DateTimeTranslationsViewModel, DotVVM.Samples.Common +@import System + + + + + + + + + + + +

DateOnly testing

+ + + + +

+ DateOnly.ToString: + +

+

+ DateOnly properties: + + {{value: DateOnly.FromDateTime(NullableDateTimeProp).Day}}. {{value: DateOnly.FromDateTime(NullableDateTimeProp).Month}}. {{value: DateOnly.FromDateTime(NullableDateTimeProp).Year}} + +

+ + + + diff --git a/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateTimeTranslations.dothtml b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateTimeTranslations.dothtml index d5500c9643..626044f233 100644 --- a/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateTimeTranslations.dothtml +++ b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateTimeTranslations.dothtml @@ -24,11 +24,19 @@ TimeShift.setTimezoneOffset(-120); }()) +

DateTime testing

+ + +

Year: diff --git a/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/TimeOnlyTranslations.dothtml b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/TimeOnlyTranslations.dothtml new file mode 100644 index 0000000000..3741b97752 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/TimeOnlyTranslations.dothtml @@ -0,0 +1,35 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.JavascriptTranslation.DateTimeTranslationsViewModel, DotVVM.Samples.Common +@import System + + + + + + + + + + + +

TimeOnly testing

+ + + +

+ TimeOnly.ToString: + +

+

+ TimeOnly properties: + + {{value: TimeOnly.FromDateTime(NullableDateTimeProp).Hour}} hours {{value: TimeOnly.FromDateTime(NullableDateTimeProp).Minute}} minues {{value: TimeOnly.FromDateTime(NullableDateTimeProp).Second}} seconds and {{value: TimeOnly.FromDateTime(NullableDateTimeProp).Millisecond}} milliseconds + + + + + + diff --git a/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml b/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml index 23394a4f4c..cce5c3578b 100644 --- a/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml +++ b/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml @@ -6,6 +6,8 @@ + + diff --git a/src/Samples/Common/Views/FeatureSamples/Redirect/RedirectPostbackConcurrency.dothtml b/src/Samples/Common/Views/FeatureSamples/Redirect/RedirectPostbackConcurrency.dothtml new file mode 100644 index 0000000000..f9838fcdc6 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/Redirect/RedirectPostbackConcurrency.dothtml @@ -0,0 +1,33 @@ +@viewModel DotVVM.Samples.BasicSamples.ViewModels.FeatureSamples.Redirect.RedirectPostbackConcurrencyViewModel, DotVVM.Samples.Common + + + Hello from DotVVM! + + +

+ Back +
+
+

Redirect and postback concurrency test

+

+ Testing Concurrency=Deny / Concurrency=Queue with redirect and file returns. +

+

First, we have a set of buttons incrementing a static variable, each takes about 2sec and redirects to a blank page afterwards

+

GlobalCounter =

+

MiniCounter(Concurrency=Deny) =

+ +

+ Increment (Concurrency=Default) + Increment (Concurrency=Deny) + Increment (Concurrency=Queue) +

+ +

We also test that returning files does not block the page forever,

+ +

+ Standard return file + Custom return file (will have delay before page works) +

+
+ + diff --git a/src/Samples/Owin/Web.config b/src/Samples/Owin/Web.config index 22947dc087..a65c7430ad 100644 --- a/src/Samples/Owin/Web.config +++ b/src/Samples/Owin/Web.config @@ -66,4 +66,4 @@ - \ No newline at end of file + diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index 22217bbc28..7549c68a19 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -268,12 +268,14 @@ public partial class SamplesRouteUrls public const string FeatureSamples_IdGeneration_IdGeneration = "FeatureSamples/IdGeneration/IdGeneration"; public const string FeatureSamples_JavascriptEvents_JavascriptEvents = "FeatureSamples/JavascriptEvents/JavascriptEvents"; public const string FeatureSamples_JavascriptTranslation_ArrayTranslation = "FeatureSamples/JavascriptTranslation/ArrayTranslation"; + public const string FeatureSamples_JavascriptTranslation_DateOnlyTranslations = "FeatureSamples/JavascriptTranslation/DateOnlyTranslations"; public const string FeatureSamples_JavascriptTranslation_DateTimeTranslations = "FeatureSamples/JavascriptTranslation/DateTimeTranslations"; public const string FeatureSamples_JavascriptTranslation_DictionaryIndexerTranslation = "FeatureSamples/JavascriptTranslation/DictionaryIndexerTranslation"; public const string FeatureSamples_JavascriptTranslation_GenericMethodTranslation = "FeatureSamples/JavascriptTranslation/GenericMethodTranslation"; public const string FeatureSamples_JavascriptTranslation_ListIndexerTranslation = "FeatureSamples/JavascriptTranslation/ListIndexerTranslation"; public const string FeatureSamples_JavascriptTranslation_ListMethodTranslations = "FeatureSamples/JavascriptTranslation/ListMethodTranslations"; public const string FeatureSamples_JavascriptTranslation_MathMethodTranslation = "FeatureSamples/JavascriptTranslation/MathMethodTranslation"; + public const string FeatureSamples_JavascriptTranslation_TimeOnlyTranslations = "FeatureSamples/JavascriptTranslation/TimeOnlyTranslations"; public const string FeatureSamples_JavascriptTranslation_StringMethodTranslations = "FeatureSamples/JavascriptTranslation/StringMethodTranslations"; public const string FeatureSamples_JavascriptTranslation_WebUtilityTranslations = "FeatureSamples/JavascriptTranslation/WebUtilityTranslations"; public const string FeatureSamples_JsComponentIntegration_ReactComponentIntegration = "FeatureSamples/JsComponentIntegration/ReactComponentIntegration"; @@ -329,6 +331,7 @@ public partial class SamplesRouteUrls public const string FeatureSamples_Redirect_RedirectionHelpers_PageC = "FeatureSamples/Redirect/RedirectionHelpers_PageC"; public const string FeatureSamples_Redirect_RedirectionHelpers_PageD = "FeatureSamples/Redirect/RedirectionHelpers_PageD"; public const string FeatureSamples_Redirect_RedirectionHelpers_PageE = "FeatureSamples/Redirect/RedirectionHelpers_PageE"; + public const string FeatureSamples_Redirect_RedirectPostbackConcurrency = "FeatureSamples/Redirect/RedirectPostbackConcurrency"; public const string FeatureSamples_Redirect_Redirect_StaticCommand = "FeatureSamples/Redirect/Redirect_StaticCommand"; public const string FeatureSamples_RenderSettingsModeServer_RenderSettingModeServerProperty = "FeatureSamples/RenderSettingsModeServer/RenderSettingModeServerProperty"; public const string FeatureSamples_RenderSettingsModeServer_RepeaterCollectionExchange = "FeatureSamples/RenderSettingsModeServer/RepeaterCollectionExchange"; diff --git a/src/Samples/Tests/Tests/Complex/TaskListTests.cs b/src/Samples/Tests/Tests/Complex/TaskListTests.cs index 09fd44c01d..2e1776e050 100644 --- a/src/Samples/Tests/Tests/Complex/TaskListTests.cs +++ b/src/Samples/Tests/Tests/Complex/TaskListTests.cs @@ -62,5 +62,46 @@ public void Complex_TaskList_ServerRenderedTaskList() "Last task is not marked as completed."); }); } + + [Fact] + public void Complex_TaskList_TaskListAsyncCommands_ViewModelRestore() + { + // view model should be restored after back/forward navigation, but not on refresh + RunInAllBrowsers(browser => + { + browser.NavigateToUrl("/"); + browser.NavigateToUrl(SamplesRouteUrls.ComplexSamples_TaskList_TaskListAsyncCommands); + + browser.SendKeys("input[type=text]", "test1"); + browser.Click("input[type=button]"); + + browser.FindElements(".table tr").ThrowIfDifferentCountThan(4); + + browser.NavigateBack(); + browser.WaitUntilDotvvmInited(); + browser.NavigateForward(); + + browser.FindElements(".table tr").ThrowIfDifferentCountThan(4); + + browser.Refresh(); + + browser.FindElements(".table tr").ThrowIfDifferentCountThan(3); + + browser.SendKeys("input[type=text]", "test2"); + browser.Click("input[type=button]"); + browser.FindElements(".table tr").ThrowIfDifferentCountThan(4); + + browser.NavigateToUrl("/"); + browser.NavigateToUrl(SamplesRouteUrls.ComplexSamples_TaskList_TaskListAsyncCommands); + + browser.FindElements(".table tr").ThrowIfDifferentCountThan(3); + + browser.NavigateBack(); + browser.WaitUntilDotvvmInited(); + browser.NavigateBack(); + + browser.FindElements(".table tr").ThrowIfDifferentCountThan(4); + }); + } } } diff --git a/src/Samples/Tests/Tests/Feature/DateTimeTranslationTests.cs b/src/Samples/Tests/Tests/Feature/DateTimeTranslationTests.cs index 70ca58aa4a..ae6b2a3dcb 100644 --- a/src/Samples/Tests/Tests/Feature/DateTimeTranslationTests.cs +++ b/src/Samples/Tests/Tests/Feature/DateTimeTranslationTests.cs @@ -68,6 +68,43 @@ public void Feature_DateTime_PropertyTranslations() }); } + [Fact] + public void Feature_DateOnly_PropertyTranslations() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_JavascriptTranslation_DateOnlyTranslations); + + var stringDateTime = "6/28/2021 3:28:31 PM"; + + var textbox = browser.Single("input[data-ui=textbox]"); + textbox.Clear().SendKeys(stringDateTime).SendEnterKey(); + + var str = browser.Single("span[data-ui=dateOnlyToString]"); + AssertUI.TextEquals(str, "Monday, June 28, 2021"); + var props = browser.Single("span[data-ui=dateOnlyProperties]"); + AssertUI.TextEquals(props, "28. 6. 2021"); + }); + } + + [Fact] + public void Feature_TimeOnly_PropertyTranslations() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_JavascriptTranslation_TimeOnlyTranslations); + + var stringDateTime = "6/28/2021 3:28:31 PM"; + + var textbox = browser.Single("input[data-ui=textbox]"); + textbox.Clear().SendKeys(stringDateTime).SendEnterKey(); + + var str = browser.Single("span[data-ui=timeOnlyToString]"); + AssertUI.TextEquals(str, "3:28:31 PM"); + var props = browser.Single("span[data-ui=timeOnlyProperties]"); + AssertUI.TextEquals(props, "15 hours 28 minues 31 seconds and 0 milliseconds"); + }); + } + + public DateTimeTranslationTests(ITestOutputHelper output) : base(output) { } diff --git a/src/Samples/Tests/Tests/Feature/FormControlsEnabledTests.cs b/src/Samples/Tests/Tests/Feature/FormControlsEnabledTests.cs index 79c468a479..7a47b04a0c 100644 --- a/src/Samples/Tests/Tests/Feature/FormControlsEnabledTests.cs +++ b/src/Samples/Tests/Tests/Feature/FormControlsEnabledTests.cs @@ -84,7 +84,7 @@ public void Feature_FormControlsEnabled_FormControlsEnabled() private void TestLinkButton(IBrowserWrapper browser, string id, bool shouldBeEnabled, ref int currentPresses) { - browser.First($"#{id}").Click(); + browser.First($"#{id}").ScrollTo().Wait(500).Click(); if (shouldBeEnabled) { currentPresses++; diff --git a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs index e3b56c741b..5d26fb5071 100644 --- a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs +++ b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs @@ -146,6 +146,8 @@ public void Feature_Localization_LocalizableRoute() AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); AssertUI.Attribute(links[3], "href", links[2].GetAttribute("href")); + AssertAlternateLink("cs-cz", "/cs/FeatureSamples/Localization/lokalizovana-routa"); + AssertAlternateLink("de", "/de/FeatureSamples/Localization/lokalisierte-route"); links[0].Click().Wait(500); culture = browser.Single("span[data-ui=culture]"); @@ -155,6 +157,8 @@ public void Feature_Localization_LocalizableRoute() AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); AssertUI.Attribute(links[3], "href", links[0].GetAttribute("href")); + AssertAlternateLink("x-default", "/FeatureSamples/Localization/LocalizableRoute"); + AssertAlternateLink("de", "/de/FeatureSamples/Localization/lokalisierte-route"); links[1].Click().Wait(500); culture = browser.Single("span[data-ui=culture]"); @@ -164,6 +168,8 @@ public void Feature_Localization_LocalizableRoute() AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); AssertUI.Attribute(links[3], "href", links[1].GetAttribute("href")); + AssertAlternateLink("x-default", "/FeatureSamples/Localization/LocalizableRoute"); + AssertAlternateLink("cs-cz", "/cs/FeatureSamples/Localization/lokalizovana-routa"); links[2].Click().Wait(500); culture = browser.Single("span[data-ui=culture]"); @@ -173,6 +179,13 @@ public void Feature_Localization_LocalizableRoute() AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); AssertUI.Attribute(links[3], "href", links[2].GetAttribute("href")); + AssertAlternateLink("cs-cz", "/cs/FeatureSamples/Localization/lokalizovana-routa"); + AssertAlternateLink("de", "/de/FeatureSamples/Localization/lokalisierte-route"); + + void AssertAlternateLink(string culture, string url) + { + AssertUI.Attribute(browser.Single($"link[rel=alternate][hreflang={culture}]"), "href", this.TestSuiteRunner.Configuration.BaseUrls[0].TrimEnd('/') + url); + } }); } diff --git a/src/Samples/Tests/Tests/Feature/RedirectTests.cs b/src/Samples/Tests/Tests/Feature/RedirectTests.cs index 70af6f5cc2..ef9b12b62e 100644 --- a/src/Samples/Tests/Tests/Feature/RedirectTests.cs +++ b/src/Samples/Tests/Tests/Feature/RedirectTests.cs @@ -1,7 +1,11 @@ using System; using System.Linq; +using System.Threading; using DotVVM.Samples.Tests.Base; using DotVVM.Testing.Abstractions; +using OpenQA.Selenium; +using Riganti.Selenium.Core; +using Riganti.Selenium.Core.Abstractions; using Riganti.Selenium.DotVVM; using Xunit; using Xunit.Abstractions; @@ -69,6 +73,94 @@ public void Feature_Redirect_RedirectionHelpers() Assert.Matches($"{SamplesRouteUrls.FeatureSamples_Redirect_RedirectionHelpers_PageE}/1221\\?x=a", currentUrl.LocalPath + currentUrl.Query); }); } - + + bool TryClick(IElementWrapper element) + { + if (element is null) return false; + try + { + element.Click(); + return true; + } + catch (StaleElementReferenceException) + { + return false; + } + } + + [Fact] + public void Feature_Redirect_RedirectPostbackConcurrency() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Redirect_RedirectPostbackConcurrency); + + int globalCounter() => int.Parse(browser.First("counter", SelectByDataUi).GetText()); + + var initialCounter = globalCounter(); + for (int i = 0; i < 20; i++) + { + TryClick(browser.FirstOrDefault("inc-default", SelectByDataUi)); + Thread.Sleep(1); + } + browser.WaitFor(() => Assert.Contains("empty=true", browser.CurrentUrl, StringComparison.OrdinalIgnoreCase), 7000, "Redirect did not happen"); + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Redirect_RedirectPostbackConcurrency); + + // must increment at least 20 times, otherwise delays are too short + Assert.Equal(globalCounter(), initialCounter + 20); + + initialCounter = globalCounter(); + var clickCount = 0; + while (TryClick(browser.FirstOrDefault("inc-deny", SelectByDataUi))) + { + clickCount++; + Thread.Sleep(1); + } + Assert.InRange(clickCount, 3, int.MaxValue); + browser.WaitFor(() => Assert.Contains("empty=true", browser.CurrentUrl, StringComparison.OrdinalIgnoreCase), timeout: 500, "Redirect did not happen"); + + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Redirect_RedirectPostbackConcurrency); + Assert.Equal(globalCounter(), initialCounter + 1); // only one click was allowed + + initialCounter = globalCounter(); + clickCount = 0; + while (TryClick(browser.FirstOrDefault("inc-queue", SelectByDataUi))) + { + clickCount++; + Thread.Sleep(1); + } + + Assert.InRange(clickCount, 3, int.MaxValue); + browser.WaitFor(() => Assert.Contains("empty=true", browser.CurrentUrl, StringComparison.OrdinalIgnoreCase), timeout: 500, "Redirect did not happen"); + + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Redirect_RedirectPostbackConcurrency); + Assert.Equal(globalCounter(), initialCounter + 1); // only one click was allowed + }); + } + + [Fact] + public void Feature_Redirect_RedirectPostbackConcurrencyFileReturn() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Redirect_RedirectPostbackConcurrency); + + void increment(int timeout) + { + browser.WaitFor(() => { + var original = int.Parse(browser.First("minicounter", SelectByDataUi).GetText()); + browser.First("minicounter", SelectByDataUi).Click(); + AssertUI.TextEquals(browser.First("minicounter", SelectByDataUi), (original + 1).ToString()); + }, timeout, "Could not increment minicounter in given timeout (postback queue is blocked)"); + } + + increment(3000); + + browser.First("file-std", SelectByDataUi).Click(); + increment(3000); + + browser.First("file-custom", SelectByDataUi).Click(); + // longer timeout, because DotVVM blocks postback queue for 5s after redirects to debounce any further requests + increment(15000); + }); + } } } diff --git a/src/Tests/Binding/JavascriptCompilationTests.cs b/src/Tests/Binding/JavascriptCompilationTests.cs index d387558513..7b9aa99c94 100644 --- a/src/Tests/Binding/JavascriptCompilationTests.cs +++ b/src/Tests/Binding/JavascriptCompilationTests.cs @@ -1112,6 +1112,17 @@ public void JsTranslator_DateTime_Property_Getters(string binding, string jsFunc Assert.AreEqual($"dotvvm.serialization.parseDate(DateTime()).{jsFunction}(){(increment ? "+1" : string.Empty)}", result); } + [TestMethod] + [DataRow("DateTime.Now", "dotvvm.serialization.serializeDate(new Date(),false)")] + [DataRow("DateTime.UtcNow", "dotvvm.serialization.serializeDate(new Date(),true)")] + [DataRow("DateTime.Today", "dotvvm.serialization.serializeDate(new Date(),false).substring(0,10)+\"T00:00:00.000\"")] + public void JsTranslator_DateTime_Now(string binding, string expected) + { + var result = CompileBinding(binding); + Assert.AreEqual(expected, result); + } + + [TestMethod] [DataRow("DateOnly.ToString()", "")] [DataRow("DateOnly.ToString('D')", "\"D\"")] @@ -1129,6 +1140,16 @@ public void JsTranslator_NullableDateOnly_ToString(string binding, string args) Assert.AreEqual($"dotvvm.globalize.bindingDateOnlyToString(NullableDateOnly{((args.Length > 0) ? $",{args}" : string.Empty)})", result); } + [DataTestMethod] + [DataRow("DateOnly.Year", "getFullYear()")] + [DataRow("DateOnly.Month", "getMonth()+1")] + [DataRow("DateOnly.Day", "getDate()")] + public void JsTranslator_DateOnly_Property_Getters(string binding, string jsFunction) + { + var result = CompileBinding(binding, new[] { typeof(TestViewModel) }); + Assert.AreEqual($"dotvvm.serialization.parseDateOnly(DateOnly()).{jsFunction}", result); + } + [TestMethod] [DataRow("TimeOnly.ToString()", "")] [DataRow("TimeOnly.ToString('T')", "\"T\"")] @@ -1146,6 +1167,17 @@ public void JsTranslator_NullableTimeOnly_ToString(string binding, string args) Assert.AreEqual($"dotvvm.globalize.bindingTimeOnlyToString(NullableTimeOnly{((args.Length > 0) ? $",{args}" : string.Empty)})", result); } + [DataTestMethod] + [DataRow("TimeOnly.Hour", "getHours()")] + [DataRow("TimeOnly.Minute", "getMinutes()")] + [DataRow("TimeOnly.Second", "getSeconds()")] + [DataRow("TimeOnly.Millisecond", "getMilliseconds()")] + public void JsTranslator_TimeOnly_Property_Getters(string binding, string jsFunction) + { + var result = CompileBinding(binding, new[] { typeof(TestViewModel) }); + Assert.AreEqual($"dotvvm.serialization.parseTimeOnly(TimeOnly()).{jsFunction}", result); + } + [TestMethod] public void JsTranslator_WebUtility_UrlEncode() { diff --git a/src/Tests/Parser/Dothtml/DothtmlTokenizerElementsTests.cs b/src/Tests/Parser/Dothtml/DothtmlTokenizerElementsTests.cs index 0fd1c4fc11..697f0b2455 100644 --- a/src/Tests/Parser/Dothtml/DothtmlTokenizerElementsTests.cs +++ b/src/Tests/Parser/Dothtml/DothtmlTokenizerElementsTests.cs @@ -239,6 +239,18 @@ public void DothtmlTokenizer_Invalid_OpenBraceInText() tokenizer.Tokenize(input); Assert.IsTrue(tokenizer.Tokens.All(t => t.Type == DothtmlTokenType.Text)); + Assert.IsTrue(tokenizer.Tokens.All(t => !t.HasError)); + Assert.AreEqual(string.Concat(tokenizer.Tokens.Select(t => t.Text)), input); + } + + [TestMethod] + public void DothtmlTokenizer_Invalid_OpenBraceInTextWithoutSpace() + { + var input = "inline t.HasError)); Assert.AreEqual(string.Concat(tokenizer.Tokens.Select(t => t.Text)), input); } diff --git a/src/Tests/Runtime/DotvvmBindableObjectTests.cs b/src/Tests/Runtime/DotvvmBindableObjectTests.cs new file mode 100644 index 0000000000..a3f8b7b23b --- /dev/null +++ b/src/Tests/Runtime/DotvvmBindableObjectTests.cs @@ -0,0 +1,174 @@ +using System.Threading.Tasks; +using DotVVM.Framework.Binding; +using DotVVM.Framework.Compilation.ControlTree; +using DotVVM.Framework.Controls; +using DotVVM.Framework.Testing; +using DotVVM.Framework.Tests.Binding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotVVM.Framework.Tests.Runtime +{ + [TestClass] + public class DotvvmBindableObjectTests + { + readonly BindingCompilationService bindingService = DotvvmTestHelper.DefaultConfig.ServiceProvider.GetRequiredService(); + readonly DataContextStack dataContext = DataContextStack.Create(typeof(TestViewModel)); + + [TestMethod] + public void CopyProperty_Error_NotSet() + { + var source = new HtmlGenericControl("div"); + var target = new HtmlGenericControl("div"); + + var ex = Assert.ThrowsException(() => source.CopyProperty(HtmlGenericControl.VisibleProperty, target, HtmlGenericControl.VisibleProperty, throwOnFailure: true)); + StringAssert.Contains(ex.Message, "Visible is not set"); + } + + [TestMethod] + public void CopyProperty_Nop_NotSet() + { + var source = new HtmlGenericControl("div"); + var target = new HtmlGenericControl("div"); + + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, HtmlGenericControl.VisibleProperty); // throwOnFailure: false is default + Assert.IsFalse(target.IsPropertySet(HtmlGenericControl.VisibleProperty)); + } + + [TestMethod] + public void CopyProperty_Copy_Value() + { + var source = new HtmlGenericControl("div"); + source.SetValue(HtmlGenericControl.VisibleProperty, (object)false); + var target = new HtmlGenericControl("div"); + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, HtmlGenericControl.VisibleProperty); + + Assert.IsFalse(target.GetValue(HtmlGenericControl.VisibleProperty)); + Assert.AreSame(source.GetValue(HtmlGenericControl.VisibleProperty), target.GetValue(HtmlGenericControl.VisibleProperty)); + } + + [TestMethod] + public void CopyProperty_Copy_Binding() + { + var source = new HtmlGenericControl("div"); + source.DataContext = new TestViewModel { IntProp = 0 }; + source.SetValue(Internal.DataContextTypeProperty, dataContext); + source.SetValue(HtmlGenericControl.VisibleProperty, bindingService.Cache.CreateValueBinding("IntProp == 12", dataContext)); + var target = new HtmlGenericControl("div"); + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, HtmlGenericControl.VisibleProperty); + target.DataContext = source.DataContext; + + Assert.IsFalse(source.GetValue(HtmlGenericControl.VisibleProperty)); + Assert.IsFalse(target.GetValue(HtmlGenericControl.VisibleProperty)); + Assert.AreSame(source.GetValue(HtmlGenericControl.VisibleProperty), target.GetValue(HtmlGenericControl.VisibleProperty)); + } + + [TestMethod] + public void CopyProperty_EvalBinding() + { + var source = new HtmlGenericControl("div"); + source.DataContext = new TestViewModel { IntProp = 0 }; + source.SetValue(Internal.DataContextTypeProperty, dataContext); + source.SetValue(HtmlGenericControl.VisibleProperty, bindingService.Cache.CreateValueBinding("IntProp == 12", dataContext)); + + Assert.IsFalse(Button.IsSubmitButtonProperty.MarkupOptions.AllowBinding); + var target = new Button(); + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, Button.IsSubmitButtonProperty); + target.DataContext = source.DataContext; + + Assert.IsFalse(target.IsSubmitButton); + Assert.AreEqual(false, target.GetValueRaw(Button.IsSubmitButtonProperty)); + } + + [TestMethod] + public void CopyProperty_Error_ValueToBinding() + { + var source = new HtmlGenericControl("div"); + source.SetValue(HtmlGenericControl.VisibleProperty, (object)false); + Assert.IsFalse(CheckBox.CheckedProperty.MarkupOptions.AllowHardCodedValue); + var target = new CheckBox(); + + var ex = Assert.ThrowsException(() => + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, CheckBox.CheckedProperty, throwOnFailure: true)); + StringAssert.Contains(ex.Message, "Checked does not support hard coded values"); + } + + [TestMethod] + public void CopyProperty_Nop_ValueToBinding() + { + // TODO: this is a weird behavior, I'd consider changing it in a future major version + var source = new HtmlGenericControl("div"); + source.SetValue(HtmlGenericControl.VisibleProperty, (object)false); + Assert.IsFalse(CheckBox.CheckedProperty.MarkupOptions.AllowHardCodedValue); + var target = new CheckBox(); + + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, CheckBox.CheckedProperty); + Assert.IsFalse(target.IsPropertySet(CheckBox.CheckedProperty)); + } + + [TestMethod] + public void CopyProperty_Copy_InheritedBinding() + { + var sourceParent = new HtmlGenericControl("div"); + sourceParent.DataContext = new TestViewModel { IntProp = 0 }; + sourceParent.SetValue(Internal.DataContextTypeProperty, dataContext); + sourceParent.SetValue(Validation.EnabledProperty, bindingService.Cache.CreateValueBinding("IntProp == 12", dataContext)); + var source = new HtmlGenericControl("div"); + sourceParent.Children.Add(source); + + var target = new HtmlGenericControl("div"); + source.CopyProperty(Validation.EnabledProperty, target, Validation.EnabledProperty); + + Assert.AreSame(sourceParent.GetValue(Validation.EnabledProperty), target.GetValue(Validation.EnabledProperty)); + } + + [TestMethod] + public void CopyProperty_Copy_InheritedValue() + { + var sourceParent = new HtmlGenericControl("div"); + sourceParent.SetValue(Validation.EnabledProperty, (object)false); + var source = new HtmlGenericControl("div"); + sourceParent.Children.Add(source); + + var target = new HtmlGenericControl("div"); + source.CopyProperty(Validation.EnabledProperty, target, Validation.EnabledProperty); + + Assert.AreSame(sourceParent.GetValue(Validation.EnabledProperty), target.GetValue(Validation.EnabledProperty)); + } + + + [TestMethod] + public void CopyProperty_Copy_FormControlsEnabledBinding() + { + var sourceParent = new HtmlGenericControl("div"); + sourceParent.DataContext = new TestViewModel { IntProp = 0 }; + sourceParent.SetValue(Internal.DataContextTypeProperty, dataContext); + sourceParent.SetValue(FormControls.EnabledProperty, bindingService.Cache.CreateValueBinding("IntProp == 12", dataContext)); + var source = new TextBox(); + sourceParent.Children.Add(source); + + var target = new TextBox(); + source.CopyProperty(TextBox.EnabledProperty, target, TextBox.EnabledProperty); + target.DataContext = source.DataContext; + + Assert.AreSame(sourceParent.GetValue(FormControls.EnabledProperty), target.GetValue(TextBox.EnabledProperty)); + Assert.AreSame(source.GetValue(TextBox.EnabledProperty), target.GetValue(TextBox.EnabledProperty)); + } + + [TestMethod] + public void CopyProperty_Copy_FormControlsEnabledValue() + { + var sourceParent = new HtmlGenericControl("div"); + sourceParent.SetValue(FormControls.EnabledProperty, (object)false); + var source = new TextBox(); + sourceParent.Children.Add(source); + + var target = new TextBox(); + source.CopyProperty(TextBox.EnabledProperty, target, TextBox.EnabledProperty); + + Assert.AreSame(sourceParent.GetValue(FormControls.EnabledProperty), target.GetValue(TextBox.EnabledProperty)); + Assert.AreSame(source.GetValue(TextBox.EnabledProperty), target.GetValue(TextBox.EnabledProperty)); + } + + } +} diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index f0b2e8f951..06f0157cc3 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -360,6 +360,12 @@ "mappingMode": "InnerElement" } }, + "DotVVM.Framework.Controls.AlternateCultureLinks": { + "RouteName": { + "type": "System.String", + "onlyHardcoded": true + } + }, "DotVVM.Framework.Controls.AppendableDataPager": { "DataSet": { "type": "DotVVM.Framework.Controls.IPageableGridViewDataSet, DotVVM.Core", @@ -2061,6 +2067,12 @@ "baseType": "DotVVM.Framework.Controls.Decorator, DotVVM.Framework", "withoutContent": true }, + "DotVVM.Framework.Controls.AlternateCultureLinks": { + "assembly": "DotVVM.Framework", + "baseType": "DotVVM.Framework.Controls.CompositeControl, DotVVM.Framework", + "withoutContent": true, + "isComposite": true + }, "DotVVM.Framework.Controls.AppendableDataPager": { "assembly": "DotVVM.Framework", "baseType": "DotVVM.Framework.Controls.HtmlGenericControl, DotVVM.Framework",