diff --git a/src/.vscode/launch.json b/src/.vscode/launch.json index 0727053186..d38883552e 100644 --- a/src/.vscode/launch.json +++ b/src/.vscode/launch.json @@ -1,6 +1,7 @@ { "version": "0.2.0", "configurations": [ + // { // "name": "Tests", // "type": "coreclr", @@ -57,6 +58,19 @@ "type": "coreclr", "request": "attach", "processId": "${command:pickProcess}" + }, + { + "type": "node", + "request": "launch", + "name": "Jest Tests", + "cwd": "${workspaceFolder}/DotVVM.Framework", + "program": "node_modules/jest/bin/jest.js", + "args": [ + //"Resources/Scripts/tests/postback.test.ts", + "-i" + ], + "internalConsoleOptions": "openOnSessionStart", + "envFile": "${workspaceRoot}/.env" } ] } \ No newline at end of file diff --git a/src/.vscode/tasks.json b/src/.vscode/tasks.json index 5b02ce74cd..e35762506c 100644 --- a/src/.vscode/tasks.json +++ b/src/.vscode/tasks.json @@ -1,32 +1,38 @@ { // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format - "version": "0.1.0", + "version": "2.0.0", "command": "dotnet", - "isShellCommand": true, + "type": "shell", "args": [ ], "tasks": [ { - "taskName": "build", + "label": "build", "args": [ "DotVVM.Samples.BasicSamples.AspNetCore/", "/p:GenerateFullPaths=true" ], - "isBuildCommand": true, - "showOutput": "silent", + "group": "build", + "presentation": { + "reveal": "silent" + }, "problemMatcher": "$msCompile" }, { - "taskName": "build-cli", + "label": "build-cli", "command": "build", "args": [ "DotVVM.CommandLine" ], - "isBuildCommand": true, - "showOutput": "always", + "group": "build", + "presentation": { + "reveal": "silent" + }, "problemMatcher": "$msCompile" }, { - "taskName": "build-tests", + "label": "build-tests", "command": "build", "args": [ "DotVVM.Framework.Tests.Common" ], - "isBuildCommand": true, - "showOutput": "always", + "group": "build", + "presentation": { + "reveal": "silent" + }, "problemMatcher": "$msCompile" } ] diff --git a/src/DotVVM.Framework.Tests.Common/Binding/StaticCommandCompilationTests.cs b/src/DotVVM.Framework.Tests.Common/Binding/StaticCommandCompilationTests.cs index f5da87466d..66387c42c5 100644 --- a/src/DotVVM.Framework.Tests.Common/Binding/StaticCommandCompilationTests.cs +++ b/src/DotVVM.Framework.Tests.Common/Binding/StaticCommandCompilationTests.cs @@ -58,14 +58,14 @@ public string CompileBinding(string expression, Type[] contexts, Type expectedTy public void StaticCommandCompilation_SimpleCommand() { var result = CompileBinding("StaticCommands.GetLength(StringProp)", typeof(TestViewModel)); - Assert.AreEqual("(function(a,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[b.$data.StringProp()]).then(function(r_0){resolve(r_0);},reject);});}(this,ko.contextFor(this)))", result); + Assert.AreEqual("(function(a,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[b.$data.StringProp()],options).then(function(r_0){resolve(r_0);},reject);});}(this,ko.contextFor(this)))", result); } [TestMethod] public void StaticCommandCompilation_AssignedCommand() { var result = CompileBinding("StringProp = StaticCommands.GetLength(StringProp).ToString()", typeof(TestViewModel)); - Assert.AreEqual("(function(a,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[b.$data.StringProp()]).then(function(r_0){resolve(b.$data.StringProp(dotvvm.globalize.bindingNumberToString(r_0)()).StringProp());},reject);});}(this,ko.contextFor(this)))", result); + Assert.AreEqual("(function(a,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[b.$data.StringProp()],options).then(function(r_0){resolve(b.$data.StringProp(dotvvm.globalize.bindingNumberToString(r_0)()).StringProp());},reject);});}(this,ko.contextFor(this)))", result); } [TestMethod] @@ -79,21 +79,21 @@ public void StaticCommandCompilation_JsOnlyCommand() public void StaticCommandCompilation_ChainedCommands() { var result = CompileBinding("StringProp = StaticCommands.GetLength(StaticCommands.GetLength(StringProp).ToString()).ToString()", typeof(TestViewModel)); - Assert.AreEqual("(function(a,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[b.$data.StringProp()]).then(function(r_0){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[dotvvm.globalize.bindingNumberToString(r_0)()]).then(function(r_1){resolve(b.$data.StringProp(dotvvm.globalize.bindingNumberToString(r_1)()).StringProp());},reject);},reject);});}(this,ko.contextFor(this)))", result); + Assert.AreEqual("(function(a,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[b.$data.StringProp()],options).then(function(r_0){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[dotvvm.globalize.bindingNumberToString(r_0)()],options).then(function(r_1){resolve(b.$data.StringProp(dotvvm.globalize.bindingNumberToString(r_1)()).StringProp());},reject);},reject);});}(this,ko.contextFor(this)))", result); } [TestMethod] public void StaticCommandCompilation_ChainedCommandsWithSemicolon() { var result = CompileBinding("StringProp = StaticCommands.GetLength(StringProp).ToString(); StringProp = StaticCommands.GetLength(StringProp).ToString()", typeof(TestViewModel)); - Assert.AreEqual("(function(a,c,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[c.$data.StringProp()]).then(function(r_0){(b=c.$data.StringProp(dotvvm.globalize.bindingNumberToString(r_0)()).StringProp(),dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[c.$data.StringProp()]).then(function(r_1){resolve((b,c.$data.StringProp(dotvvm.globalize.bindingNumberToString(r_1)()).StringProp()));},reject));},reject);});}(this,ko.contextFor(this)))", result); + Assert.AreEqual("(function(a,c,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[c.$data.StringProp()],options).then(function(r_0){(b=c.$data.StringProp(dotvvm.globalize.bindingNumberToString(r_0)()).StringProp(),dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0TGVuZ3RoIixbXSwiQUE9PSJd\",[c.$data.StringProp()],options).then(function(r_1){resolve((b,c.$data.StringProp(dotvvm.globalize.bindingNumberToString(r_1)()).StringProp()));},reject));},reject);});}(this,ko.contextFor(this)))", result); } [TestMethod] public void StaticCommandCompilation_DateTimeResultAssignment() { var result = CompileBinding("DateFrom = StaticCommands.GetDate()", typeof(TestViewModel)); - Assert.AreEqual("(function(a,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0RGF0ZSIsW10sIiJd\",[]).then(function(r_0){resolve(b.$data.DateFrom(dotvvm.serialization.serializeDate(r_0,false)).DateFrom());},reject);});}(this,ko.contextFor(this)))", result); + Assert.AreEqual("(function(a,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuU3RhdGljQ29tbWFuZHMsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiR2V0RGF0ZSIsW10sIiJd\",[],options).then(function(r_0){resolve(b.$data.DateFrom(dotvvm.serialization.serializeDate(r_0,false)).DateFrom());},reject);});}(this,ko.contextFor(this)))", result); } [TestMethod] @@ -115,7 +115,7 @@ public void StaticCommandCompilation_PossibleAmniguousMatch() { var result = CompileBinding("SomeString = injectedService.Load(SomeString)", new[] { typeof(TestViewModel3) }, typeof(Func)); - Assert.AreEqual("(function(a,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuVGVzdFNlcnZpY2UsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiTG9hZCIsW10sIkFRQT0iXQ==\",[b.$data.SomeString()]).then(function(r_0){resolve(b.$data.SomeString(r_0).SomeString());},reject);});}(this,ko.contextFor(this)))", result); + Assert.AreEqual("(function(a,b){return new Promise(function(resolve,reject){dotvvm.staticCommandPostback(a,\"WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuVGVzdFNlcnZpY2UsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMuQ29tbW9uIiwiTG9hZCIsW10sIkFRQT0iXQ==\",[b.$data.SomeString()],options).then(function(r_0){resolve(b.$data.SomeString(r_0).SomeString());},reject);});}(this,ko.contextFor(this)))", result); } [TestMethod] diff --git a/src/DotVVM.Framework/Binding/Expressions/CommandBindingExpression.cs b/src/DotVVM.Framework/Binding/Expressions/CommandBindingExpression.cs index ef3d899217..7530d0c108 100644 --- a/src/DotVVM.Framework/Binding/Expressions/CommandBindingExpression.cs +++ b/src/DotVVM.Framework/Binding/Expressions/CommandBindingExpression.cs @@ -66,6 +66,7 @@ public ExpectedTypeBindingProperty GetExpectedType(AssignedPropertyBindingProper } } + public static CodeSymbolicParameter PostbackOptionsParameter = new CodeSymbolicParameter("CommandBindingExpression.PostbackOptionsParameter"); public static CodeSymbolicParameter SenderElementParameter = new CodeSymbolicParameter("CommandBindingExpression.SenderElementParameter"); public static CodeSymbolicParameter CurrentPathParameter = new CodeSymbolicParameter("CommandBindingExpression.CurrentPathParameter"); public static CodeSymbolicParameter CommandIdParameter = new CodeSymbolicParameter("CommandBindingExpression.CommandIdParameter"); diff --git a/src/DotVVM.Framework/Compilation/Binding/StaticCommandBindingCompiler.cs b/src/DotVVM.Framework/Compilation/Binding/StaticCommandBindingCompiler.cs index 1011edd66e..27a8dffb55 100644 --- a/src/DotVVM.Framework/Compilation/Binding/StaticCommandBindingCompiler.cs +++ b/src/DotVVM.Framework/Compilation/Binding/StaticCommandBindingCompiler.cs @@ -188,7 +188,7 @@ protected virtual JsExpression CompileMethodCall(MethodCallExpression methodExpr var encryptedPlan = EncryptJson(SerializePlan(plan), protector).Apply(Convert.ToBase64String); return new JsIdentifierExpression("dotvvm").Member("staticCommandPostback") - .Invoke(new JsSymbolicParameter(CommandBindingExpression.SenderElementParameter), new JsLiteral(encryptedPlan), new JsArrayExpression(args)) + .Invoke(new JsSymbolicParameter(CommandBindingExpression.SenderElementParameter), new JsLiteral(encryptedPlan), new JsArrayExpression(args), new JsSymbolicParameter(CommandBindingExpression.PostbackOptionsParameter)) .Member("then") .Invoke(callbackFunction, errorCallback) .WithAnnotation(new StaticCommandInvocationJsAnnotation(plan)); diff --git a/src/DotVVM.Framework/Controls/KnockoutHelper.cs b/src/DotVVM.Framework/Controls/KnockoutHelper.cs index 2beb0f87e5..eb5c0f3fb7 100644 --- a/src/DotVVM.Framework/Controls/KnockoutHelper.cs +++ b/src/DotVVM.Framework/Controls/KnockoutHelper.cs @@ -162,6 +162,7 @@ string getHandlerScript() default; var call = adjustedExpression.ToString(p => + p == CommandBindingExpression.PostbackOptionsParameter ? new CodeParameterAssignment("options", OperatorPrecedence.Max) : p == CommandBindingExpression.SenderElementParameter ? options.ElementAccessor : p == CommandBindingExpression.CurrentPathParameter ? new CodeParameterAssignment( getContextPath(control), @@ -176,7 +177,7 @@ string getHandlerScript() default(CodeParameterAssignment) ); if (generatedPostbackHandlers == null && options.AllowPostbackHandlers) - return $"dotvvm.applyPostbackHandlers(function(){{return {call}}}.bind(this),{options.ElementAccessor.Code!.ToString(e => default(CodeParameterAssignment))},{getHandlerScript()})"; + return $"dotvvm.applyPostbackHandlers(function(options){{return {call}}}.bind(this),{options.ElementAccessor.Code!.ToString(e => default(CodeParameterAssignment))},{getHandlerScript()})"; else return call; } diff --git a/src/DotVVM.Framework/Resources/Scripts/createPostbackArgs.ts b/src/DotVVM.Framework/Resources/Scripts/createPostbackArgs.ts deleted file mode 100644 index 07776c3ddc..0000000000 --- a/src/DotVVM.Framework/Resources/Scripts/createPostbackArgs.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function createPostbackArgs(options: PostbackOptions) { - return { - postbackClientId: options.postbackId, - viewModel: options.viewModel, - sender: options.sender, - postbackOptions: options - }; -} diff --git a/src/DotVVM.Framework/Resources/Scripts/events.ts b/src/DotVVM.Framework/Resources/Scripts/events.ts index 41e7b5abc9..440a34b7b2 100644 --- a/src/DotVVM.Framework/Resources/Scripts/events.ts +++ b/src/DotVVM.Framework/Resources/Scripts/events.ts @@ -46,20 +46,20 @@ export class DotvvmEvent { } } -export const init = new DotvvmEvent("dotvvm.events.init", true); +export const init = new DotvvmEvent("dotvvm.events.init", true); export const beforePostback = new DotvvmEvent("dotvvm.events.beforePostback"); export const afterPostback = new DotvvmEvent("dotvvm.events.afterPostback"); export const error = new DotvvmEvent("dotvvm.events.error"); export const redirect = new DotvvmEvent("dotvvm.events.redirect"); -export const postbackHandlersStarted = new DotvvmEvent<{}>("dotvvm.events.postbackHandlersStarted"); -export const postbackHandlersCompleted = new DotvvmEvent<{}>("dotvvm.events.postbackHandlersCompleted"); -export const postbackResponseReceived = new DotvvmEvent<{}>("dotvvm.events.postbackResponseReceived"); -export const postbackCommitInvoked = new DotvvmEvent<{}>("dotvvm.events.postbackCommitInvoked"); -export const postbackViewModelUpdated = new DotvvmEvent<{}>("dotvvm.events.postbackViewModelUpdated"); -export const postbackRejected = new DotvvmEvent<{}>("dotvvm.events.postbackRejected"); -export const staticCommandMethodInvoking = new DotvvmEvent<{ args: any[], command: string }>("dotvvm.events.staticCommandMethodInvoking"); -export const staticCommandMethodInvoked = new DotvvmEvent<{ args: any[], command: string, result: any, xhr: XMLHttpRequest }>("dotvvm.events.staticCommandMethodInvoked"); -export const staticCommandMethodFailed = new DotvvmEvent<{ args: any[], command: string, xhr: XMLHttpRequest, error?: any }>("dotvvm.events.staticCommandMethodInvoked"); +export const postbackHandlersStarted = new DotvvmEvent("dotvvm.events.postbackHandlersStarted"); +export const postbackHandlersCompleted = new DotvvmEvent("dotvvm.events.postbackHandlersCompleted"); +export const postbackResponseReceived = new DotvvmEvent("dotvvm.events.postbackResponseReceived"); +export const postbackCommitInvoked = new DotvvmEvent("dotvvm.events.postbackCommitInvoked"); +export const postbackViewModelUpdated = new DotvvmEvent("dotvvm.events.postbackViewModelUpdated"); +export const postbackRejected = new DotvvmEvent("dotvvm.events.postbackRejected"); +export const staticCommandMethodInvoking = new DotvvmEvent("dotvvm.events.staticCommandMethodInvoking"); +export const staticCommandMethodInvoked = new DotvvmEvent("dotvvm.events.staticCommandMethodInvoked"); +export const staticCommandMethodFailed = new DotvvmEvent("dotvvm.events.staticCommandMethodInvoked"); class DotvvmEventHandler { constructor(public handler: (f: T) => void, public isOneTime: boolean) { diff --git a/src/DotVVM.Framework/Resources/Scripts/global-declarations.ts b/src/DotVVM.Framework/Resources/Scripts/global-declarations.ts index 9473a46131..d4972e18ee 100644 --- a/src/DotVVM.Framework/Resources/Scripts/global-declarations.ts +++ b/src/DotVVM.Framework/Resources/Scripts/global-declarations.ts @@ -8,85 +8,120 @@ type DotvvmPostbackHandler = { after?: Array before?: Array } -type PostbackRejectionReason = - | { type: "handler", handlerName: string, message?: string } + +type DotvvmPostbackErrorLike = { + readonly reason: DotvvmPostbackErrorReason +} + +type DotvvmPostbackErrorReason = + | { type: 'handler', handlerName: string, message?: string } | { type: 'network', err?: any } | { type: 'gate' } | { type: 'commit', args?: DotvvmErrorEventArgs } | { type: 'csrfToken' } | { type: 'serverError', status?: number, responseObject: any, response?: Response } | { type: 'event' } + | { type: 'validation', responseObject: any, response?: Response } & { options?: PostbackOptions } -interface AdditionalPostbackData { - [key: string]: any - validationTargetPath?: string -} +type PostbackCommandType = "postback" | "staticCommand" | "spaNavigation" type PostbackOptions = { - readonly additionalPostbackData: AdditionalPostbackData readonly postbackId: number - readonly sender?: HTMLElement + readonly commandType: PostbackCommandType readonly args: any[] + readonly sender?: HTMLElement readonly viewModel?: any + serverResponseObject?: any + validationTargetPath?: string } -type PostbackEventArgs = DotvvmEventArgs & { - readonly postbackClientId: number - readonly sender?: Element - readonly xhr?: XMLHttpRequest - readonly serverResponseObject?: any - readonly postbackOptions: PostbackOptions -} - -type DotvvmEventArgs = { - /** The global view model */ - readonly viewModel?: any -} - -type DotvvmErrorEventArgs = { - readonly sender?: Element - readonly serverResponseObject?: any - readonly viewModel?: any - readonly isSpaNavigationError?: true +type DotvvmErrorEventArgs = PostbackOptions & { + readonly response?: Response + readonly error: DotvvmPostbackErrorLike handled: boolean } -type DotvvmBeforePostBackEventArgs = PostbackEventArgs & { +type DotvvmBeforePostBackEventArgs = PostbackOptions & { cancel: boolean } -type DotvvmAfterPostBackEventArgs = PostbackEventArgs & { - handled: boolean + +type DotvvmAfterPostBackEventArgs = PostbackOptions & { /** Set to true in case the postback did not finish and it was cancelled by an event or a postback handler */ - readonly wasInterrupted: boolean; - readonly serverResponseObject: any - readonly commandResult: any + readonly wasInterrupted?: boolean; + readonly commandResult?: any readonly response?: Response - /** In SPA mode, this promise is set when the result of a postback is a redirection. */ - readonly redirectPromise?: Promise + readonly error?: DotvvmPostbackErrorLike } -type DotvvmSpaNavigatingEventArgs = DotvvmEventArgs & { - /** When set to true by an event handler, it */ - cancel: boolean - /** The url we are navigating to */ - readonly newUrl: string + +type DotvvmNavigationEventArgs = PostbackOptions & { + readonly url: string } -type DotvvmNavigationEventArgs = DotvvmEventArgs & { - readonly serverResponseObject: any - readonly xhr?: XMLHttpRequest // TODO: - readonly isSpa?: true + +type DotvvmSpaNavigatingEventArgs = DotvvmNavigationEventArgs & { + cancel: boolean } + type DotvvmSpaNavigatedEventArgs = DotvvmNavigationEventArgs & { - /** When error occurs, this is set to false and gives the event handlers a possibility to mark the error as handled */ - isHandled: boolean + readonly response?: Response } -type DotvvmRedirectEventArgs = DotvvmEventArgs & { - /** The url of the page we are navigating to */ - readonly url: string + +type DotvvmSpaNavigationFailedEventArgs = DotvvmNavigationEventArgs & { + readonly response?: Response + readonly error?: DotvvmPostbackErrorLike +} + +type DotvvmRedirectEventArgs = DotvvmNavigationEventArgs & { + readonly response?: Response /** Whether the new url should replace the current url in the browsing history */ readonly replace: boolean } +type DotvvmPostbackHandlersStartedEventArgs = PostbackOptions & { +} + +type DotvvmPostbackHandlersCompletedEventArgs = PostbackOptions & { +} + +type DotvvmPostbackResponseReceivedEventArgs = PostbackOptions & { + readonly response: Response +} + +type DotvvmPostbackCommitInvokedEventArgs = PostbackOptions & { + readonly response: Response +} + +type DotvvmPostbackViewModelUpdatedEventArgs = PostbackOptions & { + readonly response: Response +} + +type DotvvmPostbackRejectedEventArgs = PostbackOptions & { + readonly error: DotvvmPostbackErrorLike +} + +type DotvvmStaticCommandMethodEventArgs = PostbackOptions & { + readonly methodId: string + readonly methodArgs: any[] +} + +type DotvvmStaticCommandMethodInvokingEventArgs = DotvvmStaticCommandMethodEventArgs & { +} + +type DotvvmStaticCommandMethodInvokedEventArgs = DotvvmStaticCommandMethodEventArgs & { + readonly result: any + readonly response?: Response +} + +type DotvvmStaticCommandMethodFailedEventArgs = DotvvmStaticCommandMethodEventArgs & { + readonly result?: any + readonly response?: Response + readonly error: DotvvmPostbackErrorLike +} + +type DotvvmInitEventArgs = { + readonly viewModel: any +} + interface DotvvmViewModelInfo { viewModel?: any viewModelCacheId?: string diff --git a/src/DotVVM.Framework/Resources/Scripts/postback/counter.ts b/src/DotVVM.Framework/Resources/Scripts/postback/counter.ts index fb1ae424ea..1cc31349a1 100644 --- a/src/DotVVM.Framework/Resources/Scripts/postback/counter.ts +++ b/src/DotVVM.Framework/Resources/Scripts/postback/counter.ts @@ -3,11 +3,3 @@ var postBackCounter: number = 0; export function backUpPostBackCounter(): number { return ++postBackCounter; } - -export function isPostBackStillActive(currentPostBackCounter: number): boolean { - return postBackCounter === currentPostBackCounter; -} - -export function resetPostBackCounter() { - postBackCounter = -1; -} \ No newline at end of file diff --git a/src/DotVVM.Framework/Resources/Scripts/postback/gate.ts b/src/DotVVM.Framework/Resources/Scripts/postback/gate.ts index 2d17923b0c..97e1a35fc4 100644 --- a/src/DotVVM.Framework/Resources/Scripts/postback/gate.ts +++ b/src/DotVVM.Framework/Resources/Scripts/postback/gate.ts @@ -1,28 +1,17 @@ -import { resetPostBackCounter } from './counter'; -import { postbackQueues } from './queue'; +import { backUpPostBackCounter } from './counter'; -var postbacksDisabled = false; +let postbacksDisabled = false +let lastDisabledPostback = -1 -export const isSpaNavigationRunning = ko.observable(false); +export function isPostbackDisabled(postbackId: number) { + return postbacksDisabled || lastDisabledPostback >= postbackId +} -export function arePostbacksDisabled() { - return postbacksDisabled; +export function enablePostbacks() { + postbacksDisabled = false + lastDisabledPostback = backUpPostBackCounter() } export function disablePostbacks() { - resetPostBackCounter(); - for (const q in postbackQueues) { - if (postbackQueues.hasOwnProperty(q)) { - let postbackQueue = postbackQueues[q]; - postbackQueue.queue.length = 0; - postbackQueue.noRunning = 0; - } - } - - // disable all other postbacks - // but not in SPA mode, since we'll need them for the next page - // and user might want to try another postback in case this navigation hangs - if (!compileConstants.isSpa) { - postbacksDisabled = true; - } + postbacksDisabled = true } diff --git a/src/DotVVM.Framework/Resources/Scripts/postback/handlers.ts b/src/DotVVM.Framework/Resources/Scripts/postback/handlers.ts index 7d7dce11f7..a551e82ba3 100644 --- a/src/DotVVM.Framework/Resources/Scripts/postback/handlers.ts +++ b/src/DotVVM.Framework/Resources/Scripts/postback/handlers.ts @@ -26,7 +26,7 @@ class SuppressPostBackHandler implements DotvvmPostbackHandler { function createWindowSetTimeoutHandler(time: number): DotvvmPostbackHandler { return { name: "timeout", - before: ["eventInvoke-postbackHandlersStarted", "setIsPostbackRunning"], + before: ["setIsPostbackRunning"], async execute(next: () => Promise, options: PostbackOptions) { await new Promise((resolve, reject) => window.setTimeout(resolve, time)) return await next() diff --git a/src/DotVVM.Framework/Resources/Scripts/postback/http.ts b/src/DotVVM.Framework/Resources/Scripts/postback/http.ts index 1717e655b9..d6aebc7862 100644 --- a/src/DotVVM.Framework/Resources/Scripts/postback/http.ts +++ b/src/DotVVM.Framework/Resources/Scripts/postback/http.ts @@ -1,8 +1,13 @@ import { getVirtualDirectory, getViewModel } from '../dotvvm-base'; -import { DotvvmPostbackError } from '../shared-classes'; import { keys } from '../utils/objects'; +import { DotvvmPostbackError } from '../shared-classes'; + +export type WrappedResponse = { + readonly result: T, + readonly response?: Response +} -export async function getJSON(url: string, spaPlaceHolderUniqueId?: string, additionalHeaders?: { [key: string]: string }): Promise { +export async function getJSON(url: string, spaPlaceHolderUniqueId?: string, additionalHeaders?: { [key: string]: string }): Promise> { const headers = new Headers(); headers.append('Accept', 'application/json'); if (compileConstants.isSpa && spaPlaceHolderUniqueId) { @@ -13,7 +18,7 @@ export async function getJSON(url: string, spaPlaceHolderUniqueId?: string, a return await fetchJson(url, { headers: headers }); } -export async function postJSON(url: string, postData: any, additionalHeaders?: { [key: string]: string }): Promise { +export async function postJSON(url: string, postData: any, additionalHeaders?: { [key: string]: string }): Promise> { const headers = new Headers(); headers.append('Content-Type', 'application/json'); headers.append('X-DotVVM-PostBack', 'true'); @@ -22,7 +27,7 @@ export async function postJSON(url: string, postData: any, additionalHeaders? return await fetchJson(url, { body: postData, headers: headers, method: "POST" }); } -export async function fetchJson(url: string, init: RequestInit): Promise { +export async function fetchJson(url: string, init: RequestInit): Promise> { let response: Response; try { response = await fetch(url, init); @@ -38,7 +43,7 @@ export async function fetchJson(url: string, init: RequestInit): Promise { throw new DotvvmPostbackError({ type: "serverError", status: response.status, responseObject: (isJson ? await response.json() : null), response }); } - return response.json(); + return { result: await response.json(), response }; } export async function fetchCsrfToken(): Promise { diff --git a/src/DotVVM.Framework/Resources/Scripts/postback/internal-handlers.ts b/src/DotVVM.Framework/Resources/Scripts/postback/internal-handlers.ts index 6ca17d65b3..779ab6a87b 100644 --- a/src/DotVVM.Framework/Resources/Scripts/postback/internal-handlers.ts +++ b/src/DotVVM.Framework/Resources/Scripts/postback/internal-handlers.ts @@ -1,9 +1,10 @@ import * as events from "../events"; -import { DotvvmPostbackError } from "../shared-classes"; +import * as gate from "./gate"; import { isElementDisabled } from "../utils/dom"; import { getPostbackQueue, enterActivePostback, leaveActivePostback, runNextInQueue } from "./queue"; import { getLastStartedPostbackId } from "./postbackCore"; import { getIsViewModelUpdating } from "./updater"; +import { DotvvmPostbackError } from "../shared-classes"; let postbackCount = 0; @@ -27,7 +28,6 @@ export const suppressOnDisabledElementHandler: DotvvmPostbackHandler = { export const isPostBackRunningHandler: DotvvmPostbackHandler = { name: "setIsPostbackRunning", - before: ["eventInvoke-postbackHandlersStarted"], async execute(next: () => Promise) { isPostbackRunning(true) postbackCount++ @@ -39,23 +39,6 @@ export const isPostBackRunningHandler: DotvvmPostbackHandler = { } }; -export const postbackHandlersStartedEventHandler: DotvvmPostbackHandler = { - name: "eventInvoke-postbackHandlersStarted", - execute: (callback: () => Promise, options: PostbackOptions) => { - events.postbackHandlersStarted.trigger(options); - return callback() - } -}; - -export const postbackHandlersCompletedEventHandler: DotvvmPostbackHandler = { - name: "eventInvoke-postbackHandlersCompleted", - after: ["eventInvoke-postbackHandlersStarted"], - execute: (callback: () => Promise, options: PostbackOptions) => { - events.postbackHandlersCompleted.trigger(options); - return callback() - } -}; - export const concurrencyDefault = (o: any) => ({ name: "concurrency-default", before: ["setIsPostbackRunning"], @@ -110,35 +93,37 @@ export const suppressOnUpdating = (o: any) => ({ return next(); } } -}); +}) + +export function isPostbackStillActive(id: number) { + return getLastStartedPostbackId() == id && !gate.isPostbackDisabled(id) +} function commonConcurrencyHandler(promise: Promise, options: PostbackOptions, queueName: string): Promise { enterActivePostback(queueName); - const dispatchNext = (args: DotvvmAfterPostBackEventArgs | undefined) => { - const drop = () => { - // run the next postback after everything about this one is finished (after, error events, ...) - Promise.resolve().then(() => { - leaveActivePostback(queueName); - runNextInQueue(queueName); - }); - } - if (args && args.redirectPromise) { - args.redirectPromise.then(drop, drop); - } else { - drop(); - } + const dispatchNext = async () => { + // run the next postback after everything about this one is finished (after, error events, ...) + await Promise.resolve() + + leaveActivePostback(queueName) + runNextInQueue(queueName) } - return promise.then(result => { - const p = getLastStartedPostbackId() == options.postbackId ? result : () => Promise.reject(new DotvvmPostbackError({ type: "commit" })); - return () => { - const pr = p(); - pr.then(dispatchNext, dispatchNext); - return pr; + return promise.then(innerCommit => { + return async () => { + try { + if (isPostbackStillActive(options.postbackId)) { + return await innerCommit(); + } else { + throw new DotvvmPostbackError({ type: "commit" }) + } + } finally { + dispatchNext() + } }; }, error => { - dispatchNext(error) + dispatchNext() return Promise.reject(error) }); } diff --git a/src/DotVVM.Framework/Resources/Scripts/postback/postback.ts b/src/DotVVM.Framework/Resources/Scripts/postback/postback.ts index b81567a279..5540b7a636 100644 --- a/src/DotVVM.Framework/Resources/Scripts/postback/postback.ts +++ b/src/DotVVM.Framework/Resources/Scripts/postback/postback.ts @@ -1,33 +1,27 @@ import * as counter from './counter' import { postbackCore } from './postbackCore' import { getViewModel } from '../dotvvm-base' -import { defaultConcurrencyPostbackHandler, postbackHandlers, getPostbackHandler } from './handlers'; +import { defaultConcurrencyPostbackHandler, getPostbackHandler } from './handlers'; import * as internalHandlers from './internal-handlers'; -import { DotvvmPostbackError } from '../shared-classes'; import * as events from '../events'; import * as gate from './gate'; -import { createPostbackArgs } from '../createPostbackArgs'; +import { DotvvmPostbackError } from '../shared-classes'; const globalPostbackHandlers: (ClientFriendlyPostbackHandlerConfiguration)[] = [ internalHandlers.suppressOnDisabledElementHandler, - internalHandlers.isPostBackRunningHandler, - internalHandlers.postbackHandlersStartedEventHandler -]; -const globalLaterPostbackHandlers: (ClientFriendlyPostbackHandlerConfiguration)[] = [ - internalHandlers.postbackHandlersCompletedEventHandler, + internalHandlers.isPostBackRunningHandler ]; +const globalLaterPostbackHandlers: (ClientFriendlyPostbackHandlerConfiguration)[] = []; export async function postBack( - sender: HTMLElement, - path: string[], - command: string, - controlUniqueId: string, - context?: any, - handlers?: ClientFriendlyPostbackHandlerConfiguration[], - commandArgs?: any[] - ): Promise { - if (gate.isSpaNavigationRunning()) return Promise.reject({ type: "gate" }); - + sender: HTMLElement, + path: string[], + command: string, + controlUniqueId: string, + context?: any, + handlers?: ClientFriendlyPostbackHandlerConfiguration[], + commandArgs?: any[] +): Promise { context = context || ko.contextFor(sender); const preparedHandlers = findPostbackHandlers(context, globalPostbackHandlers.concat(handlers || []).concat(globalLaterPostbackHandlers)); @@ -39,12 +33,12 @@ export async function postBack( const options: PostbackOptions = { postbackId: counter.backUpPostBackCounter(), sender, - args: commandArgs || [], + args: ko.toJS(commandArgs) || [], // TODO: consult with @exyi to fix it properly. Whether commandArgs should or not be serialized via dotvvm serializer. viewModel: context.$data, - additionalPostbackData: {} - }; + commandType: "postback" + } - const coreCallback = (o: PostbackOptions) => postbackCore(o, path, command, controlUniqueId, context, commandArgs); + const coreCallback = (o: PostbackOptions) => postbackCore(o, path, command, controlUniqueId, context, options.args); try { const wrappedPostbackCommit = await applyPostbackHandlersCore(coreCallback, options, preparedHandlers); @@ -56,31 +50,50 @@ export async function postBack( } catch (err) { if (err instanceof DotvvmPostbackError) { - const r = err.reason; - const wasInterrupted = r.type == "handler" || r.type == "event"; - const serverResponseObject = - r.type == "commit" && r.args ? r.args.serverResponseObject : - r.type == "network" ? r.err : - r.type == "serverError" ? r.responseObject : - null; + const wasInterrupted = isInterruptingErrorReason(err); + const serverResponseObject = extractServerResponseObject(err); + + if (wasInterrupted) { + // trigger postbackRejected event + const postbackRejectedEventArgs: DotvvmPostbackRejectedEventArgs = { + ...options, + error: err + }; + events.postbackRejected.trigger(postbackRejectedEventArgs) + } + + // trigger afterPostback event const eventArgs: DotvvmAfterPostBackEventArgs = { - ...createPostbackArgs(options), + ...options, serverResponseObject, - handled: false, wasInterrupted, commandResult: null, - response: (r as any).response + response: (err.reason as any).response, + error: err } - if (wasInterrupted) { - // trigger afterPostback event - events.postbackRejected.trigger(eventArgs) - } else if (r.type == "network" || r.type == "serverError") { - events.error.trigger(eventArgs); - if (!eventArgs.handled) { - console.error("Postback failed", eventArgs); + events.afterPostback.trigger(eventArgs); + + if (shouldTriggerErrorEvent(err)) { + // trigger error event + const errorEventArgs: DotvvmErrorEventArgs = { + ...options, + serverResponseObject, + response: (err.reason as any).response, + error: err, + handled: false + } + events.error.trigger(errorEventArgs); + if (!errorEventArgs.handled) { + console.error("Postback failed", errorEventArgs); + } else { + return { + ...options, + serverResponseObject, + response: (err.reason as any).response, + error: err + }; } } - events.afterPostback.trigger(eventArgs); } throw err; } @@ -89,17 +102,17 @@ export async function postBack( function findPostbackHandlers(knockoutContext: KnockoutBindingContext, config: ClientFriendlyPostbackHandlerConfiguration[]) { const createHandler = (name: string, options: any) => options.enabled === false ? null : getPostbackHandler(name)(options); return config.map(h => { - if (typeof h == 'string') { - return createHandler(h, {}); - } else if (isPostbackHandler(h)) { - return h; - } else if (h instanceof Array) { - const [name, opt] = h; - return createHandler(name, typeof opt == "function" ? opt(knockoutContext, knockoutContext.$data) : opt); - } else { - return createHandler(h.name, h.options && h.options(knockoutContext)); - } - }).filter(h => h != null) as DotvvmPostbackHandler[]; + if (typeof h == 'string') { + return createHandler(h, {}); + } else if (isPostbackHandler(h)) { + return h; + } else if (h instanceof Array) { + const [name, opt] = h; + return createHandler(name, typeof opt == "function" ? opt(knockoutContext, knockoutContext.$data) : opt); + } else { + return createHandler(h.name, h.options && h.options(knockoutContext)); + } + }).filter(h => h != null) as DotvvmPostbackHandler[]; } type MaybePromise = Promise | T @@ -112,19 +125,17 @@ export async function applyPostbackHandlers( context = ko.contextFor(sender), viewModel = context.$root ): Promise { - if (gate.isSpaNavigationRunning()) return Promise.reject({ type: "gate" }); - const saneNext = (o: PostbackOptions) => { return wrapCommitFunction(next(o), o); } const options: PostbackOptions = { postbackId: counter.backUpPostBackCounter(), + commandType: "staticCommand", sender, - args: [], - viewModel: context.$data, - additionalPostbackData: {} - }; + args, + viewModel: context.$data + } const handlers = findPostbackHandlers(context, globalPostbackHandlers.concat(handlerConfigurations || []).concat(globalLaterPostbackHandlers)); @@ -132,27 +143,57 @@ export async function applyPostbackHandlers( const commit = await applyPostbackHandlersCore(saneNext, options, handlers); const result = await commit(); return result; - } catch (reason) { - if (reason) { - console.log("Promise rejected: " + reason); + } catch (err) { + + if (err instanceof DotvvmPostbackError) { + + if (shouldTriggerErrorEvent(err)) { + // trigger error event + const serverResponseObject = extractServerResponseObject(err); + const errorEventArgs: DotvvmErrorEventArgs = { + ...options, + serverResponseObject, + response: (err.reason as any).response, + error: err, + handled: false + } + events.error.trigger(errorEventArgs); + + if (!errorEventArgs.handled) { + console.error("StaticCommand failed", errorEventArgs); + } else { + return { + ...options, + serverResponseObject, + response: (err.reason as any).response, + error: err + }; + } + } } - throw reason + throw err } } function applyPostbackHandlersCore(next: (options: PostbackOptions) => Promise, options: PostbackOptions, handlers: DotvvmPostbackHandler[]): Promise { + events.postbackHandlersStarted.trigger(options); + let fired = false const nextWithCheck = (o: PostbackOptions) => { if (fired) { throw new Error("The same postback can't run twice."); } fired = true; + events.postbackHandlersCompleted.trigger(options); return next(o); } const sortedHandlers = sortHandlers(handlers) function recursiveCore(index: number): Promise { + if (gate.isPostbackDisabled(options.postbackId)) { + throw new DotvvmPostbackError({ type: "gate" }) + } if (index == sortedHandlers.length) { return nextWithCheck(options); } else { @@ -169,16 +210,12 @@ function wrapCommitFunction(value: MaybePromise, o return Promise.resolve(value).then(v => { if (typeof v == "function") { - return value; - } else { + return value; + } else { return () => Promise.resolve({ - postbackOptions: options, - postbackClientId: options.postbackId, - serverResponseObject: null, + ...options, commandResult: v, - wasInterrupted: false, - handled: true, - viewModel: options.viewModel! + wasInterrupted: false }); } }); @@ -198,12 +235,12 @@ export function sortHandlers(handlers: DotvvmPostbackHandler[]): DotvvmPostbackH } return (s: string | DotvvmPostbackHandler) => typeof s == "string" ? handlerMap[s] : s; })(); - const dependencies = handlers.map((handler, i) => (( handler)["@sort_index"] = i, ({ handler, deps: (handler.after || []).map(getHandler) }))); + const dependencies = handlers.map((handler, i) => ((handler)["@sort_index"] = i, ({ handler, deps: (handler.after || []).map(getHandler) }))); for (const h of handlers) { if (h.before) { for (const before of h.before.map(getHandler)) { if (before) { - const index = ( before)["@sort_index"] as number; + const index = (before)["@sort_index"] as number; dependencies[index].deps.push(h); } } @@ -226,7 +263,7 @@ export function sortHandlers(handlers: DotvvmPostbackHandler[]): DotvvmPostbackH const { handler, deps } = dependencies[index]; for (const d of deps) { - addToResult(( d)["@sort_index"]); + addToResult((d)["@sort_index"]); } doneBitmap[index] = 2; @@ -237,3 +274,21 @@ export function sortHandlers(handlers: DotvvmPostbackHandler[]): DotvvmPostbackH } return result; } + +function isInterruptingErrorReason(err: DotvvmPostbackError) { + return err.reason.type == "handler" || err.reason.type == "event"; +} +function shouldTriggerErrorEvent(err: DotvvmPostbackError) { + return err.reason.type == "network" || err.reason.type == "serverError"; +} +function extractServerResponseObject(err: DotvvmPostbackError) { + if (err.reason.type == "commit" && err.reason.args) { + return err.reason.args.serverResponseObject; + } + else if (err.reason.type == "network") { + return err.reason.err; + } else if (err.reason.type == "serverError") { + return err.reason.responseObject; + } + return null; +} \ No newline at end of file diff --git a/src/DotVVM.Framework/Resources/Scripts/postback/postbackCore.ts b/src/DotVVM.Framework/Resources/Scripts/postback/postbackCore.ts index 5d1cf35465..eba4a47c40 100644 --- a/src/DotVVM.Framework/Resources/Scripts/postback/postbackCore.ts +++ b/src/DotVVM.Framework/Resources/Scripts/postback/postbackCore.ts @@ -3,14 +3,14 @@ import { deserialize } from '../serialization/deserialize'; import { getViewModel, getInitialUrl, getViewModelCache, getViewModelCacheId, clearViewModelCache } from '../dotvvm-base'; import { loadResourceList, RenderedResourceList, getRenderedResources } from './resourceLoader'; import * as events from '../events'; -import { createPostbackArgs } from "../createPostbackArgs"; import * as updater from './updater'; import * as http from './http'; -import { DotvvmPostbackError } from '../shared-classes'; import { setIdFragment } from '../utils/dom'; import { handleRedirect } from './redirect'; import * as evaluator from '../utils/evaluator' import * as gate from './gate' +import { mergeValidationRules, showValidationErrorsFromServer } from '../validation/validation'; +import { DotvvmPostbackError } from '../shared-classes'; let lastStartedPostbackId: number; @@ -30,21 +30,17 @@ export async function postbackCore( lastStartedPostbackId = options.postbackId; const beforePostbackArgs: DotvvmBeforePostBackEventArgs = { - ...createPostbackArgs(options), + ...options, cancel: false }; events.beforePostback.trigger(beforePostbackArgs); if (beforePostbackArgs.cancel) { - throw new DotvvmPostbackError({ type: "event", options }); + throw new DotvvmPostbackError({ type: "event" }); } return await http.retryOnInvalidCsrfToken(async () => { await http.fetchCsrfToken(); - if (gate.arePostbacksDisabled()) { - throw new DotvvmPostbackError({ type: "gate" }); - } - updateDynamicPathFragments(context, path); const postedViewModel = serialize(getViewModel(), { @@ -55,7 +51,7 @@ export async function postbackCore( currentPath: path, command: command, controlUniqueId: processPassedId(controlUniqueId, context), - additionalData: options.additionalPostbackData, + validationTargetPath: options.validationTargetPath, renderedResources: getRenderedResources(), commandArgs: commandArgs }; @@ -69,9 +65,9 @@ export async function postbackCore( } const initialUrl = getInitialUrl(); - let result = await http.postJSON(initialUrl, ko.toJSON(data)); + let response = await http.postJSON(initialUrl, JSON.stringify(data)); - if (result.action == "viewModelNotCached") { + if (response.result.action == "viewModelNotCached") { // repeat the request with full viewmodel clearViewModelCache(); @@ -79,45 +75,79 @@ export async function postbackCore( delete data.viewModelCache; data.viewModel = postedViewModel; - result = await http.postJSON(initialUrl, ko.toJSON(data)); + response = await http.postJSON(initialUrl, JSON.stringify(data)); } - events.postbackResponseReceived.trigger({}); + events.postbackResponseReceived.trigger({ + ...options, + response: response.response!, + serverResponseObject: response.result + }); return async () => { try { - return await processPostbackResponse(options, postedViewModel, result); + return await processPostbackResponse(options, context, postedViewModel, response.result, response.response!); } catch (err) { - throw new DotvvmPostbackError({ type: "commit", args: { serverResponseObject: err.reason.responseObject, handled: false } }); + if (err instanceof DotvvmPostbackError) { + throw err; + } + + throw new DotvvmPostbackError({ + type: "commit", + args: { + ...options, + serverResponseObject: response.result, + response: response.response, + handled: false, + error: err + } + }); } }; }); } -async function processPostbackResponse(options: PostbackOptions, postedViewModel: any, result: PostbackResponse): Promise { - events.postbackCommitInvoked.trigger({}); +async function processPostbackResponse(options: PostbackOptions, context: any, postedViewModel: any, result: PostbackResponse, response: Response): Promise { + events.postbackCommitInvoked.trigger({ + ...options, + response, + serverResponseObject: result + }); processViewModelDiff(result, postedViewModel); await loadResourceList(result.resources); + if (gate.isPostbackDisabled(options.postbackId)) + throw "Postbacks are disabled" + let isSuccess = false; if (result.action == "successfulCommand") { + mergeValidationRules(result) updater.updateViewModelAndControls(result, false); - events.postbackViewModelUpdated.trigger({}); + events.postbackViewModelUpdated.trigger({ + ...options, + response, + serverResponseObject: result + }); isSuccess = true; } else if (result.action == "redirect") { - // redirect - const redirectPromise = handleRedirect(result); + await handleRedirect(options, result, response); return { - ...createPostbackArgs(options), + ...options, + response, serverResponseObject: result, commandResult: result.commandResult, - redirectPromise, - handled: false, wasInterrupted: false }; + } else if (result.action == "validationErrors") { + showValidationErrorsFromServer(context, options.validationTargetPath!, result, options); + throw new DotvvmPostbackError({ + type: "validation", + response, + responseObject: result + }); } setIdFragment(result.resultIdFragment) @@ -125,14 +155,15 @@ async function processPostbackResponse(options: PostbackOptions, postedViewModel if (!isSuccess) { throw new DotvvmPostbackError({ type: "serverError", + response, responseObject: result }); } else { return { - ...createPostbackArgs(options), + ...options, + response, serverResponseObject: result, commandResult: result.commandResult, - handled: false, wasInterrupted: false } } diff --git a/src/DotVVM.Framework/Resources/Scripts/postback/redirect.ts b/src/DotVVM.Framework/Resources/Scripts/postback/redirect.ts index 92d8eda042..52257e2e6f 100644 --- a/src/DotVVM.Framework/Resources/Scripts/postback/redirect.ts +++ b/src/DotVVM.Framework/Resources/Scripts/postback/redirect.ts @@ -3,19 +3,21 @@ import * as magicNavigator from '../utils/magic-navigator' import { handleSpaNavigationCore } from "../spa/spa"; import { disablePostbacks } from './gate'; -export function performRedirect(url: string, replace: boolean, allowSpa: boolean): Promise | undefined { +export function performRedirect(url: string, replace: boolean, allowSpa: boolean): Promise { disablePostbacks(); - + if (replace) { location.replace(url); + return Promise.resolve(); } else if (compileConstants.isSpa && allowSpa) { - return handleSpaNavigationCore(url) + return handleSpaNavigationCore(url); } else { magicNavigator.navigate(url); + return Promise.resolve(); } } -export function handleRedirect(resultObject: any, replace: boolean = false): Promise | undefined { +export async function handleRedirect(options: PostbackOptions, resultObject: any, response: Response, replace: boolean = false): Promise { if (resultObject.replace != null) { replace = resultObject.replace || replace; } @@ -23,10 +25,15 @@ export function handleRedirect(resultObject: any, replace: boolean = false): Pro // trigger redirect event const redirectArgs: DotvvmRedirectEventArgs = { + ...options, url, replace, + serverResponseObject: resultObject, + response: response } events.redirect.trigger(redirectArgs); - return performRedirect(url, replace, resultObject.allowSpa); + await performRedirect(url, replace, resultObject.allowSpa); + + return redirectArgs; } diff --git a/src/DotVVM.Framework/Resources/Scripts/postback/staticCommand.ts b/src/DotVVM.Framework/Resources/Scripts/postback/staticCommand.ts index fa993946a2..02dfff7090 100644 --- a/src/DotVVM.Framework/Resources/Scripts/postback/staticCommand.ts +++ b/src/DotVVM.Framework/Resources/Scripts/postback/staticCommand.ts @@ -5,58 +5,61 @@ import * as events from '../events'; import * as updater from './updater'; import * as http from './http' import { handleRedirect } from './redirect'; -import { DotvvmPostbackError } from '../shared-classes'; -export function staticCommandPostback_old(viewModelName: string, sender: HTMLElement, command: string, args: any[], callback = (a: any) => { }, errorCallback = (errorInfo: { xhr?: XMLHttpRequest, error?: any }) => { }) { - return staticCommandPostback(sender, command, args).then( - callback, - errorCallback - ); -} - -export async function staticCommandPostback(sender: HTMLElement, command: string, args: any[]): Promise { +export async function staticCommandPostback(sender: HTMLElement, command: string, args: any[], options: PostbackOptions): Promise { let data: any; + let response: http.WrappedResponse; + try { - return await http.retryOnInvalidCsrfToken(async () => { + await http.retryOnInvalidCsrfToken(async () => { const csrfToken = await http.fetchCsrfToken(); - data = serialize({ args, command, $csrfToken: csrfToken }); + }); - events.staticCommandMethodInvoking.trigger(data); - - const response = await http.postJSON( - getInitialUrl(), - ko.toJSON(data), - { "X-PostbackType": "StaticCommand" } - ); - - const result = response.result; - if ("action" in response) { - if (response.action == "redirect") { - // redirect - handleRedirect(response); - return; - } else { - throw new Error(`Invalid action ${response.action}`); - } - } - events.staticCommandMethodInvoked.trigger({ ...data, result }); - - return result; + events.staticCommandMethodInvoking.trigger({ + ...options, + methodId: command, + methodArgs: args, }); - } catch (err) { - events.staticCommandMethodFailed.trigger({ ...data, error: err }) - - if (err instanceof DotvvmPostbackError) { - const r = err.reason; - if (r.type == "network") { - events.error.trigger({ sender, handled: false, serverResponseObject: r.err }); + + response = await http.postJSON( + getInitialUrl(), + JSON.stringify(data), + { "X-PostbackType": "StaticCommand" } + ); + + if ("action" in response.result) { + if (response.result.action == "redirect") { + // redirect + await handleRedirect(options, response.result, response.response!); return; + } else { + throw new Error(`Invalid action ${response.result.action}`); } } - events.error.trigger({ sender, handled: false, serverResponseObject: err }); + events.staticCommandMethodInvoked.trigger({ + ...options, + methodId: command, + methodArgs: args, + serverResponseObject: response.result, + result: (response as any).result.result, + response: (response as any).response + }); + + return response.result.result; + + } catch (err) { + events.staticCommandMethodFailed.trigger({ + ...options, + methodId: command, + methodArgs: args, + error: err, + result: (err.reason as any).responseObject, + response: (err.reason as any).response + }) + throw err; } } diff --git a/src/DotVVM.Framework/Resources/Scripts/shared-classes.ts b/src/DotVVM.Framework/Resources/Scripts/shared-classes.ts index ab982467cb..932408c733 100644 --- a/src/DotVVM.Framework/Resources/Scripts/shared-classes.ts +++ b/src/DotVVM.Framework/Resources/Scripts/shared-classes.ts @@ -1,4 +1,4 @@ export class DotvvmPostbackError { - constructor(public reason: PostbackRejectionReason) { + constructor(public reason: DotvvmPostbackErrorReason) { } -} +} \ No newline at end of file diff --git a/src/DotVVM.Framework/Resources/Scripts/spa/events.ts b/src/DotVVM.Framework/Resources/Scripts/spa/events.ts index 99d20a2c95..d9e690ca9a 100644 --- a/src/DotVVM.Framework/Resources/Scripts/spa/events.ts +++ b/src/DotVVM.Framework/Resources/Scripts/spa/events.ts @@ -2,3 +2,4 @@ import { DotvvmEvent } from "../events"; export const spaNavigating = new DotvvmEvent("dotvvm.events.spaNavigating"); export const spaNavigated = new DotvvmEvent("dotvvm.events.spaNavigated"); +export const spaNavigationFailed = new DotvvmEvent("dotvvm.events.spaNavigationFailed"); diff --git a/src/DotVVM.Framework/Resources/Scripts/spa/navigation.ts b/src/DotVVM.Framework/Resources/Scripts/spa/navigation.ts index ef6b5ddb08..4e2d550992 100644 --- a/src/DotVVM.Framework/Resources/Scripts/spa/navigation.ts +++ b/src/DotVVM.Framework/Resources/Scripts/spa/navigation.ts @@ -2,27 +2,25 @@ import * as uri from '../utils/uri'; import * as http from '../postback/http'; import { getViewModel } from '../dotvvm-base'; -import { DotvvmPostbackError } from '../shared-classes'; import { loadResourceList } from '../postback/resourceLoader'; import * as updater from '../postback/updater'; -import * as counter from '../postback/counter'; import * as events from './events'; import { getSpaPlaceHoldersUniqueId, isSpaReady } from './spa'; import { handleRedirect } from '../postback/redirect'; import * as gate from '../postback/gate'; +import { DotvvmPostbackError } from '../shared-classes'; -export async function navigateCore(url: string, handlePageNavigating?: (url: string) => void): Promise { - - return await http.retryOnInvalidCsrfToken(async () => { +let lastStartedNavigation = -1 - // prevent double postbacks - const currentPostBackCounter = counter.backUpPostBackCounter(); - gate.isSpaNavigationRunning(true); +export async function navigateCore(url: string, options: PostbackOptions, handlePageNavigating: (url: string) => void): Promise { + + let response: http.WrappedResponse | undefined; + try { // trigger spaNavigating event const spaNavigatingArgs: DotvvmSpaNavigatingEventArgs = { - viewModel: getViewModel(), - newUrl: url, + ...options, + url, cancel: false }; events.spaNavigating.trigger(spaNavigatingArgs); @@ -30,15 +28,19 @@ export async function navigateCore(url: string, handlePageNavigating?: (url: str throw new DotvvmPostbackError({ type: "event" }); } + lastStartedNavigation = options.postbackId + gate.disablePostbacks() + // compose URLs + // TODO: get rid of ___dotvvm-spa___ const spaFullUrl = uri.addVirtualDirectoryToUrl("/___dotvvm-spa___" + uri.addLeadingSlash(url)); const displayUrl = uri.addVirtualDirectoryToUrl(url); // send the request - const resultObject = await http.getJSON(spaFullUrl, getSpaPlaceHoldersUniqueId()); + response = await http.getJSON(spaFullUrl, getSpaPlaceHoldersUniqueId()); // if another postback has already been passed, don't do anything - if (!counter.isPostBackStillActive(currentPostBackCounter)) { + if (options.postbackId < lastStartedNavigation) { return { }; // TODO: what here https://github.com/riganti/dotvvm/pull/787/files#diff-edefee5e25549b2a6ed0136e520e009fR852 } @@ -47,30 +49,44 @@ export async function navigateCore(url: string, handlePageNavigating?: (url: str handlePageNavigating(displayUrl); } - await loadResourceList(resultObject.resources); + await loadResourceList(response.result.resources); - if (resultObject.action === "successfulCommand" || !resultObject.action) { - updater.updateViewModelAndControls(resultObject, true); + if (response.result.action === "successfulCommand") { + updater.updateViewModelAndControls(response.result, true); isSpaReady(true); - } else if (resultObject.action === "redirect") { - gate.isSpaNavigationRunning(false); - const x = await handleRedirect(resultObject, true) as DotvvmNavigationEventArgs - return x + } else if (response.result.action === "redirect") { + await handleRedirect(options, response.result, response.response!); + return { ...options, url }; } // trigger spaNavigated event const spaNavigatedArgs: DotvvmSpaNavigatedEventArgs = { + ...options, + url, viewModel: getViewModel(), - serverResponseObject: resultObject, - isSpa: true, - isHandled: true + serverResponseObject: response.result, + response: response.response }; events.spaNavigated.trigger(spaNavigatedArgs); - gate.isSpaNavigationRunning(false); + return spaNavigatedArgs; + + } catch (err) { + // trigger spaNavigationFailed event + let spaNavigationFailedArgs: DotvvmSpaNavigationFailedEventArgs = { + ...options, + url, + serverResponseObject: (err.reason as any).responseObject, + response: (err.reason as any).response, + error: err + }; + events.spaNavigationFailed.trigger(spaNavigationFailedArgs); - return spaNavigatedArgs - }, 0, () => { - gate.isSpaNavigationRunning(false); - }); + throw err; + } finally { + // when no other navigation is running, enable postbacks again + if (options.postbackId == lastStartedNavigation) { + gate.enablePostbacks() + } + } } diff --git a/src/DotVVM.Framework/Resources/Scripts/spa/spa.ts b/src/DotVVM.Framework/Resources/Scripts/spa/spa.ts index 242d973799..577bf1e762 100644 --- a/src/DotVVM.Framework/Resources/Scripts/spa/spa.ts +++ b/src/DotVVM.Framework/Resources/Scripts/spa/spa.ts @@ -3,7 +3,8 @@ import * as http from '../postback/http'; import { getViewModel } from '../dotvvm-base'; import * as events from '../events'; import { navigateCore } from './navigation'; -import { DotvvmPostbackError } from '../shared-classes'; +import * as counter from '../postback/counter'; +import { options } from 'knockout'; export const isSpaReady = ko.observable(false); @@ -37,7 +38,7 @@ function handlePopState(event: PopStateEvent, inSpaPage: boolean) { if (isSpaPage(event.state)) { const historyRecord = (event.state); if (inSpaPage) { - navigateCore(historyRecord.url); + handleSpaNavigationCore(historyRecord.url); } else { location.replace(historyRecord.url); } @@ -49,8 +50,9 @@ function handlePopState(event: PopStateEvent, inSpaPage: boolean) { function handleHashChangeWithHistory(spaPlaceHolders: NodeListOf, isInitialPageLoad: boolean) { if (document.location.hash.indexOf("#!/") === 0) { // the user requested navigation to another SPA page - navigateCore( + handleSpaNavigationCore( document.location.hash.substring(2), + undefined, (url) => { replacePage(url); } ); } else { @@ -64,41 +66,57 @@ function handleHashChangeWithHistory(spaPlaceHolders: NodeListOf, i } } -export async function handleSpaNavigation(element: HTMLElement): Promise { +export async function handleSpaNavigation(element: HTMLElement): Promise { const target = element.getAttribute('target'); if (target == "_blank") { - return { viewModel: getViewModel(), serverResponseObject: null }; + return; // TODO: shall we return result if the target is _blank? And what about other targets? } + return await handleSpaNavigationCore(element.getAttribute('href'), element); +} + +export async function handleSpaNavigationCore(url: string | null, sender?: HTMLElement, handlePageNavigating?: (url: string) => void): Promise { + + if (!url || url.indexOf("/") !== 0) { + throw new Error("Invalid url for SPAN navigation!"); + } + + const currentPostBackCounter = counter.backUpPostBackCounter(); + + const options: PostbackOptions = { + sender, + commandType: "spaNavigation", + postbackId: currentPostBackCounter, + viewModel: getViewModel(), + args: [] + }; + try { - return await handleSpaNavigationCore(element.getAttribute('href')); + + url = uri.removeVirtualDirectoryFromUrl(url); + return await navigateCore(url, options, handlePageNavigating || defaultHandlePageNavigating); + } catch (err) { - // execute error handlers + + // execute error handler const errArgs: DotvvmErrorEventArgs = { - sender: element, - viewModel: getViewModel(), - handled: false, - isSpaNavigationError: true, - serverResponseObject: err + ...options, + error: err, + response: (err.reason as any).response, + serverResponseObject: (err.reason as any).responseObject, + handled: false }; events.error.trigger(errArgs); if (!errArgs.handled) { - alert("SPA Navigation Error"); + console.error("SPA Navigation Error", errArgs); } throw err; } } -export async function handleSpaNavigationCore(url: string | null): Promise { - if (url && url.indexOf("/") === 0) { - url = uri.removeVirtualDirectoryFromUrl(url); - return await navigateCore(url, (navigatedUrl) => { - if (!history.state || history.state.url != navigatedUrl) { - pushPage(navigatedUrl); - } - }); - } else { - throw new Error("invalid url"); +function defaultHandlePageNavigating(navigatedUrl: string) { + if (!history.state || history.state.url != navigatedUrl) { + pushPage(navigatedUrl); } } diff --git a/src/DotVVM.Framework/Resources/Scripts/tests/eventArgs.test.ts b/src/DotVVM.Framework/Resources/Scripts/tests/eventArgs.test.ts new file mode 100644 index 0000000000..37bb3aea81 --- /dev/null +++ b/src/DotVVM.Framework/Resources/Scripts/tests/eventArgs.test.ts @@ -0,0 +1,676 @@ +import { postBack, applyPostbackHandlers } from "../postback/postback"; +import { initDotvvmWithSpa, watchEvents, getEventHistory } from "./helper"; +import { getViewModel, updateViewModelCache, replaceViewModel } from "../dotvvm-base"; +import { DotvvmPostbackError } from "../shared-classes"; +import { keys } from "../utils/objects"; +import { WrappedResponse } from "../postback/http"; +import { spaNavigationFailed } from "../spa/events"; +import { updateViewModelAndControls } from "../postback/updater"; +import { detachAllErrors } from "../validation/error"; + + +jest.unmock("../spa/spa"); +const spa = jest.requireActual("../spa/spa"); +spa.init = () => {}; +spa.getSpaPlaceHolderUniqueId = () => "someId"; + + +var fetchJson = function(url: string, init: RequestInit): Promise { + return Promise.resolve({} as T); +} + +function appendAdditionalHeaders(headers: Headers, additionalHeaders?: { [key: string]: string }) { + if (additionalHeaders) { + for (const key of keys(additionalHeaders)) { + headers.append(key, additionalHeaders[key]); + } + } +} + + +jest.mock("../postback/http", () => ({ + async fetchCsrfToken() { + getViewModel().$csrfToken = "test token" + }, + + retryOnInvalidCsrfToken(postbackFunction: () => Promise) { + return postbackFunction() + }, + + async getJSON(url: string, spaPlaceHolderUniqueId?: string, additionalHeaders?: { [key: string]: string }): Promise> { + const headers = new Headers(); + headers.append('Accept', 'application/json'); + if (compileConstants.isSpa && spaPlaceHolderUniqueId) { + headers.append('X-DotVVM-SpaContentPlaceHolder', spaPlaceHolderUniqueId); + } + appendAdditionalHeaders(headers, additionalHeaders); + + return { response: { fake: "get" } as any as Response, result: await fetchJson(url, { headers: headers }) }; + }, + + async postJSON(url: string, postData: any, additionalHeaders?: { [key: string]: string }): Promise> { + const headers = new Headers(); + headers.append('Content-Type', 'application/json'); + headers.append('X-DotVVM-PostBack', 'true'); + appendAdditionalHeaders(headers, additionalHeaders); + + return { response: { fake: "post" } as any as Response, result: await fetchJson(url, { body: postData, headers: headers, method: "POST" }) }; + } +})); +jest.mock("../postback/gate", () => ({ + isPostbackDisabled(postbackId: number) { return false; }, + enablePostbacks() { }, + disablePostbacks() { } +})); + +function validateEvent(actual: { event: string, args: any }, expectedEvent: string, expectedCommandType: PostbackCommandType, ...extraValidations: ((args: any) => void)[]) { + expect(actual.event).toBe(expectedEvent); + + expect(actual.args.postbackId).toBeGreaterThan(0); + expect(actual.args.commandType).toBe(expectedCommandType); + expect(actual.args.args).toBeDefined(); + expect(actual.args.viewModel).toBeDefined(); + + for (let validation of extraValidations) { + validation(actual.args); + } +} + +const validations = { + hasSender(args: any) { + expect(args.sender).toBeDefined(); + }, + hasServerResponseObject(args: any) { + expect(args.serverResponseObject).toBeDefined(); + }, + hasValidationTargetPath(args: any) { + expect(args.hasValidationTargetPath).toBeDefined(); + }, + hasError(args: any) { + expect(args.error).toBeInstanceOf(DotvvmPostbackError); + }, + hasResponse(args: any) { + expect(args.response).toBeDefined(); + }, + hasHandled(args: any) { + expect(args.handled).toBeDefined(); + }, + hasCancel(args: any) { + expect(args.cancel).toBeDefined(); + }, + hasWasInterrupted(args: any) { + expect(args.wasInterrupted).toBeDefined(); + }, + hasCommandResult(args: any) { + expect(args.commandResult).toBeDefined(); + }, + hasUrl(args: any) { + expect(args.url).toBeDefined(); + }, + hasReplace(args: any) { + expect(args.replace).toBeDefined(); + }, + hasMethodId(args: any) { + expect(args.methodId).toBeDefined(); + }, + hasMethodArgs(args: any) { + expect(args.methodArgs).toBeDefined(); + }, + hasArgs(args: any) { + expect(args.args).toBeDefined(); + }, + hasResult(args: any) { + expect(args.result).toBeDefined(); + } +}; + +const fetchDefinitions = { + postbackSuccess: async (url: string, init: RequestInit) => { + return { + viewModelDiff: { + Property1: 1 + }, + action: "successfulCommand", + resources: {}, + updatedControls: {} + } as any; + }, + postbackServerError: async (url: string, init: RequestInit) => { + throw new DotvvmPostbackError({ + type: "serverError", + status: 500, + responseObject: null, + response: { fake: "error" } as any as Response + }); + }, + postbackValidationErrors: async (url: string, init: RequestInit) => { + return { + modelState: [ + { + propertyPath: "Property1", + errorMessage: "Property 1 is required!" + } + ], + action: "validationErrors" + } as any; + }, + networkError: async (url: string, init: RequestInit) => { + throw new DotvvmPostbackError({ + type: "network", + err: { fake: "error" } + }); + }, + postbackViewModelNotCached: async (url: string, init: RequestInit) => { + if (JSON.parse(init.body as string).viewModelCacheId) { + return { + action: "viewModelNotCached" + } as any; + } + return await fetchDefinitions.postbackSuccess(url, init); + }, + postbackRedirect: async (url: string, init: RequestInit) => { + return { + action: "redirect", + url: "/newUrl" + } as any; + }, + + spaNavigateSuccess: async (url: string, init: RequestInit) => { + return { + viewModel: { + PropertyA: 1, + PropertyB: 2 + }, + action: "successfulCommand", + virtualDirectory: "", + resources: {}, + updatedControls: { + "c01": "new html" + } + } as any; + }, + spaNavigateRedirect: async (url: string, init: RequestInit) => { + if (url == "/___dotvvm-spa___/newUrl") { + return await fetchDefinitions.spaNavigateSuccess(url, init); + } + return { + action: "redirect", + url: "/newUrl", + allowSpa: true + } as any; + }, + spaNavigateRedirectWithReplace: async (url: string, init: RequestInit) => { + return { + action: "redirect", + url: "/newUrl", + allowSpa: true, + replace: true + } as any; + }, + spaNavigateError: async (url: string, init: RequestInit) => { + throw new DotvvmPostbackError({ + type: "serverError", + status: 500, + responseObject: null, + response: { fake: "error" } as any as Response + }); + }, + + staticCommandSuccess: async (url: string, init: RequestInit) => { + return { + type: "successfulCommand", + result: 1, + response: { fake: "error" } as any as Response + } as any; + }, + staticCommandServerError: async (url: string, init: RequestInit) => { + throw new DotvvmPostbackError({ + type: "serverError", + status: 500, + responseObject: null, + response: { fake: "error" } as any as Response + }); + } +}; + + + +const originalViewModel = { + viewModel: { + Property1: 0, + Property2: 0 + }, + url: "/myPage", + virtualDirectory: "", + renderedResources: ["resource1", "resource2"] +}; +initDotvvmWithSpa(originalViewModel); + + + +test("PostBack + success", async () => { + + fetchJson = fetchDefinitions.postbackSuccess; + + const cleanup = watchEvents(false); + try { + + await postBack(window.document.body, [], "c", "", undefined, [ "concurrency-default" ]); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "postbackHandlersStarted", "postback", validations.hasSender); + validateEvent(history[i++], "postbackHandlersCompleted", "postback", validations.hasSender); + validateEvent(history[i++], "beforePostback", "postback", validations.hasSender, validations.hasCancel); + validateEvent(history[i++], "postbackResponseReceived", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject); + validateEvent(history[i++], "postbackCommitInvoked", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject); + validateEvent(history[i++], "postbackViewModelUpdated", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject); + validateEvent(history[i++], "afterPostback", "postback", validations.hasSender, validations.hasWasInterrupted, validations.hasResponse, validations.hasServerResponseObject); + + expect(history.length).toBe(i); + + } + finally { + cleanup(); + } + +}); + +test("PostBack + viewModelCache", async () => { + var fetchSpy = jest.spyOn(fetchDefinitions, 'postbackViewModelNotCached'); + + fetchJson = fetchDefinitions.postbackViewModelNotCached; + + const cleanup = watchEvents(false); + try { + + updateViewModelCache("testId", getViewModel()); + + await postBack(window.document.body, [], "c", "", undefined, [ "concurrency-default" ]); + expect(fetchSpy).toBeCalledTimes(2); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "postbackHandlersStarted", "postback", validations.hasSender); + validateEvent(history[i++], "postbackHandlersCompleted", "postback", validations.hasSender); + validateEvent(history[i++], "beforePostback", "postback", validations.hasSender, validations.hasCancel); + validateEvent(history[i++], "postbackResponseReceived", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject); + validateEvent(history[i++], "postbackCommitInvoked", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject); + validateEvent(history[i++], "postbackViewModelUpdated", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject); + validateEvent(history[i++], "afterPostback", "postback", validations.hasSender, validations.hasWasInterrupted, validations.hasResponse, validations.hasServerResponseObject); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + + +test("PostBack + redirect", async () => { + fetchJson = fetchDefinitions.postbackRedirect; + + const cleanup = watchEvents(false); + try { + + await postBack(window.document.body, [], "c", "", undefined, [ "concurrency-default" ]); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "postbackHandlersStarted", "postback", validations.hasSender); + validateEvent(history[i++], "postbackHandlersCompleted", "postback", validations.hasSender); + validateEvent(history[i++], "beforePostback", "postback", validations.hasSender, validations.hasCancel); + validateEvent(history[i++], "postbackResponseReceived", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject); + validateEvent(history[i++], "postbackCommitInvoked", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject); + validateEvent(history[i++], "redirect", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject, validations.hasUrl, validations.hasReplace); + validateEvent(history[i++], "afterPostback", "postback", validations.hasSender, validations.hasWasInterrupted, validations.hasResponse, validations.hasServerResponseObject); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + +test("PostBack + validation errors", async () => { + fetchJson = fetchDefinitions.postbackValidationErrors; + + const cleanup = watchEvents(false); + try { + + await expect(postBack(window.document.body, ["$root"], "c", "", undefined, [ "concurrency-default", [ "validate", { path: "$root" } ] ])).rejects.toBeInstanceOf(DotvvmPostbackError); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "postbackHandlersStarted", "postback", validations.hasSender); + validateEvent(history[i++], "postbackHandlersCompleted", "postback", validations.hasSender); + validateEvent(history[i++], "beforePostback", "postback", validations.hasSender, validations.hasCancel); + validateEvent(history[i++], "postbackResponseReceived", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject); + validateEvent(history[i++], "postbackCommitInvoked", "postback", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject); + validateEvent(history[i++], "validationErrorsChanged", "postback"); + validateEvent(history[i++], "afterPostback", "postback", validations.hasSender, validations.hasWasInterrupted, validations.hasResponse, validations.hasServerResponseObject); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + +test("PostBack + server error", async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + + fetchJson = fetchDefinitions.postbackServerError; + + const cleanup = watchEvents(false); + try { + + await expect(postBack(window.document.body, [], "c", "", undefined, [ "concurrency-default" ])).rejects.toBeInstanceOf(DotvvmPostbackError); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "postbackHandlersStarted", "postback", validations.hasSender); + validateEvent(history[i++], "postbackHandlersCompleted", "postback", validations.hasSender); + validateEvent(history[i++], "beforePostback", "postback", validations.hasSender, validations.hasCancel); + validateEvent(history[i++], "afterPostback", "postback", validations.hasSender, validations.hasWasInterrupted, validations.hasResponse, validations.hasError, validations.hasServerResponseObject); + validateEvent(history[i++], "error", "postback", validations.hasSender, validations.hasHandled, validations.hasResponse, validations.hasError, validations.hasServerResponseObject); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + +test("PostBack + network error", async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + + fetchJson = fetchDefinitions.networkError; + + const cleanup = watchEvents(false); + try { + + await expect(postBack(window.document.body, [], "c", "", undefined, [ "concurrency-default" ])).rejects.toBeInstanceOf(DotvvmPostbackError); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "postbackHandlersStarted", "postback", validations.hasSender); + validateEvent(history[i++], "postbackHandlersCompleted", "postback", validations.hasSender); + validateEvent(history[i++], "beforePostback", "postback", validations.hasSender, validations.hasCancel); + validateEvent(history[i++], "afterPostback", "postback", validations.hasSender, validations.hasWasInterrupted, validations.hasError, validations.hasServerResponseObject); + validateEvent(history[i++], "error", "postback", validations.hasSender, validations.hasHandled, validations.hasError, validations.hasServerResponseObject); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + + + + +test("spaNavigation + success", async () => { + fetchJson = fetchDefinitions.spaNavigateSuccess; + + detachAllErrors(); + + const cleanup = watchEvents(false); + try { + + const link = document.createElement("a"); + link.href = "/test"; + await spa.handleSpaNavigation(link, (u: string) => {}); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "spaNavigating", "spaNavigation", validations.hasSender, validations.hasCancel, validations.hasUrl); + validateEvent(history[i++], "spaNavigated", "spaNavigation", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject, validations.hasUrl); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + +test("spaNavigation + redirect", async () => { + fetchJson = fetchDefinitions.spaNavigateRedirect; + + const cleanup = watchEvents(false); + try { + + const link = document.createElement("a"); + link.href = "/test"; + await spa.handleSpaNavigation(link, (u: string) => {}); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "spaNavigating", "spaNavigation", validations.hasSender, validations.hasCancel, validations.hasUrl); + validateEvent(history[i++], "redirect", "spaNavigation", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject, validations.hasUrl, validations.hasReplace); + validateEvent(history[i++], "spaNavigating", "spaNavigation", validations.hasCancel, validations.hasUrl); + validateEvent(history[i++], "spaNavigated", "spaNavigation", validations.hasResponse, validations.hasServerResponseObject, validations.hasUrl); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + + replaceViewModel(originalViewModel as RootViewModel); + } + +}); + +test("spaNavigation + redirect with replace (new page is loaded without SPA)", async () => { + fetchJson = fetchDefinitions.spaNavigateRedirectWithReplace; + + const cleanup = watchEvents(false); + try { + + const link = document.createElement("a"); + link.href = "/test"; + await spa.handleSpaNavigation(link, (u: string) => {}); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "spaNavigating", "spaNavigation", validations.hasSender, validations.hasCancel, validations.hasUrl); + validateEvent(history[i++], "redirect", "spaNavigation", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject, validations.hasUrl, validations.hasReplace); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + + replaceViewModel(originalViewModel as RootViewModel); + } + +}); + +test("spaNavigation + network error", async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + + fetchJson = fetchDefinitions.networkError; + + const cleanup = watchEvents(false); + try { + + const link = document.createElement("a"); + link.href = "/test"; + await expect(spa.handleSpaNavigation(link, (u: string) => {})).rejects.toBeInstanceOf(DotvvmPostbackError); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "spaNavigating", "spaNavigation", validations.hasSender, validations.hasCancel); + validateEvent(history[i++], "spaNavigationFailed", "spaNavigation", validations.hasSender, validations.hasError, validations.hasUrl); + validateEvent(history[i++], "error", "spaNavigation", validations.hasSender, validations.hasHandled, validations.hasError); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + +test("spaNavigation + server error", async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + + fetchJson = fetchDefinitions.spaNavigateError; + + const cleanup = watchEvents(false); + try { + + const link = document.createElement("a"); + link.href = "/test"; + await expect(spa.handleSpaNavigation(link, (u: string) => {})).rejects.toBeInstanceOf(DotvvmPostbackError); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "spaNavigating", "spaNavigation", validations.hasSender, validations.hasCancel); + validateEvent(history[i++], "spaNavigationFailed", "spaNavigation", validations.hasSender, validations.hasResponse, validations.hasServerResponseObject, validations.hasError, validations.hasUrl); + validateEvent(history[i++], "error", "spaNavigation", validations.hasSender, validations.hasHandled, validations.hasResponse, validations.hasError, validations.hasServerResponseObject); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + + + +test("staticCommand (JS only) + success", async () => { + const cleanup = watchEvents(false); + try { + + await applyPostbackHandlers(options => (function(a,b) { + return Promise.resolve(a.$data.Property1(b)); + })(ko.contextFor(window.document.body)), window.document.body, [], [1]); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "postbackHandlersStarted", "staticCommand", validations.hasSender); + validateEvent(history[i++], "postbackHandlersCompleted", "staticCommand", validations.hasSender); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + +test("staticCommand (with server call) + success", async () => { + fetchJson = fetchDefinitions.staticCommandSuccess; + + const cleanup = watchEvents(false); + try { + + await applyPostbackHandlers(options => (function(a,b){ + return new Promise(function(resolve,reject){ + dotvvm.staticCommandPostback(a,"test",[],options).then(function(r_0){resolve(r_0);},reject); + }); + }(window.document.body, ko.contextFor(window.document.body))), window.document.body, [], []); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "postbackHandlersStarted", "staticCommand", validations.hasSender); + validateEvent(history[i++], "postbackHandlersCompleted", "staticCommand", validations.hasSender); + validateEvent(history[i++], "staticCommandMethodInvoking", "staticCommand", validations.hasSender, validations.hasMethodId, validations.hasMethodArgs); + validateEvent(history[i++], "staticCommandMethodInvoked", "staticCommand", validations.hasSender, validations.hasMethodId, validations.hasMethodArgs, validations.hasResult, validations.hasResponse); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + +test("staticCommand (with two server call) + success", async () => { + fetchJson = fetchDefinitions.staticCommandSuccess; + + const cleanup = watchEvents(false); + try { + + await applyPostbackHandlers(options => (function(a,b){ + return new Promise(function(resolve,reject){ + dotvvm.staticCommandPostback(a,"test",[],options).then(function(r_0){ + dotvvm.staticCommandPostback(a,"test2",[],options).then(function(r_1){ + resolve(r_1); + }, reject); + }, reject); + }); + }(window.document.body, ko.contextFor(window.document.body))), window.document.body, [], []); + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "postbackHandlersStarted", "staticCommand", validations.hasSender); + validateEvent(history[i++], "postbackHandlersCompleted", "staticCommand", validations.hasSender); + validateEvent(history[i++], "staticCommandMethodInvoking", "staticCommand", validations.hasSender, validations.hasMethodId, validations.hasMethodArgs, validations.hasArgs); + validateEvent(history[i++], "staticCommandMethodInvoked", "staticCommand", validations.hasSender, validations.hasMethodId, validations.hasMethodArgs, validations.hasArgs, validations.hasResult, validations.hasResponse); + validateEvent(history[i++], "staticCommandMethodInvoking", "staticCommand", validations.hasSender, validations.hasMethodId, validations.hasMethodArgs, validations.hasArgs); + validateEvent(history[i++], "staticCommandMethodInvoked", "staticCommand", validations.hasSender, validations.hasMethodId, validations.hasMethodArgs, validations.hasArgs, validations.hasResult, validations.hasResponse); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); + + +test("staticCommand (with server call) + server error", async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + + fetchJson = fetchDefinitions.staticCommandServerError; + + const cleanup = watchEvents(false); + try { + + await expect( + applyPostbackHandlers(options => (function(a,b){ + return new Promise(function(resolve,reject){ + dotvvm.staticCommandPostback(a,"test",[],options).then(function(r_0){resolve(r_0);},reject); + }); + }(window.document.body, ko.contextFor(window.document.body))), window.document.body, [], []) + ).rejects.toBeInstanceOf(DotvvmPostbackError);; + + var history = getEventHistory(); + + let i = 1; // skip the "init" event + validateEvent(history[i++], "postbackHandlersStarted", "staticCommand", validations.hasSender); + validateEvent(history[i++], "postbackHandlersCompleted", "staticCommand", validations.hasSender); + validateEvent(history[i++], "staticCommandMethodInvoking", "staticCommand", validations.hasSender, validations.hasMethodId, validations.hasMethodArgs, validations.hasArgs); + validateEvent(history[i++], "staticCommandMethodFailed", "staticCommand", validations.hasSender, validations.hasMethodId, validations.hasMethodArgs, validations.hasArgs, validations.hasError, validations.hasResponse); + validateEvent(history[i++], "error", "staticCommand", validations.hasSender, validations.hasHandled, validations.hasResponse, validations.hasError, validations.hasServerResponseObject); + + expect(history.length).toBe(i); + } + finally { + cleanup(); + } + +}); diff --git a/src/DotVVM.Framework/Resources/Scripts/tests/helper.ts b/src/DotVVM.Framework/Resources/Scripts/tests/helper.ts index 5971fcb849..cb1e822d50 100644 --- a/src/DotVVM.Framework/Resources/Scripts/tests/helper.ts +++ b/src/DotVVM.Framework/Resources/Scripts/tests/helper.ts @@ -1,31 +1,59 @@ import dotvvm from '../dotvvm-root' import { keys } from '../utils/objects' +import { events as validationEvents } from '../validation/validation' + +type EventHistoryEntry = { + event: string, + args: any +} + +const eventHistory: EventHistoryEntry[] = []; export function initDotvvm(viewModel: any, culture: string = "en-US") { + window.compileConstants.isSpa = false; + const input = window.document.createElement("input") input.value = JSON.stringify(viewModel) input.id = "__dot_viewmodel_root" document.body.appendChild(input) dotvvm.init(culture) +} + +export function initDotvvmWithSpa(viewModel: any, culture: string = "en-US") { + window.compileConstants.isSpa = true; + const input = window.document.createElement("input") + input.value = JSON.stringify(viewModel) + input.id = "__dot_viewmodel_root" + document.body.appendChild(input) + dotvvm.init(culture) } -export function watchEvents() { +export function watchEvents(consoleOutput: boolean = true) { const handlers: any = {} - for (const event of keys(dotvvm.events)) { - if ("subscribe" in (dotvvm.events as any)[event]) { + const allEvents = { ...dotvvm.events, ...validationEvents }; + for (const event of keys(allEvents)) { + if ("subscribe" in (allEvents as any)[event]) { var h = function (args: any) { - console.debug("Event " + event, args.postbackClientId ?? args.postbackId ?? "") - }; - (dotvvm.events as any)[event].subscribe(h) + if (consoleOutput) { + console.debug("Event " + event, args.postbackId ?? "") + } + eventHistory.push({ event, args }) + } + (allEvents as any)[event].subscribe(h) handlers[event] = h } } return () => { - for (const event in keys(handlers)) { - (dotvvm.events as any)[event].unsubscribe(handlers[event]) + for (const event of keys(handlers)) { + (allEvents as any)[event].unsubscribe(handlers[event]) } + eventHistory.length = 0; } } + +export function getEventHistory() { + return eventHistory; +} diff --git a/src/DotVVM.Framework/Resources/Scripts/tests/postback.test.ts b/src/DotVVM.Framework/Resources/Scripts/tests/postback.test.ts index a715fb004c..cb093dd85a 100644 --- a/src/DotVVM.Framework/Resources/Scripts/tests/postback.test.ts +++ b/src/DotVVM.Framework/Resources/Scripts/tests/postback.test.ts @@ -4,10 +4,11 @@ import { initDotvvm, watchEvents } from './helper'; import { postBack } from '../postback/postback'; import { getViewModel } from '../dotvvm-base'; import { keys } from '../utils/objects'; -import { DotvvmPostbackError } from '../shared-classes'; import enable from '../binding-handlers/enable'; import { serialize } from '../serialization/serialize'; import { getPostbackQueue, postbackQueues } from '../postback/queue'; +import { DotvvmPostbackError } from '../shared-classes'; +import { WrappedResponse } from '../postback/http'; var fetchJson = async function(url: string, init: RequestInit): Promise { // the implementation is replaced by individual tests @@ -50,7 +51,7 @@ jest.mock("../postback/http", () => ({ return postbackFunction() }, - async getJSON(url: string, spaPlaceHolderUniqueId?: string, additionalHeaders?: { [key: string]: string }): Promise { + async getJSON(url: string, spaPlaceHolderUniqueId?: string, additionalHeaders?: { [key: string]: string }): Promise> { const headers = new Headers(); headers.append('Accept', 'application/json'); if (compileConstants.isSpa && spaPlaceHolderUniqueId) { @@ -58,16 +59,16 @@ jest.mock("../postback/http", () => ({ } appendAdditionalHeaders(headers, additionalHeaders); - return await fetchJson(url, { headers: headers }); + return { response: { fake: "get" } as any as Response, result: await fetchJson(url, { headers: headers }) }; }, - async postJSON(url: string, postData: any, additionalHeaders?: { [key: string]: string }): Promise { + async postJSON(url: string, postData: any, additionalHeaders?: { [key: string]: string }): Promise> { const headers = new Headers(); headers.append('Content-Type', 'application/json'); headers.append('X-DotVVM-PostBack', 'true'); appendAdditionalHeaders(headers, additionalHeaders); - return await fetchJson(url, { body: postData, headers: headers, method: "POST" }); + return { response: { fake: "post" } as any as Response, result: await fetchJson(url, { body: postData, headers: headers, method: "POST" }) }; } })); @@ -172,7 +173,7 @@ test("Postback: sanity check", async () => { const obj = JSON.parse(init.body as string) expect(obj.command).toBe("c") expect(obj.renderedResources).toStrictEqual(["resource1", "resource2"]) - expect(obj.additionalData.validationTargetPath).toBe("$data") + expect(obj.validationTargetPath).toBe("$data") expect(obj.viewModel.$csrfToken).toBe("test token") expect(obj.viewModel.Property1).toBe(0) @@ -241,7 +242,7 @@ test("Run postbacks [Queue | no failures]", async () => { expect(state().Property1).toBe(parallelism) } - ), { timeout: 500 }) + ), { timeout: 2000 }) }) test("Run postbacks [Queue + Deny | no failures]", async () => { @@ -305,7 +306,7 @@ test("Run postbacks [Queue + Deny | no failures]", async () => { expect(state().Property2).toBeGreaterThan(0) await initDenyPostback } - ), { timeout: 500 }) + ), { timeout: 2000 }) }) test("Run postbacks [Queue + Default | no failures]", async () => { @@ -376,5 +377,5 @@ test("Run postbacks [Queue + Default | no failures]", async () => { expect(index2).toBe(parallelismD + 1) expect(state().Property2).toBe(index2) } - ), { timeout: 500 }) + ), { timeout: 2000 }) }) diff --git a/src/DotVVM.Framework/Resources/Scripts/tests/serialization.fastcheck.test.ts b/src/DotVVM.Framework/Resources/Scripts/tests/serialization.fastcheck.test.ts index 70f35ee490..3c63fa48f3 100644 --- a/src/DotVVM.Framework/Resources/Scripts/tests/serialization.fastcheck.test.ts +++ b/src/DotVVM.Framework/Resources/Scripts/tests/serialization.fastcheck.test.ts @@ -11,7 +11,7 @@ test('Serialize and parse date', () => { fc.assert(fc.property( reasonableDate, date => { - const serialized = serializeDate(date) + const serialized = serializeDate(date, false) const parsedDate = parseDate(serialized!) expect(date).toStrictEqual(parsedDate) @@ -19,7 +19,7 @@ test('Serialize and parse date', () => { const normalParsed = new Date(serialized!) expect(normalParsed).toStrictEqual(date) - expect(serializeDate(serialized)).toBe(serialized) + expect(serializeDate(serialized, false)).toBe(serialized) } )) diff --git a/src/DotVVM.Framework/Resources/Scripts/tests/serialization.test.ts b/src/DotVVM.Framework/Resources/Scripts/tests/serialization.test.ts index c0db97b9ae..6607666d60 100644 --- a/src/DotVVM.Framework/Resources/Scripts/tests/serialization.test.ts +++ b/src/DotVVM.Framework/Resources/Scripts/tests/serialization.test.ts @@ -2,6 +2,7 @@ import { validateType } from '../serialization/typeValidation' import { deserialize } from '../serialization/deserialize' import { serialize } from '../serialization/serialize' +import { serializeDate } from '../serialization/date' const assertObservable = (object: any): any => { expect(object).observable() @@ -1231,7 +1232,7 @@ class TestData { boolVm: boolean = true stringVm: string = "viewmodel" dateVm: Date = new Date(1995, 11, 17) - dateVmString: string = "1995-12-17T00:00:00.0000000" + dateVmString: string = serializeDate(new Date(1995, 11, 17))! // "new Date(1995, 11, 17)" depends on the local timezone, we need the the string representation to correspond with that array2Vm = ["aa", "bb"] array3Vm = ["aa", "bb", "cc"] objectVm = { Prop1: "aa", Prop2: "bb" } diff --git a/src/DotVVM.Framework/Resources/Scripts/tests/setup.js b/src/DotVVM.Framework/Resources/Scripts/tests/setup.js index 65011afadc..db41c8313d 100644 --- a/src/DotVVM.Framework/Resources/Scripts/tests/setup.js +++ b/src/DotVVM.Framework/Resources/Scripts/tests/setup.js @@ -3,7 +3,7 @@ // this PR should solve this in the future: https://github.com/facebook/jest/pull/6876 // global.Promise = require('promise'); -global.compileConstants = { isSpa: false, nomodules: false } +global.compileConstants = { isSpa: true, nomodules: false } global.ko = require("../knockout-latest.debug") global.dotvvm_Globalize = require("../Globalize/globalize") diff --git a/src/DotVVM.Framework/Resources/Scripts/validation/validation.ts b/src/DotVVM.Framework/Resources/Scripts/validation/validation.ts index 1269566337..512a9abde8 100644 --- a/src/DotVVM.Framework/Resources/Scripts/validation/validation.ts +++ b/src/DotVVM.Framework/Resources/Scripts/validation/validation.ts @@ -5,13 +5,13 @@ import { allErrors, detachAllErrors, ValidationError, getErrors } from "./error" import { DotvvmEvent } from '../events' import * as dotvvmEvents from '../events' import * as spaEvents from '../spa/events' -import { DotvvmPostbackError } from "../shared-classes" import { postbackHandlers } from "../postback/handlers" import { DotvvmValidationContext, ErrorsPropertyName } from "./common" import { hasOwnProperty, isPrimitive, keys } from "../utils/objects" import { validateType } from "../serialization/typeValidation" import { elementActions } from "./actions" import { getValidationRules } from "../dotvvm-base" +import { DotvvmPostbackError } from "../shared-classes" type ValidationSummaryBinding = { target: KnockoutObservable, @@ -20,7 +20,11 @@ type ValidationSummaryBinding = { hideWhenValid: boolean } -const validationErrorsChanged = new DotvvmEvent("dotvvm.validation.events.validationErrorsChanged"); +type DotvvmValidationErrorsChangedEventArgs = PostbackOptions & { + readonly allErrors: ValidationError[] +} + +const validationErrorsChanged = new DotvvmEvent("dotvvm.validation.events.validationErrorsChanged"); export const events = { validationErrorsChanged @@ -33,17 +37,19 @@ export const globalValidationObject = { } const createValidationHandler = (path: string) => ({ + name: "validate", execute: (callback: () => Promise, options: PostbackOptions) => { if (path) { - options.additionalPostbackData.validationTargetPath = path; + options.validationTargetPath = path; // resolve target const context = ko.contextFor(options.sender); const validationTarget = evaluator.evaluateOnViewModel(context, path); - detachAllErrors(); - validateViewModel(validationTarget); + watchAndTriggerValidationErrorChanged(options, () => { + detachAllErrors(); + validateViewModel(validationTarget); + }); - validationErrorsChanged.trigger({ }); if (allErrors.length > 0) { console.log("Validation failed: postback aborted; errors: ", allErrors); return Promise.reject(new DotvvmPostbackError({ type: "handler", handlerName: "validation", message: "Validation failed" })) @@ -58,26 +64,11 @@ export function init() { postbackHandlers["validate-root"] = () => createValidationHandler("dotvvm.viewModelObservables['root']"); postbackHandlers["validate-this"] = () => createValidationHandler("$data"); - dotvvmEvents.afterPostback.subscribe(args => { - if (!args.wasInterrupted && args.serverResponseObject) { - if (args.serverResponseObject.action === "successfulCommand") { - // merge validation rules from postback with those we already have (required when a new type appears in the view model) - mergeValidationRules(args); - args.handled = true; - } else if (args.serverResponseObject.action === "validationErrors") { - // apply validation errors from server - detachAllErrors(); - showValidationErrorsFromServer(args); - validationErrorsChanged.trigger(args); - args.handled = true; - } - } - - }); if (compileConstants.isSpa) { - spaEvents.spaNavigating.subscribe(_ => { - detachAllErrors(); - validationErrorsChanged.trigger({ }); + spaEvents.spaNavigating.subscribe(args => { + watchAndTriggerValidationErrorChanged(args, () => { + detachAllErrors(); + }); }); } @@ -194,8 +185,8 @@ function validateProperty(viewModel: any, property: KnockoutObservable, val } /** Adds validation rules from the serverResponseObject into our global validation rule collection */ -function mergeValidationRules(args: DotvvmAfterPostBackEventArgs) { - const newRules = args.serverResponseObject.validationRules; +export function mergeValidationRules(serverResponseObject: any) { + const newRules = serverResponseObject.validationRules; if (newRules) { const existingRules = getValidationRules(); for (const type of keys(newRules)) { @@ -268,27 +259,29 @@ function getValidationErrors( /** * Adds validation errors from the server to the appropriate arrays */ -function showValidationErrorsFromServer(args: DotvvmAfterPostBackEventArgs) { - // resolve validation target - const dataContext = ko.contextFor(args.sender); - const validationTarget = > evaluator.evaluateOnViewModel( - dataContext, - args.postbackOptions.additionalPostbackData.validationTargetPath!); - if (!validationTarget) { - return; - } +export function showValidationErrorsFromServer(dataContext: any, path: string, serverResponseObject: any, options: PostbackOptions) { + watchAndTriggerValidationErrorChanged(options, () => { + detachAllErrors() + // resolve validation target + const validationTarget = > evaluator.evaluateOnViewModel( + dataContext, + path!); + if (!validationTarget) { + return; + } - // add validation errors - for (const prop of args.serverResponseObject.modelState) { - // find the property - const propertyPath = prop.propertyPath; - const property = - propertyPath ? - evaluator.evaluateOnViewModel(ko.unwrap(validationTarget), propertyPath) : - validationTarget; + // add validation errors + for (const prop of serverResponseObject.modelState) { + // find the property + const propertyPath = prop.propertyPath; + const property = + propertyPath ? + evaluator.evaluateOnViewModel(ko.unwrap(validationTarget), propertyPath) : + validationTarget; - ValidationError.attach(prop.errorMessage, property); - } + ValidationError.attach(prop.errorMessage, property); + } + }); } function applyValidatorActions( @@ -305,3 +298,16 @@ function applyValidatorActions( validatorOptions[option]); } } + +function watchAndTriggerValidationErrorChanged(options: PostbackOptions, action: () => void) { + const originalErrorsCount = allErrors.length; + action(); + + const currentErrorsCount = allErrors.length; + if (originalErrorsCount == 0 && currentErrorsCount == 0) { + // no errors before, no errors now + return; + } + + validationErrorsChanged.trigger({ ...options, allErrors }); +} \ No newline at end of file diff --git a/src/DotVVM.Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs b/src/DotVVM.Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs index 6dd6fa2864..5d7ea79102 100644 --- a/src/DotVVM.Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs +++ b/src/DotVVM.Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs @@ -286,7 +286,7 @@ public void PopulateViewModel(IDotvvmRequestContext context, string serializedPo else viewModelConverter = new ViewModelJsonConverter(context.IsPostBack, viewModelMapper, context.Services); // get validation path - context.ModelState.ValidationTargetPath = data.SelectToken("additionalData.validationTargetPath")?.Value(); + context.ModelState.ValidationTargetPath = (string)data["validationTargetPath"]; // populate the ViewModel var serializer = CreateJsonSerializer(); diff --git a/src/DotVVM.Framework/package.json b/src/DotVVM.Framework/package.json index 576e8c883f..f3248b9b0c 100644 --- a/src/DotVVM.Framework/package.json +++ b/src/DotVVM.Framework/package.json @@ -17,6 +17,7 @@ "typescript": "4.0.3" }, "scripts": { + "build-development": "rollup -c", "build": "npm run build-production && npm run build-polyfills", "build-production": "rollup -c --environment BUILD:production", "tsc-build": "tsc -p .", diff --git a/src/DotVVM.Samples.Tests/Feature/JavascriptEventsTests.cs b/src/DotVVM.Samples.Tests/Feature/JavascriptEventsTests.cs index b1c536c62b..4a46627c55 100644 --- a/src/DotVVM.Samples.Tests/Feature/JavascriptEventsTests.cs +++ b/src/DotVVM.Samples.Tests/Feature/JavascriptEventsTests.cs @@ -36,11 +36,11 @@ public void Feature_JavascriptEvents_JavascriptEvents() browser.ConfirmAlert(); browser.Wait(); - AssertUI.AlertTextEquals(browser, "custom error handler"); + AssertUI.AlertTextEquals(browser, "afterPostback"); browser.ConfirmAlert(); browser.Wait(); - AssertUI.AlertTextEquals(browser, "afterPostback"); + AssertUI.AlertTextEquals(browser, "custom error handler"); browser.ConfirmAlert(); }); }