diff --git a/src/Framework/Framework/Controls/Timer.cs b/src/Framework/Framework/Controls/Timer.cs new file mode 100644 index 0000000000..c731c2eeb6 --- /dev/null +++ b/src/Framework/Framework/Controls/Timer.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Text; +using DotVVM.Framework.Binding.Expressions; +using DotVVM.Framework.Binding; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Framework.Controls +{ + /// + /// An invisible control that periodically invokes a command. + /// + public class Timer : DotvvmControl + { + /// + /// Gets or sets the command binding that will be invoked on every tick. + /// + [MarkupOptions(AllowHardCodedValue = false, Required = true)] + public ICommandBinding Command + { + get { return (ICommandBinding)GetValue(CommandProperty)!; } + set { SetValue(CommandProperty, value); } + } + public static readonly DotvvmProperty CommandProperty + = DotvvmProperty.Register(c => c.Command, null); + + /// + /// Gets or sets the interval in milliseconds. + /// + [MarkupOptions(AllowBinding = false, Required = true)] + public int Interval + { + get { return (int)GetValue(IntervalProperty)!; } + set { SetValue(IntervalProperty, value); } + } + public static readonly DotvvmProperty IntervalProperty + = DotvvmProperty.Register(c => c.Interval, 30000); + + /// + /// Gets or sets whether the timer is enabled. + /// + public bool Enabled + { + get { return (bool)GetValue(EnabledProperty)!; } + set { SetValue(EnabledProperty, value); } + } + public static readonly DotvvmProperty EnabledProperty + = DotvvmProperty.Register(c => c.Enabled, true); + + public Timer() + { + SetValue(Validation.EnabledProperty, false); + SetValue(PostBack.ConcurrencyProperty, PostbackConcurrencyMode.Queue); + } + + protected override void RenderBeginTag(IHtmlWriter writer, IDotvvmRequestContext context) + { + var group = new KnockoutBindingGroup(); + group.Add("command", KnockoutHelper.GenerateClientPostbackLambda("Command", Command, this)); + group.Add("interval", Interval.ToString()); + group.Add("enabled", this, EnabledProperty); + writer.WriteKnockoutDataBindComment("dotvvm-timer", group.ToString()); + + base.RenderBeginTag(writer, context); + } + + protected override void RenderEndTag(IHtmlWriter writer, IDotvvmRequestContext context) + { + base.RenderEndTag(writer, context); + + writer.WriteKnockoutDataBindEndComment(); + } + } +} diff --git a/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts b/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts index 11c0ed71f7..95850ce761 100644 --- a/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts +++ b/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts @@ -12,6 +12,7 @@ import fileUpload from './file-upload' import jsComponents from './js-component' import modalDialog from './modal-dialog' import appendableDataPager from './appendable-data-pager' +import timer from './timer' type KnockoutHandlerDictionary = { [name: string]: KnockoutBindingHandler @@ -30,7 +31,8 @@ const allHandlers: KnockoutHandlerDictionary = { ...fileUpload, ...jsComponents, ...modalDialog, - ...appendableDataPager + ...appendableDataPager, + ...timer } export default allHandlers diff --git a/src/Framework/Framework/Resources/Scripts/binding-handlers/timer.ts b/src/Framework/Framework/Resources/Scripts/binding-handlers/timer.ts new file mode 100644 index 0000000000..3385d17709 --- /dev/null +++ b/src/Framework/Framework/Resources/Scripts/binding-handlers/timer.ts @@ -0,0 +1,46 @@ +type TimerProps = { + interval: number, + enabled: KnockoutObservable, + command: () => Promise +} + +ko.virtualElements.allowedBindings["dotvvm-timer"] = true; + +export default { + "dotvvm-timer": { + init: (element: HTMLElement, valueAccessor: () => TimerProps) => { + const prop = valueAccessor(); + let timer: number | null = null; + + const observable = ko.isObservable(prop.enabled) ? prop.enabled : ko.pureComputed(() => ko.unwrap(valueAccessor().enabled)); + const subscription = observable.subscribe(newValue => createOrDestroyTimer(newValue)); + createOrDestroyTimer(ko.unwrap(prop.enabled)); + + function createOrDestroyTimer(enabled: boolean) { + if (enabled) { + if (timer) { + window.clearTimeout(timer); + } + + const callback = async () => { + try { + await prop.command.bind(element)(); + } catch (err) { + dotvvm.log.logError("postback", err); + } + timer = window.setTimeout(callback, prop.interval); + }; + timer = window.setTimeout(callback, prop.interval); + + } else if (timer) { + window.clearTimeout(timer); + } + }; + + ko.utils.domNodeDisposal.addDisposeCallback(element, () => { + subscription.dispose(); + createOrDestroyTimer(false); + }); + } + } +}; diff --git a/src/Samples/Common/ViewModels/ControlSamples/Timer/LongCommandViewModel.cs b/src/Samples/Common/ViewModels/ControlSamples/Timer/LongCommandViewModel.cs new file mode 100644 index 0000000000..3afc1438af --- /dev/null +++ b/src/Samples/Common/ViewModels/ControlSamples/Timer/LongCommandViewModel.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Samples.Common.ViewModels.ControlSamples.Timer +{ + public class LongCommandViewModel : DotvvmViewModelBase + { + public int Value { get; set; } + + public async Task LongCommand() + { + await Task.Delay(3000); + Value++; + } + } +} + diff --git a/src/Samples/Common/ViewModels/ControlSamples/Timer/RemovalViewModel.cs b/src/Samples/Common/ViewModels/ControlSamples/Timer/RemovalViewModel.cs new file mode 100644 index 0000000000..0736663ca1 --- /dev/null +++ b/src/Samples/Common/ViewModels/ControlSamples/Timer/RemovalViewModel.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Samples.Common.ViewModels.ControlSamples.Timer +{ + public class RemovalViewModel : DotvvmViewModelBase + { + public bool Disabled { get; set; } = false; + + public int Value { get; set; } + + public bool IsRemoved { get; set; } = false; + + } +} + diff --git a/src/Samples/Common/ViewModels/ControlSamples/Timer/TimerViewModel.cs b/src/Samples/Common/ViewModels/ControlSamples/Timer/TimerViewModel.cs new file mode 100644 index 0000000000..0eca33fa59 --- /dev/null +++ b/src/Samples/Common/ViewModels/ControlSamples/Timer/TimerViewModel.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Samples.Common.ViewModels.ControlSamples.Timer +{ + public class TimerViewModel : DotvvmViewModelBase + { + public int Value1 { get; set; } + public int Value2 { get; set; } + public int Value3 { get; set; } + + public bool Enabled1 { get; set; } = true; + public bool Enabled2 { get; set; } + } +} + diff --git a/src/Samples/Common/Views/ControlSamples/Timer/LongCommand.dothtml b/src/Samples/Common/Views/ControlSamples/Timer/LongCommand.dothtml new file mode 100644 index 0000000000..8a516a33cf --- /dev/null +++ b/src/Samples/Common/Views/ControlSamples/Timer/LongCommand.dothtml @@ -0,0 +1,20 @@ +@viewModel DotVVM.Samples.Common.ViewModels.ControlSamples.Timer.LongCommandViewModel, DotVVM.Samples.Common + + + + + + + + + + +

Timer with long command

+ +

{{value: Value}}

+ + + + + + diff --git a/src/Samples/Common/Views/ControlSamples/Timer/Removal.dothtml b/src/Samples/Common/Views/ControlSamples/Timer/Removal.dothtml new file mode 100644 index 0000000000..8ebbbc47cb --- /dev/null +++ b/src/Samples/Common/Views/ControlSamples/Timer/Removal.dothtml @@ -0,0 +1,23 @@ +@viewModel DotVVM.Samples.Common.ViewModels.ControlSamples.Timer.RemovalViewModel, DotVVM.Samples.Common + + + + + + + + + +

Making sure that removing timer disposes the callback

+ +

{{value: Value}}

+ + + + + + + + + diff --git a/src/Samples/Common/Views/ControlSamples/Timer/Timer.dothtml b/src/Samples/Common/Views/ControlSamples/Timer/Timer.dothtml new file mode 100644 index 0000000000..86cfc735b1 --- /dev/null +++ b/src/Samples/Common/Views/ControlSamples/Timer/Timer.dothtml @@ -0,0 +1,50 @@ +@viewModel DotVVM.Samples.Common.ViewModels.ControlSamples.Timer.TimerViewModel, DotVVM.Samples.Common + + + + + + + + + + +

Timer

+ +
+

Timer 1 - enabled from the start

+

+ Value: {{value: Value1}} +

+

+ +

+ + +
+ +
+

Timer 2 - disabled from the start

+

+ Value: {{value: Value2}} +

+

+ +

+ + +
+ +
+

Timer 3 - without Enabled property

+

+ Value: {{value: Value3}} +

+ + +
+ + + + + diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index bd65692afc..ef89e8cc85 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -18,7 +18,6 @@ public partial class SamplesRouteUrls public const string ComplexSamples_ChangedEvent_ChangedEvent = "ComplexSamples/ChangedEvent/ChangedEvent"; public const string ComplexSamples_ClassBindings_ClassBindings = "ComplexSamples/ClassBindings/ClassBindings"; public const string ComplexSamples_EmptyDataTemplate_RepeaterGridView = "ComplexSamples/EmptyDataTemplate/RepeaterGridView"; - public const string ComplexSamples_EventPropagation_EventPropagation = "ComplexSamples/EventPropagation/EventPropagation"; public const string ComplexSamples_FileUploadInRepeater_FileUploadInRepeater = "ComplexSamples/FileUploadInRepeater/FileUploadInRepeater"; public const string ComplexSamples_GridViewDataSet_GridViewDataSet = "ComplexSamples/GridViewDataSet/GridViewDataSet"; public const string ComplexSamples_InvoiceCalculator_InvoiceCalculator = "ComplexSamples/InvoiceCalculator/InvoiceCalculator"; @@ -153,6 +152,9 @@ public partial class SamplesRouteUrls public const string ControlSamples_TextBox_TextBox_FormatDoubleProperty = "ControlSamples/TextBox/TextBox_FormatDoubleProperty"; public const string ControlSamples_TextBox_TextBox_Format_Binding = "ControlSamples/TextBox/TextBox_Format_Binding"; public const string ControlSamples_TextBox_TextBox_Types = "ControlSamples/TextBox/TextBox_Types"; + public const string ControlSamples_Timer_LongCommand = "ControlSamples/Timer/LongCommand"; + public const string ControlSamples_Timer_Removal = "ControlSamples/Timer/Removal"; + public const string ControlSamples_Timer_Timer = "ControlSamples/Timer/Timer"; public const string ControlSamples_UpdateProgress_UpdateProgress = "ControlSamples/UpdateProgress/UpdateProgress"; public const string ControlSamples_UpdateProgress_UpdateProgressDelay = "ControlSamples/UpdateProgress/UpdateProgressDelay"; public const string ControlSamples_UpdateProgress_UpdateProgressQueues = "ControlSamples/UpdateProgress/UpdateProgressQueues"; @@ -269,6 +271,7 @@ 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_CommandInsideWhere = "FeatureSamples/JavascriptTranslation/CommandInsideWhere"; 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"; @@ -276,8 +279,8 @@ public partial class SamplesRouteUrls 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_TimeOnlyTranslations = "FeatureSamples/JavascriptTranslation/TimeOnlyTranslations"; public const string FeatureSamples_JavascriptTranslation_WebUtilityTranslations = "FeatureSamples/JavascriptTranslation/WebUtilityTranslations"; public const string FeatureSamples_JsComponentIntegration_ReactComponentIntegration = "FeatureSamples/JsComponentIntegration/ReactComponentIntegration"; public const string FeatureSamples_JsComponentIntegration_SvelteComponentIntegration = "FeatureSamples/JsComponentIntegration/SvelteComponentIntegration"; diff --git a/src/Samples/Tests/Tests/Control/TimerTests.cs b/src/Samples/Tests/Tests/Control/TimerTests.cs new file mode 100644 index 0000000000..6fd2a53364 --- /dev/null +++ b/src/Samples/Tests/Tests/Control/TimerTests.cs @@ -0,0 +1,162 @@ +using System; +using DotVVM.Samples.Tests.Base; +using Riganti.Selenium.Core; +using DotVVM.Testing.Abstractions; +using Xunit; +using Xunit.Abstractions; +using OpenQA.Selenium.Interactions; +using OpenQA.Selenium; +using Riganti.Selenium.Core.Abstractions; + +namespace DotVVM.Samples.Tests.Control +{ + public class TimerTests : AppSeleniumTest + { + public TimerTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Control_Timer_Timer_Timer1() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_Timer_Timer); + + var value = browser.Single("[data-ui=value1]"); + + // ensure the first timer is running + Assert.True(EqualsWithTolerance(0, int.Parse(value.GetInnerText()), 1)); + browser.Wait(3000); + Assert.True(EqualsWithTolerance(3, int.Parse(value.GetInnerText()), 1)); + browser.Wait(3000); + Assert.True(EqualsWithTolerance(6, int.Parse(value.GetInnerText()), 1)); + + // stop the first timer + browser.Single("[data-ui=enabled1]").Click(); + browser.Wait(3000); + Assert.True(EqualsWithTolerance(6, int.Parse(value.GetInnerText()), 1)); + browser.Wait(3000); + Assert.True(EqualsWithTolerance(6, int.Parse(value.GetInnerText()), 1)); + + // restart the timer + browser.Single("[data-ui=enabled1]").Click(); + browser.Wait(3000); + Assert.True(EqualsWithTolerance(9, int.Parse(value.GetInnerText()), 1)); + browser.Wait(3000); + Assert.True(EqualsWithTolerance(12, int.Parse(value.GetInnerText()), 1)); + }); + } + + [Fact] + public void Control_Timer_Timer_Timer2() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_Timer_Timer); + + var value = browser.Single("[data-ui=value2]"); + + // ensure the timer is not running + Assert.True(EqualsWithTolerance(0, int.Parse(value.GetInnerText()), 1)); + browser.Wait(3000); + Assert.True(EqualsWithTolerance(0, int.Parse(value.GetInnerText()), 1)); + browser.Wait(3000); + Assert.True(EqualsWithTolerance(0, int.Parse(value.GetInnerText()), 1)); + + // start the second timer + browser.Single("[data-ui=enabled2]").Click(); + browser.Wait(4000); + Assert.True(EqualsWithTolerance(2, int.Parse(value.GetInnerText()), 1)); + browser.Wait(4000); + Assert.True(EqualsWithTolerance(4, int.Parse(value.GetInnerText()), 1)); + + // stop the second timer + browser.Single("[data-ui=enabled2]").Click(); + browser.Wait(4000); + Assert.True(EqualsWithTolerance(4, int.Parse(value.GetInnerText()), 1)); + browser.Wait(4000); + Assert.True(EqualsWithTolerance(4, int.Parse(value.GetInnerText()), 1)); + }); + } + + + [Fact] + public void Control_Timer_Timer_Timer3() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_Timer_Timer); + + var value = browser.Single("[data-ui=value3]"); + + // ensure the timer is running + Assert.True(EqualsWithTolerance(0, int.Parse(value.GetInnerText()), 1)); + browser.Wait(3000); + Assert.True(EqualsWithTolerance(1, int.Parse(value.GetInnerText()), 1)); + browser.Wait(3000); + Assert.True(EqualsWithTolerance(2, int.Parse(value.GetInnerText()), 1)); + }); + } + + [Fact] + public void Control_Timer_LongCommand() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_Timer_LongCommand); + + var value = browser.Single(".result"); + + // ensure the new command does not start before the old finishes + Assert.True(EqualsWithTolerance(0, int.Parse(value.GetInnerText()), 1)); + browser.Wait(4000); + + Assert.True(EqualsWithTolerance(1, int.Parse(value.GetInnerText()), 1)); + browser.Wait(4000); + + Assert.True(EqualsWithTolerance(2, int.Parse(value.GetInnerText()), 1)); + browser.Wait(4000); + + Assert.True(EqualsWithTolerance(3, int.Parse(value.GetInnerText()), 1)); + }); + } + + + [Fact] + public void Control_Timer_Removal() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_Timer_Removal); + + var value = browser.Single(".result"); + + // ensure the timer works + Assert.True(EqualsWithTolerance(0, int.Parse(value.GetInnerText()), 1)); + browser.Wait(1000); + + Assert.True(EqualsWithTolerance(1, int.Parse(value.GetInnerText()), 1)); + browser.Wait(1000); + + // disable the timer + browser.Single("disabled", SelectByDataUi).Click(); + Assert.True(EqualsWithTolerance(2, int.Parse(value.GetInnerText()), 1)); + browser.Wait(2000); + Assert.True(EqualsWithTolerance(2, int.Parse(value.GetInnerText()), 1)); + + // remove the timer from DOM + browser.Single("remove", SelectByDataUi).Click(); + Assert.True(EqualsWithTolerance(2, int.Parse(value.GetInnerText()), 1)); + browser.Wait(2000); + Assert.True(EqualsWithTolerance(2, int.Parse(value.GetInnerText()), 1)); + + // reenable the timer + browser.Single("disabled", SelectByDataUi).Click(); + + // make sure it hasn't started + Assert.True(EqualsWithTolerance(2, int.Parse(value.GetInnerText()), 1)); + browser.Wait(2000); + Assert.True(EqualsWithTolerance(2, int.Parse(value.GetInnerText()), 1)); + }); + } + + private static bool EqualsWithTolerance(int expected, int actual, int tolerance) + => Math.Abs(expected - actual) <= tolerance; + } +} diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index 5e8af402a2..90bf408dce 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -1631,6 +1631,23 @@ "onlyHardcoded": true } }, + "DotVVM.Framework.Controls.Timer": { + "Command": { + "type": "DotVVM.Framework.Binding.Expressions.ICommandBinding, DotVVM.Framework", + "required": true, + "onlyBindings": true + }, + "Enabled": { + "type": "System.Boolean", + "defaultValue": true + }, + "Interval": { + "type": "System.Int32", + "defaultValue": 30000, + "required": true, + "onlyHardcoded": true + } + }, "DotVVM.Framework.Controls.UITests": { "GenerateStub": { "type": "System.Boolean", @@ -2329,6 +2346,9 @@ "baseType": "DotVVM.Framework.Controls.HtmlGenericControl, DotVVM.Framework", "withoutContent": true }, + "DotVVM.Framework.Controls.Timer": { + "baseType": "DotVVM.Framework.Controls.DotvvmControl, DotVVM.Framework" + }, "DotVVM.Framework.Controls.UpdateProgress": { "baseType": "DotVVM.Framework.Controls.HtmlGenericControl, DotVVM.Framework" },