diff --git a/backend/Squidex.sln b/backend/Squidex.sln index 1215fb2ef..9abce5569 100644 --- a/backend/Squidex.sln +++ b/backend/Squidex.sln @@ -30,12 +30,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Users.MongoD EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Users.Tests", "tests\Squidex.Domain.Users.Tests\Squidex.Domain.Users.Tests.csproj", "{42184546-E3CB-4D4F-9495-43979B9C63B9}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Infrastructure.GetEventStore", "src\Squidex.Infrastructure.GetEventStore\Squidex.Infrastructure.GetEventStore.csproj", "{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Infrastructure.Azure", "src\Squidex.Infrastructure.Azure\Squidex.Infrastructure.Azure.csproj", "{7931187E-A1E6-4F89-8BC8-20A1E445579F}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{360C300D-0F7E-439D-A437-714C959E3CAD}" ProjectSection(SolutionItems) = preProject + Squidex.ruleset = Squidex.ruleset stylecop.json = stylecop.json EndProjectSection EndProject @@ -179,30 +176,6 @@ Global {42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x64.Build.0 = Release|Any CPU {42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x86.ActiveCfg = Release|Any CPU {42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x86.Build.0 = Release|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|x64.ActiveCfg = Debug|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|x64.Build.0 = Debug|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|x86.ActiveCfg = Debug|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|x86.Build.0 = Debug|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|Any CPU.Build.0 = Release|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|x64.ActiveCfg = Release|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|x64.Build.0 = Release|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|x86.ActiveCfg = Release|Any CPU - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|x86.Build.0 = Release|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|x64.ActiveCfg = Debug|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|x64.Build.0 = Debug|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|x86.ActiveCfg = Debug|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|x86.Build.0 = Debug|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|Any CPU.Build.0 = Release|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x64.ActiveCfg = Release|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x64.Build.0 = Release|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x86.ActiveCfg = Release|Any CPU - {7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x86.Build.0 = Release|Any CPU {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|Any CPU.Build.0 = Debug|Any CPU {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -326,8 +299,6 @@ Global {F7771E22-47BD-45C4-A133-FD7F1DE27CA0} = {7EDE8CF1-B1E4-4005-B154-834B944E0D7A} {27CF800D-890F-4882-BF05-44EC3233537D} = {7EDE8CF1-B1E4-4005-B154-834B944E0D7A} {42184546-E3CB-4D4F-9495-43979B9C63B9} = {7EDE8CF1-B1E4-4005-B154-834B944E0D7A} - {EF75E488-1324-4E18-A1BD-D3A05AE67B1F} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF} - {7931187E-A1E6-4F89-8BC8-20A1E445579F} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF} {F0A83301-50A5-40EA-A1A2-07C7858F5A3F} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A} {6B3F75B6-5888-468E-BA4F-4FC725DAEF31} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A} {79FEF326-CA5E-4698-B2BA-C16A4580B4D5} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A} diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 83d250a89..7daa8257a 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -41,9 +41,11 @@ "apps.leaveFailed": "Failed to leave app. Please reload.", "apps.listPageTitle": "Apps", "apps.loadFailed": "Failed to load apps. Please reload.", + "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.removeImage": "Remove image", "apps.removeImageFailed": "Failed to remove app image. Please reload.", "apps.updateFailed": "Failed to update app. Please reload.", + "apps.updateSettingsFailed": "Faield to update UI settings. Please reload.", "apps.upgradeHintCurrent": "You are on the {plan} plan.", "apps.upgradeHintUpgrade": "Upgrade!", "apps.uploadImage": "Drop an file to replace the app image. Use a square size.", @@ -52,11 +54,18 @@ "apps.uploadImageTooBig": "App image is too big.", "apps.welcomeSubtitle": "Welcome to Squidex.", "apps.welcomeTitle": "Hi {user}", + "appSettings.editors.deleteConfirmText": "Do you really want to remove this Editor URL?", + "appSettings.editors.deleteConfirmTitle": "Delete Editor URL", + "appSettings.editors.empty": "No Editor URL created yet.", + "appSettings.editors.title": "Custom Editors", "appSettings.hideScheduler": "Hide dialog for scheduled publishing", + "appSettings.patterns.deleteConfirmText": "Do you really want to remove this pattern?", + "appSettings.patterns.deleteConfirmTitle": "Delete pattern", + "appSettings.patterns.empty": "No pattern created yet.", + "appSettings.patterns.title": "Patterns", "appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)", "appSettings.reloaded": "UI Settings reloaded.", "appSettings.title": "UI Settings", - "appSettings.updateFailed": "Failed to update UI settings. Please reload.", "assets.createFolder": "Create Folder", "assets.createFolderFailed": "Failed to create asset folder. Please reload.", "assets.createFolderTooltip": "Create new folder (CTRL + SHIFT + G)", @@ -331,6 +340,7 @@ "common.settings": "Settings", "common.sidebar": "Sidebar Extension", "common.sidebarTour": "The sidebar navigation contains useful context specific links. Here you can view the history how this schema has changed over time.", + "common.skipped": "Skipped", "common.slug": "Slug", "common.stars.max": "Must not have more more than 15 stars", "common.status": "Status", @@ -528,10 +538,6 @@ "dashboard.trafficSummaryCard": "API Traffic Summary", "dashboard.welcomeText": "Welcome to **{app}** dashboard.", "dashboard.welcomeTitle": "Hi {user}", - "editors.deleteConfirmText": "Do you really want to remove this Editor URL?", - "editors.deleteConfirmTitle": "Delete Editor URL", - "editors.empty": "No Editor URL created yet.", - "editors.title": "Custom Editors", "eventConsumers.count": "Count", "eventConsumers.loadFailed": "Failed to load event consumers. Please reload.", "eventConsumers.pageTitle": "Event Consumers", @@ -563,10 +569,6 @@ "news.headline": "What's new?", "news.title": "New Features", "notifo.subscripeTooltip": "Click this button to subscribe to all changes and to receive push notifications.", - "patterns.deleteConfirmText": "Do you really want to remove this pattern?", - "patterns.deleteConfirmTitle": "Delete pattern", - "patterns.empty": "No pattern created yet.", - "patterns.title": "Patterns", "plans.billingPortal": "Billing Portal", "plans.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.", "plans.change": "Change", @@ -590,7 +592,7 @@ "roles.addFailed": "Failed to add role. Please reload.", "roles.default.owner": "Can do everything, including deleting the app.", "roles.default.reader": "Can only read assets and contents.", - "roles.defaults.developer": "Can use the API view, edit assets, contents, schemas, rules, workflows and patterns.", + "roles.defaults.developer": "Can use the API view, edit assets, contents, schemas, rules, workflows and appSettings.patterns.", "roles.defaults.editor": "Can edit assets and contents and view workflows.", "roles.deleteConfirmText": "Delete role", "roles.deleteConfirmTitle": "Do you really want to delete the role?", @@ -611,7 +613,8 @@ "roles.revokeFailed": "Failed to revoke role. Please reload.", "roles.roleNamePlaceholder": "Enter role name", "roles.updateFailed": "Failed to update role. Please reload.", - "rules.actionEdit": "Edit Action", + "rules.actionData": "Action Data", + "rules.actionHint": "The selection of the action type cannot be changed later.", "rules.cancelFailed": "Failed to cancel rule. Please reload.", "rules.create": "New Rule", "rules.createFailed": "Failed to create rule. Please reload.", @@ -619,10 +622,8 @@ "rules.deleteConfirmText": "Do you really want to delete the rule?", "rules.deleteConfirmTitle": "Delete rule", "rules.deleteFailed": "Failed to delete rule. Please reload.", - "rules.disableFailed": "Failed to disable rule. Please reload.", "rules.empty": "No rule created yet.", "rules.emptyAddRule": "Add Rule", - "rules.enableFailed": "Failed to enable rule. Please reload.", "rules.enqueued": "Rule has been added to the queue.", "rules.itemPageTitle": "Rule", "rules.listPageTitle": "Rules", @@ -642,6 +643,7 @@ "rules.ruleEvents.nextAttemptLabel": "Next", "rules.ruleEvents.numAttemptsLabel": "Attempts", "rules.ruleEvents.reloaded": "RuleEvents reloaded.", + "rules.ruleSimulator.listPageTitle": "Simulator", "rules.ruleSyntax.if": "If", "rules.ruleSyntax.then": "then", "rules.run": "Run", @@ -650,17 +652,16 @@ "rules.runningRule": "Rule '{name}' is currently running.", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmTitle": "Run rule", + "rules.simulate": "Simulate", + "rules.simulateTooltip": "Simulate this rules using the last 100 events.", + "rules.simulator": "Simulator", "rules.stop": "Rule will stop soon.", "rules.triggerConfirmText": "Do you really want to trigger the rule?", "rules.triggerConfirmTitle": "Trigger rule", - "rules.triggerEdit": "Edit Trigger", "rules.triggerFailed": "Failed to trigger rule. Please reload.", + "rules.triggerHint": "The selection of the trigger type cannot be changed later.", "rules.unnamed": "Unnamed Rule", "rules.updateFailed": "Failed to update rule. Please reload.", - "rules.wizard.actionHint": "The selection of the action type cannot be changed later.", - "rules.wizard.selectAction": "Select Action", - "rules.wizard.selectTrigger": "Select Trigger", - "rules.wizard.triggerHint": "The selection of the trigger type cannot be changed later.", "schemas.addField": "Add Field", "schemas.addFieldAndClose": "Create and close", "schemas.addFieldAndCreate": "Create and add field", @@ -883,7 +884,7 @@ "tour.step1Next": "Continue", "tour.step1Text": "An App is the repository for your project, e.g. (blog, web shop or mobile app). You can assign contributors to your app to work together.\n\nYou can create an unlimited number of Apps in Squidex to manage multiple projects at the same time.", "tour.step2Next": "Keep going!", - "tour.step2Text": "Schemas define the structure of your content, the fields and the data types of a content item.\n\nBefore you can add content to your schema, make sure to hit the 'Publish' button at the top to make the schema available to your content editors.", + "tour.step2Text": "Schemas define the structure of your content, the fields and the data types of a content item.\n\nBefore you can add content to your schema, make sure to hit the 'Publish' button at the top to make the schema available to your content appSettings.editors.", "tour.step3Next": "Almost there!", "tour.step3Text": "Content is the actual data in your app which is grouped by the schema.\n\nSelect a published schema first, then add content for this schema.", "tour.step4Next": "Got It!", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 7c7f2d19f..510a7e18c 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -41,9 +41,11 @@ "apps.leaveFailed": "Non è stato possibile uscire dall'app. Per favore ricarica.", "apps.listPageTitle": "App", "apps.loadFailed": "Non è stato possibile caricare le App. Per favore ricarica.", + "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.removeImage": "Rimuovi l'immagine", "apps.removeImageFailed": "Non è stato possibile rimuovere l'immagine dell'app. Per favore ricarica.", "apps.updateFailed": "Non è stato possibile aggiornare l'app. Per favore ricarica.", + "apps.updateSettingsFailed": "Faield to update UI settings. Please reload.", "apps.upgradeHintCurrent": "Tu sei nel piano {plan}.", "apps.upgradeHintUpgrade": "Aggiorna!", "apps.uploadImage": "Trascina il file per sostituire l'immagine dell'app. Utilizza una dimensione quadrata.", @@ -52,11 +54,18 @@ "apps.uploadImageTooBig": "L'immagine dell'app è troppo grande.", "apps.welcomeSubtitle": "Benvenuto su Squidex.", "apps.welcomeTitle": "Ciao {user}", + "appSettings.editors.deleteConfirmText": "Do you really want to remove this Editor URL?", + "appSettings.editors.deleteConfirmTitle": "Delete Editor URL", + "appSettings.editors.empty": "No Editor URL created yet.", + "appSettings.editors.title": "Custom Editors", "appSettings.hideScheduler": "Hide dialog for scheduled publishing", + "appSettings.patterns.deleteConfirmText": "Sei sicuro di voler rimuovere il pattern?", + "appSettings.patterns.deleteConfirmTitle": "Cancella il pattern", + "appSettings.patterns.empty": "Nessun pattern è stato ancora creato.", + "appSettings.patterns.title": "Patterns", "appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)", "appSettings.reloaded": "UI Settings reloaded.", "appSettings.title": "UI Settings", - "appSettings.updateFailed": "Failed to update UI settings. Please reload.", "assets.createFolder": "Crea cartella", "assets.createFolderFailed": "Non è stato possibile creare la cartella degli asset. Per favore ricarica.", "assets.createFolderTooltip": "Crea una nuova cartella (CTRL + SHIFT + G)", @@ -331,6 +340,7 @@ "common.settings": "Impostazioni", "common.sidebar": "Estensione della barra di navigazione laterale", "common.sidebarTour": "La barra di navigazione laterale contiene specifici utili collegamenti per il contesto. Qui puoi visualizzare la cronologia dei cambiamenti di questo schema.", + "common.skipped": "Skipped", "common.slug": "Slug", "common.stars.max": "Non deve avere più di 15 stelle", "common.status": "Stato", @@ -528,10 +538,6 @@ "dashboard.trafficSummaryCard": "Riepilogo del traffico delle API", "dashboard.welcomeText": "Benvenuto sulla dashboard **{app}**.", "dashboard.welcomeTitle": "Ciao {user}", - "editors.deleteConfirmText": "Do you really want to remove this Editor URL?", - "editors.deleteConfirmTitle": "Delete Editor URL", - "editors.empty": "No Editor URL created yet.", - "editors.title": "Custom Editors", "eventConsumers.count": "Conteggio", "eventConsumers.loadFailed": "Non è stato possibile caricare event consumers. Per favore ricarica.", "eventConsumers.pageTitle": "Eventi degli utenti", @@ -563,10 +569,6 @@ "news.headline": "Che cosa c'è di nuovo?", "news.title": "Nuove funzionalità", "notifo.subscripeTooltip": "Fai clic su questo pulsante per iscriverti a tutte le modifiche e ricevere le notifiche push.", - "patterns.deleteConfirmText": "Sei sicuro di voler rimuovere il pattern?", - "patterns.deleteConfirmTitle": "Cancella il pattern", - "patterns.empty": "Nessun pattern è stato ancora creato.", - "patterns.title": "Patterns", "plans.billingPortal": "Portale di fatturazione", "plans.billingPortalHint": "Vai al portale di fatturazione per lo storico dei pagamenti e una panoramica per l'abbonamento.", "plans.change": "Cambia", @@ -611,7 +613,8 @@ "roles.revokeFailed": "Non è stato possibile rimuovere il ruolo. Per favore ricarica.", "roles.roleNamePlaceholder": "Inserisci il nome del ruolo", "roles.updateFailed": "Non è stato possibile aggiornare il ruolo. Per favore ricarica.", - "rules.actionEdit": "Modifica l'Azione", + "rules.actionData": "Action Data", + "rules.actionHint": "The selection of the action type cannot be changed later.", "rules.cancelFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.", "rules.create": "Crea un nuova Regola", "rules.createFailed": "Non è stato possibile creare una nuova regola. Per favore ricarica.", @@ -619,10 +622,8 @@ "rules.deleteConfirmText": "Sei sicuro di voler eliminare la regola?", "rules.deleteConfirmTitle": "Cancella la regola", "rules.deleteFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.", - "rules.disableFailed": "Non è stato possibile disabilitare la regola. Per favore ricarica.", "rules.empty": "Nessuna regola è stato ancora creata.", "rules.emptyAddRule": "Aggiungi una regola", - "rules.enableFailed": "Non è stato possibile abilitare la regola. Per favore ricarica.", "rules.enqueued": "La regola è stata aggiunta alle code.", "rules.itemPageTitle": "Rule", "rules.listPageTitle": "Regole", @@ -642,6 +643,7 @@ "rules.ruleEvents.nextAttemptLabel": "Successivo", "rules.ruleEvents.numAttemptsLabel": "Tentativi", "rules.ruleEvents.reloaded": "Eventi della regola ricaricati.", + "rules.ruleSimulator.listPageTitle": "Simulator", "rules.ruleSyntax.if": "Se", "rules.ruleSyntax.then": "Allora", "rules.run": "Esegui", @@ -650,17 +652,16 @@ "rules.runningRule": "La regola '{name}' è attualmente in esecuzione.", "rules.runRuleConfirmText": "Sei sicuro di voler eseguire la regola per tutti gli eventi?", "rules.runRuleConfirmTitle": "Esegui la regola", + "rules.simulate": "Simulate", + "rules.simulateTooltip": "Simulate this rules using the last 100 events.", + "rules.simulator": "Simulator", "rules.stop": "La regola si fermerà al più presto.", "rules.triggerConfirmText": "Sei sicuro che voler attivare la regola?", "rules.triggerConfirmTitle": "Attiva la regola", - "rules.triggerEdit": "Modifica l'Attivazione", "rules.triggerFailed": "Non è stato possibile attivare la regola. Per favore ricarica.", + "rules.triggerHint": "The selection of the trigger type cannot be changed later.", "rules.unnamed": "Regola senza nome", "rules.updateFailed": "Non è stato possibile aggiornare la regola. Per favore ricarica.", - "rules.wizard.actionHint": "La selezione del tipo di azione non potrà essere modificata successivamente.", - "rules.wizard.selectAction": "Seleziona l'Azione", - "rules.wizard.selectTrigger": "Seleziona l'Attivazione", - "rules.wizard.triggerHint": "La selezione del tipo di attivazione non potrà essere modificata successivamente.", "schemas.addField": "Aggiungi un Campo", "schemas.addFieldAndClose": "Crea e chiudi", "schemas.addFieldAndCreate": "Crea e aggiungi il campo", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 572b47cc4..40fa88482 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -41,9 +41,11 @@ "apps.leaveFailed": "Failed to leave app. Please reload.", "apps.listPageTitle": "Apps", "apps.loadFailed": "Laden van apps is mislukt. Laad opnieuw.", + "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.removeImage": "Afbeelding verwijderen", "apps.removeImageFailed": "Verwijderen van app-afbeelding is mislukt. Laad opnieuw.", "apps.updateFailed": "Update app mislukt. Laad opnieuw.", + "apps.updateSettingsFailed": "Faield to update UI settings. Please reload.", "apps.upgradeHintCurrent": "Je zit in het plan {plan}.", "apps.upgradeHintUpgrade": "Upgrade!", "apps.uploadImage": "Zet een bestand neer om de app-afbeelding te vervangen. Gebruik een vierkant formaat.", @@ -52,11 +54,18 @@ "apps.uploadImageTooBig": "App-afbeelding is te groot.", "apps.welcomeSubtitle": "Welkom bij Squidex.", "apps.welcomeTitle": "Hallo {user}", + "appSettings.editors.deleteConfirmText": "Do you really want to remove this Editor URL?", + "appSettings.editors.deleteConfirmTitle": "Delete Editor URL", + "appSettings.editors.empty": "No Editor URL created yet.", + "appSettings.editors.title": "Custom Editors", "appSettings.hideScheduler": "Hide dialog for scheduled publishing", + "appSettings.patterns.deleteConfirmText": "Wil je dit patroon echt verwijderen?", + "appSettings.patterns.deleteConfirmTitle": "Verwijder patroon", + "appSettings.patterns.empty": "Nog geen patroon gemaakt.", + "appSettings.patterns.title": "Patterns", "appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)", "appSettings.reloaded": "UI Settings reloaded.", "appSettings.title": "UI Settings", - "appSettings.updateFailed": "Failed to update UI settings. Please reload.", "assets.createFolder": "Map maken", "assets.createFolderFailed": "Maken van een map is mislukt. Laad opnieuw.", "assets.createFolderTooltip": "Nieuwe map maken (CTRL + SHIFT + G)", @@ -331,6 +340,7 @@ "common.settings": "Instellingen", "common.sidebar": "Zijbalk Uitbreiding", "common.sidebarTour": "De zijbalknavigatie bevat nuttige contextspecifieke links. Hier kun je de geschiedenis bekijken hoe dit schema in de loop van de tijd is veranderd.", + "common.skipped": "Skipped", "common.slug": "Slug", "common.stars.max": "Mag niet meer dan 15 sterren hebben", "common.status": "Status", @@ -528,10 +538,6 @@ "dashboard.trafficSummaryCard": "API Verkeer Samenvatting", "dashboard.welcomeText": "Welkom bij **{app}** dashboard.", "dashboard.welcomeTitle": "Hallo {user}", - "editors.deleteConfirmText": "Do you really want to remove this Editor URL?", - "editors.deleteConfirmTitle": "Delete Editor URL", - "editors.empty": "No Editor URL created yet.", - "editors.title": "Custom Editors", "eventConsumers.count": "Tellen", "eventConsumers.loadFailed": "Kan gebeurtenisgebruikers niet laden. Laad opnieuw.", "eventConsumers.pageTitle": "Evenementconsumenten", @@ -563,10 +569,6 @@ "news.headline": "Wat is er nieuw?", "news.title": "Nieuwe functies", "notifo.subscripeTooltip": "Klik op deze knop om je te abonneren op alle wijzigingen en om pushmeldingen te ontvangen.", - "patterns.deleteConfirmText": "Wil je dit patroon echt verwijderen?", - "patterns.deleteConfirmTitle": "Verwijder patroon", - "patterns.empty": "Nog geen patroon gemaakt.", - "patterns.title": "Patterns", "plans.billingPortal": "Factureringsportal", "plans.billingPortalHint": "Ga naar het factureringsportaal voor betalingsgeschiedenis en abonnementsoverzicht.", "plans.change": "Wijzigen", @@ -611,7 +613,8 @@ "roles.revokeFailed": "Kan rol niet intrekken. Laad opnieuw.", "roles.roleNamePlaceholder": "Voer de rolnaam in", "roles.updateFailed": "Update rol mislukt. Laad opnieuw.", - "rules.actionEdit": "Bewerk actie", + "rules.actionData": "Action Data", + "rules.actionHint": "The selection of the action type cannot be changed later.", "rules.cancelFailed": "Annuleren van regel is mislukt. Laad opnieuw.", "rules.create": "Maak een nieuwe regel", "rules.createFailed": "Maken van regel is mislukt. Laad opnieuw.", @@ -619,10 +622,8 @@ "rules.deleteConfirmText": "Wil je de regel echt verwijderen?", "rules.deleteConfirmTitle": "Regel verwijderen", "rules.deleteFailed": "Verwijderen van regel is mislukt. Laad opnieuw.", - "rules.disableFailed": "Kan regel niet uitschakelen. Laad opnieuw.", "rules.empty": "Nog geen regel aangemaakt.", "rules.emptyAddRule": "Regel toevoegen", - "rules.enableFailed": "Kan regel niet inschakelen. Laad opnieuw.", "rules.enqueued": "Regel is toegevoegd aan de wachtrij.", "rules.itemPageTitle": "Rule", "rules.listPageTitle": "Regels", @@ -642,6 +643,7 @@ "rules.ruleEvents.nextAttemptLabel": "Volgende", "rules.ruleEvents.numAttemptsLabel": "Pogingen", "rules.ruleEvents.reloaded": "RuleEvents herladen.", + "rules.ruleSimulator.listPageTitle": "Simulator", "rules.ruleSyntax.if": "If", "rules.ruleSyntax.then": "then", "rules.run": "Uitvoeren", @@ -650,17 +652,16 @@ "rules.runningRule": "Regel '{name}' is momenteel actief.", "rules.runRuleConfirmText": "Wil je de regel echt voor alle evenementen uitvoeren?", "rules.runRuleConfirmTitle": "Regel uitvoeren", + "rules.simulate": "Simulate", + "rules.simulateTooltip": "Simulate this rules using the last 100 events.", + "rules.simulator": "Simulator", "rules.stop": "Regel stopt binnenkort.", "rules.triggerConfirmText": "Wil je echt de regel activeren?", "rules.triggerConfirmTitle": "Trigger regel", - "rules.triggerEdit": "Trigger bewerken", "rules.triggerFailed": "Kan regel niet activeren. Laad opnieuw.", + "rules.triggerHint": "The selection of the trigger type cannot be changed later.", "rules.unnamed": "Naamloos regel", "rules.updateFailed": "Update regel mislukt. Laad opnieuw.", - "rules.wizard.actionHint": "De selectie van het actietype kan later niet worden gewijzigd.", - "rules.wizard.selectAction": "Selecteer actie", - "rules.wizard.selectTrigger": "Selecteer Trigger", - "rules.wizard.triggerHint": "De selectie van het triggertype kan later niet worden gewijzigd.", "schemas.addField": "Veld toevoegen", "schemas.addFieldAndClose": "Maken en sluiten", "schemas.addFieldAndCreate": "Maak en voeg veld toe", @@ -883,7 +884,7 @@ "tour.step1Next": "Doorgaan", "tour.step1Text": "Een app is de opslagplaats voor uw project, bijvoorbeeld (blog, webshop of mobiele app). Je kunt bijdragers aan uw app toewijzen om samen te werken. \n \n Je kunt een onbeperkt aantal apps maken in Squidex om meerdere projecten tegelijkertijd te beheren. ", "tour.step2Next": "Ga door!", - "tour.step2Text": "Schema's bepalen de structuur van uw inhoud, de velden en de gegevenstypen van een inhoudsitem. \n \n Voordat je inhoud aan uw schema kunt toevoegen, moet je op de knop 'Publiceren' bovenaan klikken om het schema beschikbaar te maken voor uw inhoudeditors. ", + "tour.step2Text": "Schema's bepalen de structuur van uw inhoud, de velden en de gegevenstypen van een inhoudsitem. \n \n Voordat je inhoud aan uw schema kunt toevoegen, moet je op de knop 'Publiceren' bovenaan klikken om het schema beschikbaar te maken voor uw inhoudappSettings.editors. ", "tour.step3Next": "Bijna klaar!", "tour.step3Text": "Inhoud zijn de feitelijke gegevens in uw app die zijn gegroepeerd op basis van het schema. \n \n Selecteer eerst een gepubliceerd schema en voeg vervolgens inhoud toe voor dit schema.", "tour.step4Next": "Begrepen!", diff --git a/backend/i18n/source/frontend__ignore.json b/backend/i18n/source/frontend__ignore.json index 4635dc8c0..f8729b10d 100644 --- a/backend/i18n/source/frontend__ignore.json +++ b/backend/i18n/source/frontend__ignore.json @@ -193,13 +193,13 @@ "/shared/services/schemas.types.ts": [ "Invalid properties type" ], + "/shared/state/appSettings.patterns.forms.ts": [ + "[A-z0-9]+[A-z0-9\\- ]*[A-z0-9]" + ], "/shared/state/contents.forms.visitors.ts": [ "", "yyyy-MM-dd HH:mm:ss" ], - "/shared/state/patterns.forms.ts": [ - "[A-z0-9]+[A-z0-9\\- ]*[A-z0-9]" - ], "/shared/state/query.ts": [ "ends with", "is empty", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 83d250a89..7daa8257a 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -41,9 +41,11 @@ "apps.leaveFailed": "Failed to leave app. Please reload.", "apps.listPageTitle": "Apps", "apps.loadFailed": "Failed to load apps. Please reload.", + "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.removeImage": "Remove image", "apps.removeImageFailed": "Failed to remove app image. Please reload.", "apps.updateFailed": "Failed to update app. Please reload.", + "apps.updateSettingsFailed": "Faield to update UI settings. Please reload.", "apps.upgradeHintCurrent": "You are on the {plan} plan.", "apps.upgradeHintUpgrade": "Upgrade!", "apps.uploadImage": "Drop an file to replace the app image. Use a square size.", @@ -52,11 +54,18 @@ "apps.uploadImageTooBig": "App image is too big.", "apps.welcomeSubtitle": "Welcome to Squidex.", "apps.welcomeTitle": "Hi {user}", + "appSettings.editors.deleteConfirmText": "Do you really want to remove this Editor URL?", + "appSettings.editors.deleteConfirmTitle": "Delete Editor URL", + "appSettings.editors.empty": "No Editor URL created yet.", + "appSettings.editors.title": "Custom Editors", "appSettings.hideScheduler": "Hide dialog for scheduled publishing", + "appSettings.patterns.deleteConfirmText": "Do you really want to remove this pattern?", + "appSettings.patterns.deleteConfirmTitle": "Delete pattern", + "appSettings.patterns.empty": "No pattern created yet.", + "appSettings.patterns.title": "Patterns", "appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)", "appSettings.reloaded": "UI Settings reloaded.", "appSettings.title": "UI Settings", - "appSettings.updateFailed": "Failed to update UI settings. Please reload.", "assets.createFolder": "Create Folder", "assets.createFolderFailed": "Failed to create asset folder. Please reload.", "assets.createFolderTooltip": "Create new folder (CTRL + SHIFT + G)", @@ -331,6 +340,7 @@ "common.settings": "Settings", "common.sidebar": "Sidebar Extension", "common.sidebarTour": "The sidebar navigation contains useful context specific links. Here you can view the history how this schema has changed over time.", + "common.skipped": "Skipped", "common.slug": "Slug", "common.stars.max": "Must not have more more than 15 stars", "common.status": "Status", @@ -528,10 +538,6 @@ "dashboard.trafficSummaryCard": "API Traffic Summary", "dashboard.welcomeText": "Welcome to **{app}** dashboard.", "dashboard.welcomeTitle": "Hi {user}", - "editors.deleteConfirmText": "Do you really want to remove this Editor URL?", - "editors.deleteConfirmTitle": "Delete Editor URL", - "editors.empty": "No Editor URL created yet.", - "editors.title": "Custom Editors", "eventConsumers.count": "Count", "eventConsumers.loadFailed": "Failed to load event consumers. Please reload.", "eventConsumers.pageTitle": "Event Consumers", @@ -563,10 +569,6 @@ "news.headline": "What's new?", "news.title": "New Features", "notifo.subscripeTooltip": "Click this button to subscribe to all changes and to receive push notifications.", - "patterns.deleteConfirmText": "Do you really want to remove this pattern?", - "patterns.deleteConfirmTitle": "Delete pattern", - "patterns.empty": "No pattern created yet.", - "patterns.title": "Patterns", "plans.billingPortal": "Billing Portal", "plans.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.", "plans.change": "Change", @@ -590,7 +592,7 @@ "roles.addFailed": "Failed to add role. Please reload.", "roles.default.owner": "Can do everything, including deleting the app.", "roles.default.reader": "Can only read assets and contents.", - "roles.defaults.developer": "Can use the API view, edit assets, contents, schemas, rules, workflows and patterns.", + "roles.defaults.developer": "Can use the API view, edit assets, contents, schemas, rules, workflows and appSettings.patterns.", "roles.defaults.editor": "Can edit assets and contents and view workflows.", "roles.deleteConfirmText": "Delete role", "roles.deleteConfirmTitle": "Do you really want to delete the role?", @@ -611,7 +613,8 @@ "roles.revokeFailed": "Failed to revoke role. Please reload.", "roles.roleNamePlaceholder": "Enter role name", "roles.updateFailed": "Failed to update role. Please reload.", - "rules.actionEdit": "Edit Action", + "rules.actionData": "Action Data", + "rules.actionHint": "The selection of the action type cannot be changed later.", "rules.cancelFailed": "Failed to cancel rule. Please reload.", "rules.create": "New Rule", "rules.createFailed": "Failed to create rule. Please reload.", @@ -619,10 +622,8 @@ "rules.deleteConfirmText": "Do you really want to delete the rule?", "rules.deleteConfirmTitle": "Delete rule", "rules.deleteFailed": "Failed to delete rule. Please reload.", - "rules.disableFailed": "Failed to disable rule. Please reload.", "rules.empty": "No rule created yet.", "rules.emptyAddRule": "Add Rule", - "rules.enableFailed": "Failed to enable rule. Please reload.", "rules.enqueued": "Rule has been added to the queue.", "rules.itemPageTitle": "Rule", "rules.listPageTitle": "Rules", @@ -642,6 +643,7 @@ "rules.ruleEvents.nextAttemptLabel": "Next", "rules.ruleEvents.numAttemptsLabel": "Attempts", "rules.ruleEvents.reloaded": "RuleEvents reloaded.", + "rules.ruleSimulator.listPageTitle": "Simulator", "rules.ruleSyntax.if": "If", "rules.ruleSyntax.then": "then", "rules.run": "Run", @@ -650,17 +652,16 @@ "rules.runningRule": "Rule '{name}' is currently running.", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmTitle": "Run rule", + "rules.simulate": "Simulate", + "rules.simulateTooltip": "Simulate this rules using the last 100 events.", + "rules.simulator": "Simulator", "rules.stop": "Rule will stop soon.", "rules.triggerConfirmText": "Do you really want to trigger the rule?", "rules.triggerConfirmTitle": "Trigger rule", - "rules.triggerEdit": "Edit Trigger", "rules.triggerFailed": "Failed to trigger rule. Please reload.", + "rules.triggerHint": "The selection of the trigger type cannot be changed later.", "rules.unnamed": "Unnamed Rule", "rules.updateFailed": "Failed to update rule. Please reload.", - "rules.wizard.actionHint": "The selection of the action type cannot be changed later.", - "rules.wizard.selectAction": "Select Action", - "rules.wizard.selectTrigger": "Select Trigger", - "rules.wizard.triggerHint": "The selection of the trigger type cannot be changed later.", "schemas.addField": "Add Field", "schemas.addFieldAndClose": "Create and close", "schemas.addFieldAndCreate": "Create and add field", @@ -883,7 +884,7 @@ "tour.step1Next": "Continue", "tour.step1Text": "An App is the repository for your project, e.g. (blog, web shop or mobile app). You can assign contributors to your app to work together.\n\nYou can create an unlimited number of Apps in Squidex to manage multiple projects at the same time.", "tour.step2Next": "Keep going!", - "tour.step2Text": "Schemas define the structure of your content, the fields and the data types of a content item.\n\nBefore you can add content to your schema, make sure to hit the 'Publish' button at the top to make the schema available to your content editors.", + "tour.step2Text": "Schemas define the structure of your content, the fields and the data types of a content item.\n\nBefore you can add content to your schema, make sure to hit the 'Publish' button at the top to make the schema available to your content appSettings.editors.", "tour.step3Next": "Almost there!", "tour.step3Text": "Content is the actual data in your app which is grouped by the schema.\n\nSelect a published schema first, then add content for this schema.", "tour.step4Next": "Got It!", diff --git a/backend/i18n/source/frontend_it.json b/backend/i18n/source/frontend_it.json index 788332b03..62aaa1801 100644 --- a/backend/i18n/source/frontend_it.json +++ b/backend/i18n/source/frontend_it.json @@ -52,6 +52,9 @@ "apps.uploadImageTooBig": "L'immagine dell'app è troppo grande.", "apps.welcomeSubtitle": "Benvenuto su Squidex.", "apps.welcomeTitle": "Ciao {user}", + "appSettings.patterns.deleteConfirmText": "Sei sicuro di voler rimuovere il pattern?", + "appSettings.patterns.deleteConfirmTitle": "Cancella il pattern", + "appSettings.patterns.empty": "Nessun pattern è stato ancora creato.", "assets.createFolder": "Crea cartella", "assets.createFolderFailed": "Non è stato possibile creare la cartella degli asset. Per favore ricarica.", "assets.createFolderTooltip": "Crea una nuova cartella (CTRL + SHIFT + G)", @@ -545,9 +548,6 @@ "news.headline": "Che cosa c'è di nuovo?", "news.title": "Nuove funzionalità", "notifo.subscripeTooltip": "Fai clic su questo pulsante per iscriverti a tutte le modifiche e ricevere le notifiche push.", - "patterns.deleteConfirmText": "Sei sicuro di voler rimuovere il pattern?", - "patterns.deleteConfirmTitle": "Cancella il pattern", - "patterns.empty": "Nessun pattern è stato ancora creato.", "plans.billingPortal": "Portale di fatturazione", "plans.billingPortalHint": "Vai al portale di fatturazione per lo storico dei pagamenti e una panoramica per l'abbonamento.", "plans.change": "Cambia", diff --git a/backend/i18n/source/frontend_nl.json b/backend/i18n/source/frontend_nl.json index 542821cb1..f6019dea5 100644 --- a/backend/i18n/source/frontend_nl.json +++ b/backend/i18n/source/frontend_nl.json @@ -48,6 +48,9 @@ "apps.uploadImageTooBig": "App-afbeelding is te groot.", "apps.welcomeSubtitle": "Welkom bij Squidex.", "apps.welcomeTitle": "Hallo {user}", + "appSettings.patterns.deleteConfirmText": "Wil je dit patroon echt verwijderen?", + "appSettings.patterns.deleteConfirmTitle": "Verwijder patroon", + "appSettings.patterns.empty": "Nog geen patroon gemaakt.", "assets.createFolder": "Map maken", "assets.createFolderFailed": "Maken van een map is mislukt. Laad opnieuw.", "assets.createFolderTooltip": "Nieuwe map maken (CTRL + SHIFT + G)", @@ -517,9 +520,6 @@ "news.headline": "Wat is er nieuw?", "news.title": "Nieuwe functies", "notifo.subscripeTooltip": "Klik op deze knop om je te abonneren op alle wijzigingen en om pushmeldingen te ontvangen.", - "patterns.deleteConfirmText": "Wil je dit patroon echt verwijderen?", - "patterns.deleteConfirmTitle": "Verwijder patroon", - "patterns.empty": "Nog geen patroon gemaakt.", "plans.billingPortal": "Factureringsportal", "plans.billingPortalHint": "Ga naar het factureringsportaal voor betalingsgeschiedenis en abonnementsoverzicht.", "plans.change": "Wijzigen", @@ -820,7 +820,7 @@ "tour.step1Next": "Doorgaan", "tour.step1Text": "Een app is de opslagplaats voor uw project, bijvoorbeeld (blog, webshop of mobiele app). Je kunt bijdragers aan uw app toewijzen om samen te werken. \n \n Je kunt een onbeperkt aantal apps maken in Squidex om meerdere projecten tegelijkertijd te beheren. ", "tour.step2Next": "Ga door!", - "tour.step2Text": "Schema's bepalen de structuur van uw inhoud, de velden en de gegevenstypen van een inhoudsitem. \n \n Voordat je inhoud aan uw schema kunt toevoegen, moet je op de knop 'Publiceren' bovenaan klikken om het schema beschikbaar te maken voor uw inhoudeditors. ", + "tour.step2Text": "Schema's bepalen de structuur van uw inhoud, de velden en de gegevenstypen van een inhoudsitem. \n \n Voordat je inhoud aan uw schema kunt toevoegen, moet je op de knop 'Publiceren' bovenaan klikken om het schema beschikbaar te maken voor uw inhoudappSettings.editors. ", "tour.step3Next": "Bijna klaar!", "tour.step3Text": "Inhoud zijn de feitelijke gegevens in uw app die zijn gegroepeerd op basis van het schema. \n \n Selecteer eerst een gepubliceerd schema en voeg vervolgens inhoud toe voor dit schema.", "tour.step4Next": "Begrepen!", diff --git a/backend/i18n/translate_en.bat b/backend/i18n/translate_en.bat new file mode 100644 index 000000000..80987da02 --- /dev/null +++ b/backend/i18n/translate_en.bat @@ -0,0 +1,8 @@ +cd translator\Squidex.Translator + +dotnet run translate check-backend ..\..\..\.. -l en +dotnet run translate check-frontend ..\..\..\.. -l en + +dotnet run translate gen-frontend ..\..\..\.. -l en +dotnet run translate gen-backend ..\..\..\.. -l en + diff --git a/backend/i18n/translator/Squidex.Translator/Commands.cs b/backend/i18n/translator/Squidex.Translator/Commands.cs index b57344f4e..64ea32a9d 100644 --- a/backend/i18n/translator/Squidex.Translator/Commands.cs +++ b/backend/i18n/translator/Squidex.Translator/Commands.cs @@ -6,13 +6,17 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using CommandDotNet; using FluentValidation; using FluentValidation.Attributes; using Squidex.Translator.Processes; using Squidex.Translator.State; +#pragma warning disable CA1822 // Mark members as static + namespace Squidex.Translator { public class Commands @@ -120,7 +124,19 @@ namespace Squidex.Translator throw new ArgumentException("Folder does not exist."); } - var locales = new string[] { "en", "nl", "it" }; + var supportedLocaled = new string[] { "en", "nl", "it" }; + + var locales = supportedLocaled; + + if (arguments.Locales != null && arguments.Locales.Any()) + { + locales = supportedLocaled.Intersect(arguments.Locales).ToArray(); + } + + if (locales.Length == 0) + { + locales = supportedLocaled; + } var translationsDirectory = new DirectoryInfo(Path.Combine(arguments.Folder, "backend", "i18n")); var translationsService = new TranslationService(translationsDirectory, fileName, locales, arguments.SingleWords); @@ -141,6 +157,9 @@ namespace Squidex.Translator [Option(LongName = "report", ShortName = "r")] public bool Report { get; set; } + [Option(LongName = "locale", ShortName = "l")] + public IEnumerable Locales { get; set; } + public sealed class Validator : AbstractValidator { public Validator() diff --git a/backend/i18n/translator/Squidex.Translator/Processes/Helper.cs b/backend/i18n/translator/Squidex.Translator/Processes/Helper.cs index 998587374..b4e41f1f9 100644 --- a/backend/i18n/translator/Squidex.Translator/Processes/Helper.cs +++ b/backend/i18n/translator/Squidex.Translator/Processes/Helper.cs @@ -17,7 +17,7 @@ namespace Squidex.Translator.Processes { public static string RelativeName(FileInfo file, DirectoryInfo folder) { - return file.FullName.Substring(folder.FullName.Length).Replace("\\", "/"); + return file.FullName[folder.FullName.Length..].Replace("\\", "/"); } public static void CheckOtherLocales(TranslationService service) diff --git a/backend/src/Migrations/Migrations/CreateAppSettings.cs b/backend/src/Migrations/Migrations/CreateAppSettings.cs index acd5f1b7e..828cdae2d 100644 --- a/backend/src/Migrations/Migrations/CreateAppSettings.cs +++ b/backend/src/Migrations/Migrations/CreateAppSettings.cs @@ -41,7 +41,7 @@ namespace Migrations.Migrations { var apps = new Dictionary, Dictionary>(); - await eventStore.QueryAsync(storedEvent => + await foreach (var storedEvent in eventStore.QueryAllAsync("^app\\-")) { var @event = eventDataFormatter.ParseIfKnown(storedEvent); @@ -80,9 +80,7 @@ namespace Migrations.Migrations } } } - - return Task.CompletedTask; - }, "^app\\-"); + } var actor = RefToken.Client("Migrator"); diff --git a/backend/src/Migrations/Migrations/PopulateGrainIndexes.cs b/backend/src/Migrations/Migrations/PopulateGrainIndexes.cs index c577a4d54..be2bb4981 100644 --- a/backend/src/Migrations/Migrations/PopulateGrainIndexes.cs +++ b/backend/src/Migrations/Migrations/PopulateGrainIndexes.cs @@ -75,7 +75,7 @@ namespace Migrations.Migrations } } - await eventStore.QueryAsync(storedEvent => + await foreach (var storedEvent in eventStore.QueryAllAsync("^app\\-")) { var @event = eventDataFormatter.ParseIfKnown(storedEvent); @@ -109,9 +109,7 @@ namespace Migrations.Migrations break; } } - - return Task.CompletedTask; - }, "^app\\-"); + } await indexApps.RebuildAsync(appsByName); @@ -130,7 +128,7 @@ namespace Migrations.Migrations return rulesByApp!.GetOrAddNew(@event.AppId.Id); } - await eventStore.QueryAsync(storedEvent => + await foreach (var storedEvent in eventStore.QueryAllAsync("^rule\\-")) { var @event = eventDataFormatter.ParseIfKnown(storedEvent); @@ -146,9 +144,7 @@ namespace Migrations.Migrations break; } } - - return Task.CompletedTask; - }, "^rule\\-"); + } foreach (var (appId, rules) in rulesByApp) { @@ -165,7 +161,7 @@ namespace Migrations.Migrations return schemasByApp!.GetOrAddNew(@event.AppId.Id); } - await eventStore.QueryAsync(storedEvent => + await foreach (var storedEvent in eventStore.QueryAllAsync("^schema\\-")) { var @event = eventDataFormatter.ParseIfKnown(storedEvent); @@ -181,9 +177,7 @@ namespace Migrations.Migrations break; } } - - return Task.CompletedTask; - }, "^schema\\-"); + } foreach (var (appId, schemas) in schemasByApp) { diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs index aff64c83f..c9cd35ead 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs @@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers [TypeName(nameof(ContentChangedTriggerV2))] public sealed class ContentChangedTriggerV2 : RuleTrigger { - public ReadOnlyCollection Schemas { get; set; } + public ReadOnlyCollection? Schemas { get; set; } public bool HandleAll { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs index c709d966e..115517830 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs @@ -7,20 +7,22 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Infrastructure; +using Squidex.Domain.Apps.Events; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Core.HandleRules { public interface IRuleService { - bool CanCreateSnapshotEvents(Rule rule); + bool CanCreateSnapshotEvents(RuleContext context); - IAsyncEnumerable<(RuleJob? Job, Exception? Exception)> CreateSnapshotJobsAsync(Rule rule, DomainId ruleId, DomainId appId); + string GetName(AppEvent @event); - Task> CreateJobsAsync(Rule rule, DomainId ruleId, Envelope @event, bool ignoreStale = true); + IAsyncEnumerable CreateSnapshotJobsAsync(RuleContext context, CancellationToken ct = default); + + IAsyncEnumerable CreateJobsAsync(Envelope @event, RuleContext context, CancellationToken ct = default); Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job); } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs index 76e551cd1..e5fee620a 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs @@ -7,11 +7,10 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; -using Squidex.Domain.Apps.Core.Rules; +using System.Linq; +using System.Threading; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Core.HandleRules @@ -20,14 +19,33 @@ namespace Squidex.Domain.Apps.Core.HandleRules { Type TriggerType { get; } - bool CanCreateSnapshotEvents { get; } + bool CanCreateSnapshotEvents + { + get => false; + } - IAsyncEnumerable CreateSnapshotEvents(RuleTrigger trigger, DomainId appId); + IAsyncEnumerable CreateSnapshotEventsAsync(RuleContext context, CancellationToken ct) + { + return AsyncEnumerable.Empty(); + } - Task> CreateEnrichedEventsAsync(Envelope @event); + IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, CancellationToken ct); - bool Trigger(EnrichedEvent @event, RuleTrigger trigger); + string? GetName(AppEvent @event) + { + return null; + } - bool Trigger(AppEvent @event, RuleTrigger trigger, DomainId ruleId); + bool Trigger(Envelope @event, RuleContext context) + { + return true; + } + + bool Trigger(EnrichedEvent @event, RuleContext context) + { + return true; + } + + bool Handles(AppEvent @event); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs new file mode 100644 index 000000000..9fde0e823 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Rules; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public sealed record JobResult(RuleJob? Job, Exception? Exception = null, SkipReason SkipReason = default) + { + public static readonly JobResult ConditionDoesNotMatch = new JobResult(null, null, SkipReason.ConditionDoesNotMatch); + public static readonly JobResult Disabled = new JobResult(null, null, SkipReason.Disabled); + public static readonly JobResult EventMismatch = new JobResult(null, null, SkipReason.EventMismatch); + public static readonly JobResult FromRule = new JobResult(null, null, SkipReason.FromRule); + public static readonly JobResult NoAction = new JobResult(null, null, SkipReason.NoAction); + public static readonly JobResult NoTrigger = new JobResult(null, null, SkipReason.NoTrigger); + public static readonly JobResult TooOld = new JobResult(null, null, SkipReason.TooOld); + public static readonly JobResult WrongEventForTrigger = new JobResult(null, null, SkipReason.WrongEventForTrigger); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs new file mode 100644 index 000000000..f74d27aa9 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public struct RuleContext + { + public NamedId AppId { get; init; } + + public DomainId RuleId { get; init; } + + public Rule Rule { get; init; } + + public bool IgnoreStale { get; init; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs index 2724b3de5..6549b9d0e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Options; @@ -68,11 +69,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules this.log = log; } - public bool CanCreateSnapshotEvents(Rule rule) + public bool CanCreateSnapshotEvents(RuleContext context) { - Guard.NotNull(rule, nameof(rule)); + Guard.NotNull(context.Rule, nameof(context.Rule)); - if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler)) + if (!ruleTriggerHandlers.TryGetValue(context.Rule.Trigger.GetType(), out var triggerHandler)) { return false; } @@ -80,8 +81,13 @@ namespace Squidex.Domain.Apps.Core.HandleRules return triggerHandler.CanCreateSnapshotEvents; } - public async IAsyncEnumerable<(RuleJob? Job, Exception? Exception)> CreateSnapshotJobsAsync(Rule rule, DomainId ruleId, DomainId appId) + public async IAsyncEnumerable CreateSnapshotJobsAsync(RuleContext context, + [EnumeratorCancellation] CancellationToken ct = default) { + Guard.NotNull(context.Rule, nameof(context.Rule)); + + var rule = context.Rule; + if (!rule.IsEnabled) { yield break; @@ -104,68 +110,93 @@ namespace Squidex.Domain.Apps.Core.HandleRules var now = clock.GetCurrentInstant(); - await foreach (var enrichedEvent in triggerHandler.CreateSnapshotEvents(rule.Trigger, appId)) + await foreach (var enrichedEvent in triggerHandler.CreateSnapshotEventsAsync(context, ct)) { - Exception? exception; - - RuleJob? job = null; - + JobResult? job; try { await eventEnricher.EnrichAsync(enrichedEvent, null); - if (!triggerHandler.Trigger(enrichedEvent, rule.Trigger)) + if (!triggerHandler.Trigger(enrichedEvent, context)) { continue; } - (job, exception) = await CreateJobAsync(rule, ruleId, actionHandler, now, enrichedEvent); + job = await CreateJobAsync(actionHandler, enrichedEvent, context, now); } catch (Exception ex) { - exception = ex; + job = new JobResult(null, ex); } - yield return (job, exception); + yield return job; } } - public async Task> CreateJobsAsync(Rule rule, DomainId ruleId, Envelope @event, bool ignoreStale = true) + public async IAsyncEnumerable CreateJobsAsync(Envelope @event, RuleContext context, + [EnumeratorCancellation] CancellationToken ct = default) { - Guard.NotNull(rule, nameof(rule)); Guard.NotNull(@event, nameof(@event)); - var result = new List<(RuleJob Job, Exception? Exception)>(); + var jobs = new List(); + + await AddJobsAsync(jobs, @event, context, ct); + + foreach (var job in jobs) + { + if (ct.IsCancellationRequested) + { + break; + } + + yield return job; + } + } + private async Task AddJobsAsync(List jobs, Envelope @event, RuleContext context, CancellationToken ct) + { try { + var rule = context.Rule; + if (!rule.IsEnabled) { - return result; + jobs.Add(JobResult.Disabled); + return; } if (@event.Payload is not AppEvent) { - return result; + jobs.Add(JobResult.EventMismatch); + return; } var typed = @event.To(); if (typed.Payload.FromRule) { - return result; + jobs.Add(JobResult.FromRule); + return; } var actionType = rule.Action.GetType(); if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler)) { - return result; + jobs.Add(JobResult.NoTrigger); + return; + } + + if (!triggerHandler.Handles(typed.Payload)) + { + jobs.Add(JobResult.WrongEventForTrigger); + return; } if (!ruleActionHandlers.TryGetValue(actionType, out var actionHandler)) { - return result; + jobs.Add(JobResult.NoAction); + return; } var now = clock.GetCurrentInstant(); @@ -175,37 +206,50 @@ namespace Squidex.Domain.Apps.Core.HandleRules @event.Headers.Timestamp() : now; - if (ignoreStale && eventTime.Plus(Constants.StaleTime) < now) + if (context.IgnoreStale && eventTime.Plus(Constants.StaleTime) < now) { - return result; + jobs.Add(JobResult.TooOld); + return; } - if (!triggerHandler.Trigger(typed.Payload, rule.Trigger, ruleId)) + if (!triggerHandler.Trigger(typed, context)) { - return result; + jobs.Add(JobResult.ConditionDoesNotMatch); + return; } - var appEventEnvelope = @event.To(); - - var enrichedEvents = await triggerHandler.CreateEnrichedEventsAsync(appEventEnvelope); - - foreach (var enrichedEvent in enrichedEvents) + await foreach (var enrichedEvent in triggerHandler.CreateEnrichedEventsAsync(typed, context, ct)) { + if (string.IsNullOrWhiteSpace(enrichedEvent.Name)) + { + enrichedEvent.Name = GetName(typed.Payload); + } + try { await eventEnricher.EnrichAsync(enrichedEvent, typed); - if (!triggerHandler.Trigger(enrichedEvent, rule.Trigger)) + if (!triggerHandler.Trigger(enrichedEvent, context)) { + if (jobs.Count == 0) + { + jobs.Add(JobResult.ConditionDoesNotMatch); + } + continue; } - var (job, exception) = await CreateJobAsync(rule, ruleId, actionHandler, now, enrichedEvent); + var job = await CreateJobAsync(actionHandler, enrichedEvent, context, now); - result.Add((job, exception)); + jobs.Add(job); } catch (Exception ex) { + if (jobs.Count == 0) + { + jobs.Add(new JobResult(null, ex, SkipReason.Failed)); + } + log.LogError(ex, w => w .WriteProperty("action", "createRuleJobFromEvent") .WriteProperty("status", "Failed")); @@ -214,17 +258,17 @@ namespace Squidex.Domain.Apps.Core.HandleRules } catch (Exception ex) { + jobs.Add(new JobResult(null, ex, SkipReason.Failed)); + log.LogError(ex, w => w .WriteProperty("action", "createRuleJob") .WriteProperty("status", "Failed")); } - - return result; } - private async Task<(RuleJob, Exception?)> CreateJobAsync(Rule rule, DomainId ruleId, IRuleActionHandler actionHandler, Instant now, EnrichedEvent enrichedEvent) + private async Task CreateJobAsync(IRuleActionHandler actionHandler, EnrichedEvent enrichedEvent, RuleContext context, Instant now) { - var actionName = typeNameRegistry.GetName(rule.Action.GetType()); + var actionName = typeNameRegistry.GetName(context.Rule.Action.GetType()); var expires = now.Plus(Constants.ExpirationTime); @@ -238,12 +282,12 @@ namespace Squidex.Domain.Apps.Core.HandleRules EventName = enrichedEvent.Name, ExecutionPartition = enrichedEvent.Partition, Expires = expires, - RuleId = ruleId + RuleId = context.RuleId }; try { - var (description, data) = await actionHandler.CreateJobAsync(enrichedEvent, rule.Action); + var (description, data) = await actionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action); var json = jsonSerializer.Serialize(data); @@ -251,14 +295,32 @@ namespace Squidex.Domain.Apps.Core.HandleRules job.ActionName = actionName; job.Description = description; - return (job, null); + return new JobResult(job, null); } catch (Exception ex) { job.Description = "Failed to create job"; - return (job, ex); + return new JobResult(job, ex); + } + } + + public string GetName(AppEvent @event) + { + foreach (var handler in ruleTriggerHandlers.Values) + { + if (handler.Handles(@event)) + { + var name = handler.GetName(@event); + + if (!string.IsNullOrWhiteSpace(name)) + { + return name; + } + } } + + return @event.GetType().Name; } public async Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs deleted file mode 100644 index d0bb8bfc0..000000000 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs +++ /dev/null @@ -1,96 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; - -namespace Squidex.Domain.Apps.Core.HandleRules -{ - public abstract class RuleTriggerHandler : IRuleTriggerHandler - where TTrigger : RuleTrigger - where TEvent : AppEvent - where TEnrichedEvent : EnrichedEvent - { - private readonly List emptyEnrichedEvents = new List(); - - public Type TriggerType - { - get => typeof(TTrigger); - } - - public virtual bool CanCreateSnapshotEvents - { - get => false; - } - - public virtual async IAsyncEnumerable CreateSnapshotEvents(TTrigger trigger, DomainId appId) - { - await Task.Yield(); - yield break; - } - - public virtual async Task> CreateEnrichedEventsAsync(Envelope @event) - { - var enrichedEvent = await CreateEnrichedEventAsync(@event.To()); - - if (enrichedEvent != null) - { - return new List - { - enrichedEvent - }; - } - else - { - return emptyEnrichedEvents; - } - } - - IAsyncEnumerable IRuleTriggerHandler.CreateSnapshotEvents(RuleTrigger trigger, DomainId appId) - { - return CreateSnapshotEvents((TTrigger)trigger, appId); - } - - bool IRuleTriggerHandler.Trigger(EnrichedEvent @event, RuleTrigger trigger) - { - if (@event is TEnrichedEvent typed) - { - return Trigger(typed, (TTrigger)trigger); - } - - return false; - } - - bool IRuleTriggerHandler.Trigger(AppEvent @event, RuleTrigger trigger, DomainId ruleId) - { - if (@event is TEvent typed) - { - return Trigger(typed, (TTrigger)trigger, ruleId); - } - - return false; - } - - protected virtual Task CreateEnrichedEventAsync(Envelope @event) - { - return Task.FromResult(null); - } - - protected abstract bool Trigger(TEnrichedEvent @event, TTrigger trigger); - - protected virtual bool Trigger(TEvent @event, TTrigger trigger, DomainId ruleId) - { - return true; - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/SkipReason.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/SkipReason.cs new file mode 100644 index 000000000..43940bef1 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/SkipReason.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public enum SkipReason + { + None, + ConditionDoesNotMatch, + Disabled, + EventMismatch, + Failed, + FromRule, + NoAction, + NoTrigger, + TooOld, + WrongEventForTrigger + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index 25c79dcb3..0eb68127b 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -27,6 +27,7 @@ + diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 8e0e7a8b8..f1086ad30 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using MongoDB.Driver; @@ -68,13 +69,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets }, ct); } - public async IAsyncEnumerable StreamAll(DomainId appId) + public async IAsyncEnumerable StreamAll(DomainId appId, + [EnumeratorCancellation] CancellationToken ct = default) { var find = Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted); - using (var cursor = await find.ToCursorAsync()) + using (var cursor = await find.ToCursorAsync(ct)) { - while (await cursor.MoveNextAsync()) + while (await cursor.MoveNextAsync(ct)) { foreach (var entity in cursor.Current) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index e8a405b31..dbbbcf721 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -82,9 +82,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await queryScheduled.PrepareAsync(collection, skipIndex, ct); } - public IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds) + public IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, CancellationToken ct) { - return queryAsStream.StreamAll(appId, schemaIds); + return queryAsStream.StreamAll(appId, schemaIds, ct); } public async Task> QueryAsync(IAppEntity app, List schemas, Q q) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 648c28f3b..c863a1568 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -55,9 +55,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await collectionPublished.InitializeAsync(ct); } - public IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds) + public IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, CancellationToken ct) { - return collectionAll.StreamAll(appId, schemaIds); + return collectionAll.StreamAll(appId, schemaIds, ct); } public Task> QueryAsync(IAppEntity app, List schemas, Q q, SearchScope scope) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs index f87844456..3bfb779e4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using MongoDB.Driver; @@ -27,16 +28,17 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations await Collection.Indexes.CreateOneAsync(indexBySchema, cancellationToken: ct); } - public async IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds) + public async IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, + [EnumeratorCancellation] CancellationToken ct) { var find = schemaIds != null ? Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && schemaIds.Contains(x.IndexedSchemaId)) : Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted); - using (var cursor = await find.ToCursorAsync()) + using (var cursor = await find.ToCursorAsync(ct)) { - while (await cursor.MoveNextAsync()) + while (await cursor.MoveNextAsync(ct)) { foreach (var entity in cursor.Current) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs index 20c97fb2a..05afab815 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -169,6 +169,13 @@ namespace Squidex.Domain.Apps.Entities return rules.ToList(); } + public async Task GetRuleAsync(DomainId appId, DomainId id) + { + var rules = await GetRulesAsync(appId); + + return rules.Find(x => x.Id == id); + } + private static string AppCacheKey(DomainId appId) { return $"APPS_ID_{appId}"; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs index 06329e5f9..a247b8dfe 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs @@ -5,13 +5,16 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; -using System.Threading.Tasks; +using System.Runtime.CompilerServices; +using System.Threading; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Assets; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; @@ -19,13 +22,15 @@ using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Assets { - public sealed class AssetChangedTriggerHandler : RuleTriggerHandler + public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler { private readonly IScriptEngine scriptEngine; private readonly IAssetLoader assetLoader; private readonly IAssetRepository assetRepository; - public override bool CanCreateSnapshotEvents => true; + public bool CanCreateSnapshotEvents => true; + + public Type TriggerType => typeof(AssetChangedTriggerV2); public AssetChangedTriggerHandler( IScriptEngine scriptEngine, @@ -41,9 +46,15 @@ namespace Squidex.Domain.Apps.Entities.Assets this.assetRepository = assetRepository; } - public override async IAsyncEnumerable CreateSnapshotEvents(AssetChangedTriggerV2 trigger, DomainId appId) + public bool Handles(AppEvent @event) + { + return @event is AssetEvent && @event is not AssetMoved; + } + + public async IAsyncEnumerable CreateSnapshotEventsAsync(RuleContext context, + [EnumeratorCancellation] CancellationToken ct = default) { - await foreach (var asset in assetRepository.StreamAll(appId)) + await foreach (var asset in assetRepository.StreamAll(context.AppId.Id, ct)) { var result = new EnrichedAssetEvent { @@ -53,24 +64,22 @@ namespace Squidex.Domain.Apps.Entities.Assets SimpleMapper.Map(asset, result); result.Actor = asset.LastModifiedBy; - result.Name = "AssetCreatedFromSnapshot"; + result.Name = "AssetQueried"; yield return result; } } - protected override async Task CreateEnrichedEventAsync(Envelope @event) + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + [EnumeratorCancellation] CancellationToken ct = default) { - if (@event.Payload is AssetMoved) - { - return null; - } + var assetEvent = (AssetEvent)@event.Payload; var result = new EnrichedAssetEvent(); var asset = await assetLoader.GetAsync( - @event.Payload.AppId.Id, - @event.Payload.AssetId, + assetEvent.AppId.Id, + assetEvent.AssetId, @event.Headers.EventStreamNumber()); if (asset != null) @@ -96,13 +105,13 @@ namespace Squidex.Domain.Apps.Entities.Assets break; } - result.Name = $"Asset{result.Type}"; - - return result; + yield return result; } - protected override bool Trigger(EnrichedAssetEvent @event, AssetChangedTriggerV2 trigger) + public bool Trigger(EnrichedEvent @event, RuleContext context) { + var trigger = (AssetChangedTriggerV2)context.Rule.Trigger; + if (string.IsNullOrWhiteSpace(trigger.Condition)) { return true; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/RepairFiles.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/RepairFiles.cs index 1a6fe35be..55831f930 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/RepairFiles.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/RepairFiles.cs @@ -39,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.Assets public async Task RepairAsync(CancellationToken ct = default) { - await eventStore.QueryAsync(async storedEvent => + await foreach (var storedEvent in eventStore.QueryAllAsync("^asset\\-", ct: ct)) { var @event = eventDataFormatter.ParseIfKnown(storedEvent); @@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Assets break; } } - }, "^asset\\-", ct: ct); + } } private async Task TryRepairAsync(NamedId appId, DomainId id, long fileVersion, CancellationToken ct) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index d085d026d..edcd3d1b2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Squidex.Infrastructure; @@ -13,7 +14,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories { public interface IAssetRepository { - IAsyncEnumerable StreamAll(DomainId appId); + IAsyncEnumerable StreamAll(DomainId appId, CancellationToken ct); Task> QueryAsync(DomainId appId, DomainId? parentId, Q q); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs index f075898f0..23472eed0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs @@ -144,7 +144,7 @@ namespace Squidex.Domain.Apps.Entities.Backup var context = new BackupContext(appId, userMapping, writer); - await eventStore.QueryAsync(async storedEvent => + await foreach (var storedEvent in eventStore.QueryAllAsync(GetFilter(), ct: ct)) { var @event = eventDataFormatter.Parse(storedEvent); @@ -164,7 +164,7 @@ namespace Squidex.Domain.Apps.Entities.Backup job.HandledAssets = writer.WrittenAttachments; lastTimestamp = await WritePeriodically(lastTimestamp); - }, GetFilter(), null, ct); + } foreach (var handler in handlers) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs index 185a623a5..6e7269c4a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs @@ -5,8 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; -using System.Threading.Tasks; +using System.Runtime.CompilerServices; +using System.Threading; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; @@ -20,12 +22,13 @@ using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Comments { - public sealed class CommentTriggerHandler : RuleTriggerHandler + public sealed class CommentTriggerHandler : IRuleTriggerHandler { - private static readonly List EmptyResult = new List(); private readonly IScriptEngine scriptEngine; private readonly IUserResolver userResolver; + public Type TriggerType => typeof(CommentTrigger); + public CommentTriggerHandler(IScriptEngine scriptEngine, IUserResolver userResolver) { Guard.NotNull(scriptEngine, nameof(scriptEngine)); @@ -36,18 +39,22 @@ namespace Squidex.Domain.Apps.Entities.Comments this.userResolver = userResolver; } - public override async Task> CreateEnrichedEventsAsync(Envelope @event) + public bool Handles(AppEvent @event) + { + return @event is CommentCreated; + } + + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + [EnumeratorCancellation] CancellationToken ct) { - var commentCreated = @event.Payload as CommentCreated; + var commentCreated = (CommentCreated)@event.Payload; - if (commentCreated?.Mentions?.Length > 0) + if (commentCreated.Mentions?.Length > 0) { var users = await userResolver.QueryManyAsync(commentCreated.Mentions); if (users.Count > 0) { - var result = new List(); - foreach (var user in users.Values) { var enrichedEvent = new EnrichedCommentEvent @@ -59,18 +66,16 @@ namespace Squidex.Domain.Apps.Entities.Comments SimpleMapper.Map(commentCreated, enrichedEvent); - result.Add(enrichedEvent); + yield return enrichedEvent; } - - return result; } } - - return EmptyResult; } - protected override bool Trigger(EnrichedCommentEvent @event, CommentTrigger trigger) + public bool Trigger(EnrichedEvent @event, RuleContext context) { + var trigger = (CommentTrigger)context.Rule.Trigger; + if (string.IsNullOrWhiteSpace(trigger.Condition)) { return true; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs index 4bf0d54ee..e33a8ec75 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs @@ -5,15 +5,18 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; +using System.Runtime.CompilerServices; +using System.Threading; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; @@ -22,13 +25,20 @@ using Squidex.Text; namespace Squidex.Domain.Apps.Entities.Contents { - public sealed class ContentChangedTriggerHandler : RuleTriggerHandler + public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler { private readonly IScriptEngine scriptEngine; private readonly IContentLoader contentLoader; private readonly IContentRepository contentRepository; - public override bool CanCreateSnapshotEvents => true; + public bool CanCreateSnapshotEvents => true; + + public Type TriggerType => typeof(ContentChangedTriggerV2); + + public bool Handles(AppEvent appEvent) + { + return appEvent is ContentEvent; + } public ContentChangedTriggerHandler( IScriptEngine scriptEngine, @@ -44,14 +54,17 @@ namespace Squidex.Domain.Apps.Entities.Contents this.contentRepository = contentRepository; } - public override async IAsyncEnumerable CreateSnapshotEvents(ContentChangedTriggerV2 trigger, DomainId appId) + public async IAsyncEnumerable CreateSnapshotEventsAsync(RuleContext context, + [EnumeratorCancellation] CancellationToken ct = default) { + var trigger = (ContentChangedTriggerV2)context.Rule.Trigger; + var schemaIds = trigger.Schemas?.Count > 0 ? trigger.Schemas.Select(x => x.SchemaId).Distinct().ToHashSet() : null; - await foreach (var content in contentRepository.StreamAll(appId, schemaIds)) + await foreach (var content in contentRepository.StreamAll(context.AppId.Id, schemaIds, ct)) { var result = new EnrichedContentEvent { @@ -61,20 +74,23 @@ namespace Squidex.Domain.Apps.Entities.Contents SimpleMapper.Map(content, result); result.Actor = content.LastModifiedBy; - result.Name = $"{content.SchemaId.Name.ToPascalCase()}CreatedFromSnapshot"; + result.Name = $"ContentQueried({content.SchemaId.Name.ToPascalCase()})"; yield return result; } } - protected override async Task CreateEnrichedEventAsync(Envelope @event) + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + [EnumeratorCancellation] CancellationToken ct = default) { + var contentEvent = (ContentEvent)@event.Payload; + var result = new EnrichedContentEvent(); var content = await contentLoader.GetAsync( - @event.Payload.AppId.Id, - @event.Payload.ContentId, + contentEvent.AppId.Id, + contentEvent.ContentId, @event.Headers.EventStreamNumber()); if (content != null) @@ -131,13 +147,20 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - result.Name = $"{@event.Payload.SchemaId.Name.ToPascalCase()}{result.Type}"; + yield return result; + } - return result; + public string? GetName(AppEvent @event) + { + var contentEvent = (ContentEvent)@event; + + return $"{@event.GetType().Name}({contentEvent.SchemaId.Name.ToPascalCase()})"; } - protected override bool Trigger(ContentEvent @event, ContentChangedTriggerV2 trigger, DomainId ruleId) + public bool Trigger(Envelope @event, RuleContext context) { + var trigger = (ContentChangedTriggerV2)context.Rule.Trigger; + if (trigger.HandleAll) { return true; @@ -145,9 +168,11 @@ namespace Squidex.Domain.Apps.Entities.Contents if (trigger.Schemas != null) { + var contentEvent = (ContentEvent)@event.Payload; + foreach (var schema in trigger.Schemas) { - if (MatchsSchema(schema, @event.SchemaId)) + if (MatchsSchema(schema, contentEvent.SchemaId)) { return true; } @@ -157,8 +182,10 @@ namespace Squidex.Domain.Apps.Entities.Contents return false; } - protected override bool Trigger(EnrichedContentEvent @event, ContentChangedTriggerV2 trigger) + public bool Trigger(EnrichedEvent @event, RuleContext context) { + var trigger = (ContentChangedTriggerV2)context.Rule.Trigger; + if (trigger.HandleAll) { return true; @@ -166,9 +193,11 @@ namespace Squidex.Domain.Apps.Entities.Contents if (trigger.Schemas != null) { + var contentEvent = (EnrichedContentEvent)@event; + foreach (var schema in trigger.Schemas) { - if (MatchsSchema(schema, @event.SchemaId) && MatchsCondition(schema, @event)) + if (MatchsSchema(schema, contentEvent.SchemaId) && MatchsCondition(schema, contentEvent)) { return true; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index 4622bf10a..66fe44a66 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using NodaTime; using Squidex.Domain.Apps.Core.Contents; @@ -19,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories { public interface IContentRepository { - IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds); + IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, CancellationToken ct); Task> QueryAsync(IAppEntity app, List schemas, Q q, SearchScope scope); diff --git a/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs index 11762f035..5a30918c7 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs @@ -32,5 +32,7 @@ namespace Squidex.Domain.Apps.Entities Task> GetSchemasAsync(DomainId appId); Task> GetRulesAsync(DomainId appId); + + Task GetRuleAsync(DomainId appId, DomainId id); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs index 6d12c5445..a081c499e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs @@ -114,7 +114,10 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject private async Task Trigger(TriggerRule command) { - var @event = SimpleMapper.Map(command, new RuleManuallyTriggered { RuleId = Snapshot.Id, AppId = Snapshot.AppId }); + var @event = new RuleManuallyTriggered(); + + SimpleMapper.Map(command, @event); + SimpleMapper.Map(Snapshot, @event); await ruleEnqueuer.EnqueueAsync(Snapshot.RuleDef, Snapshot.Id, Envelope.Create(@event)); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs index 3a6245d2b..56662aaaf 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs @@ -5,33 +5,45 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; +using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Rules; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Rules { - public sealed class ManualTriggerHandler : RuleTriggerHandler + public sealed class ManualTriggerHandler : IRuleTriggerHandler { - protected override Task CreateEnrichedEventAsync(Envelope @event) + public Type TriggerType => typeof(ManualTrigger); + + public bool Handles(AppEvent appEvent) { - var result = new EnrichedManualEvent - { - Name = "Manual" - }; + return appEvent is RuleManuallyTriggered; + } + + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + [EnumeratorCancellation] CancellationToken ct) + { + var result = new EnrichedManualEvent(); SimpleMapper.Map(@event.Payload, result); - return Task.FromResult(result); + await Task.Yield(); + + yield return result; } - protected override bool Trigger(EnrichedManualEvent @event, ManualTrigger trigger) + public string? GetName(AppEvent @event) { - return true; + return "Manual"; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs index 80c74fbe8..76d8f6610 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs @@ -56,11 +56,21 @@ namespace Squidex.Domain.Apps.Entities.Rules Guard.NotNull(rule, nameof(rule)); Guard.NotNull(@event, nameof(@event)); - var jobs = await ruleService.CreateJobsAsync(rule, ruleId, @event); + var ruleContext = new RuleContext + { + Rule = rule, + RuleId = ruleId, + IgnoreStale = false + }; + + var jobs = ruleService.CreateJobsAsync(@event, ruleContext); - foreach (var (job, ex) in jobs) + await foreach (var (job, ex, _) in jobs) { - await ruleEventRepository.EnqueueAsync(job, ex); + if (job != null) + { + await ruleEventRepository.EnqueueAsync(job, ex); + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs new file mode 100644 index 000000000..666cef073 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs @@ -0,0 +1,128 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules.Triggers; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.Rules.Runner +{ + public sealed class DefaultRuleRunnerService : IRuleRunnerService + { + private const int MaxSimulatedEvents = 100; + private readonly IEventDataFormatter eventDataFormatter; + private readonly IEventStore eventStore; + private readonly IGrainFactory grainFactory; + private readonly IRuleService ruleService; + + public DefaultRuleRunnerService(IGrainFactory grainFactory, + IEventStore eventStore, + IEventDataFormatter eventDataFormatter, + IRuleService ruleService) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter)); + Guard.NotNull(eventStore, nameof(eventStore)); + Guard.NotNull(ruleService, nameof(ruleService)); + + this.grainFactory = grainFactory; + this.eventDataFormatter = eventDataFormatter; + this.eventStore = eventStore; + this.ruleService = ruleService; + } + + public async Task> SimulateAsync(IRuleEntity rule, CancellationToken ct) + { + Guard.NotNull(rule, nameof(rule)); + + var context = GetContext(rule); + + var result = new List(MaxSimulatedEvents); + + await foreach (var storedEvent in eventStore.QueryAllReverseAsync($"^([a-z]+)\\-{rule.AppId.Id}", null, MaxSimulatedEvents, ct)) + { + var @event = eventDataFormatter.ParseIfKnown(storedEvent); + + if (@event?.Payload is AppEvent appEvent) + { + await foreach (var (job, exception, skip) in ruleService.CreateJobsAsync(@event, context, ct)) + { + var name = job?.EventName; + + if (string.IsNullOrWhiteSpace(name)) + { + name = ruleService.GetName(appEvent); + } + + var simulationResult = new SimulatedRuleEvent( + name, + job?.ActionName, + job?.ActionData, + exception?.Message, + skip); + + result.Add(simulationResult); + } + } + } + + return result; + } + + public Task CancelAsync(DomainId appId) + { + var grain = grainFactory.GetGrain(appId.ToString()); + + return grain.CancelAsync(); + } + + public bool CanRunRule(IRuleEntity rule) + { + var context = GetContext(rule); + + return context.Rule.IsEnabled && context.Rule.Trigger is not ManualTrigger; + } + + public bool CanRunFromSnapshots(IRuleEntity rule) + { + var context = GetContext(rule); + + return CanRunRule(rule) && ruleService.CanCreateSnapshotEvents(context); + } + + public Task GetRunningRuleIdAsync(DomainId appId) + { + var grain = grainFactory.GetGrain(appId.ToString()); + + return grain.GetRunningRuleIdAsync(); + } + + public Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false) + { + var grain = grainFactory.GetGrain(appId.ToString()); + + return grain.RunAsync(ruleId, fromSnapshots); + } + + private static RuleContext GetContext(IRuleEntity rule) + { + return new RuleContext + { + AppId = rule.AppId, + Rule = rule.RuleDef, + RuleId = rule.Id, + IgnoreStale = true + }; + } + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs deleted file mode 100644 index 4c1f59579..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs +++ /dev/null @@ -1,61 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Threading.Tasks; -using Orleans; -using Squidex.Domain.Apps.Core.HandleRules; -using Squidex.Domain.Apps.Core.Rules.Triggers; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Rules.Runner -{ - public sealed class GrainRuleRunnerService : IRuleRunnerService - { - private readonly IGrainFactory grainFactory; - private readonly IRuleService ruleService; - - public GrainRuleRunnerService(IGrainFactory grainFactory, IRuleService ruleService) - { - Guard.NotNull(grainFactory, nameof(grainFactory)); - Guard.NotNull(ruleService, nameof(ruleService)); - - this.grainFactory = grainFactory; - this.ruleService = ruleService; - } - - public Task CancelAsync(DomainId appId) - { - var grain = grainFactory.GetGrain(appId.ToString()); - - return grain.CancelAsync(); - } - - public bool CanRunRule(IRuleEntity rule) - { - return rule.RuleDef.IsEnabled && rule.RuleDef.Trigger is not ManualTrigger; - } - - public bool CanRunFromSnapshots(IRuleEntity rule) - { - return CanRunRule(rule) && ruleService.CanCreateSnapshotEvents(rule.RuleDef); - } - - public Task GetRunningRuleIdAsync(DomainId appId) - { - var grain = grainFactory.GetGrain(appId.ToString()); - - return grain.GetRunningRuleIdAsync(); - } - - public Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false) - { - var grain = grainFactory.GetGrain(appId.ToString()); - - return grain.RunAsync(ruleId, fromSnapshots); - } - } -} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs index d4f5651ae..5cb073914 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Squidex.Infrastructure; @@ -12,6 +14,8 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner { public interface IRuleRunnerService { + Task> SimulateAsync(IRuleEntity rule, CancellationToken ct); + Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false); Task CancelAsync(DomainId appId); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs index 7261e90b4..507c854bc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs @@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner public string? Position { get; set; } - public bool FromSnapshots { get; set; } + public bool RunFromSnapshots { get; set; } } public RuleRunnerGrain( @@ -122,7 +122,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner state.Value = new State { RuleId = ruleId, - FromSnapshots = fromSnapshots + RunFromSnapshots = fromSnapshots }; await EnsureIsRunningAsync(false); @@ -136,7 +136,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner if (job.RuleId != null && currentJobToken == null) { - if (state.Value.FromSnapshots && continues) + if (state.Value.RunFromSnapshots && continues) { state.Value = new State(); @@ -162,24 +162,30 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner { currentReminder = await RegisterOrUpdateReminder("KeepAlive", TimeSpan.Zero, TimeSpan.FromMinutes(2)); - var rules = await appProvider.GetRulesAsync(DomainId.Create(Key)); - - var rule = rules.Find(x => x.Id == currentState.RuleId); + var rule = await appProvider.GetRuleAsync(DomainId.Create(Key), currentState.RuleId!.Value); if (rule == null) { - throw new InvalidOperationException("Cannot find rule."); + throw new DomainObjectNotFoundException(currentState.RuleId.ToString()!); } using (localCache.StartContext()) { - if (currentState.FromSnapshots && ruleService.CanCreateSnapshotEvents(rule.RuleDef)) + var context = new RuleContext + { + AppId = rule.AppId, + Rule = rule.RuleDef, + RuleId = rule.Id, + IgnoreStale = true + }; + + if (currentState.RunFromSnapshots && ruleService.CanCreateSnapshotEvents(context)) { - await EnqueueFromSnapshotsAsync(rule); + await EnqueueFromSnapshotsAsync(context, ct); } else { - await EnqueueFromEventsAsync(currentState, rule, ct); + await EnqueueFromEventsAsync(currentState, context, ct); } } } @@ -216,11 +222,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner } } - private async Task EnqueueFromSnapshotsAsync(IRuleEntity rule) + private async Task EnqueueFromSnapshotsAsync(RuleContext context, CancellationToken ct) { var errors = 0; - await foreach (var (job, ex) in ruleService.CreateSnapshotJobsAsync(rule.RuleDef, rule.Id, rule.AppId.Id)) + await foreach (var (job, ex, _) in ruleService.CreateSnapshotJobsAsync(context, ct)) { if (job != null) { @@ -242,11 +248,13 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner } } - private async Task EnqueueFromEventsAsync(State currentState, IRuleEntity rule, CancellationToken ct) + private async Task EnqueueFromEventsAsync(State currentState, RuleContext context, CancellationToken ct) { var errors = 0; - await eventStore.QueryAsync(async storedEvent => + var filter = $"^([a-z]+)\\-{Key}"; + + await foreach (var storedEvent in eventStore.QueryAllAsync(filter, currentState.Position, ct: ct)) { try { @@ -254,11 +262,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner if (@event != null) { - var jobs = await ruleService.CreateJobsAsync(rule.RuleDef, rule.Id, @event, false); + var jobs = ruleService.CreateJobsAsync(@event, context, ct); - foreach (var (job, ex) in jobs) + await foreach (var (job, ex, _) in jobs) { - await ruleEventRepository.EnqueueAsync(job, ex); + if (job != null) + { + await ruleEventRepository.EnqueueAsync(job, ex); + } } } } @@ -281,7 +292,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner } await state.WriteAsync(); - }, $"^([a-z]+)\\-{Key}", currentState.Position, ct); + } } public Task ReceiveReminder(string reminderName, TickStatus status) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/SimulatedRuleEvent.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/SimulatedRuleEvent.cs new file mode 100644 index 000000000..03ba31373 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/SimulatedRuleEvent.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.HandleRules; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Domain.Apps.Entities.Rules.Runner +{ + public sealed record SimulatedRuleEvent( + string EventName, + string? ActionName, + string? ActionData, + string? Error, + SkipReason SkipReason) + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs index 58650bae2..c5053f1fd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs @@ -5,6 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; @@ -14,25 +18,41 @@ using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking { - public sealed class UsageTriggerHandler : RuleTriggerHandler + public sealed class UsageTriggerHandler : IRuleTriggerHandler { private const string EventName = "Usage exceeded"; - protected override Task CreateEnrichedEventAsync(Envelope @event) + public Type TriggerType => typeof(UsageTrigger); + + public bool Handles(AppEvent appEvent) + { + return appEvent is AppUsageExceeded; + } + + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + [EnumeratorCancellation] CancellationToken ct) { + var usageEvent = (AppUsageExceeded)@event.Payload; + var result = new EnrichedUsageExceededEvent { - CallsCurrent = @event.Payload.CallsCurrent, - CallsLimit = @event.Payload.CallsLimit, + CallsCurrent = usageEvent.CallsCurrent, + CallsLimit = usageEvent.CallsLimit, Name = EventName }; - return Task.FromResult(result); + await Task.Yield(); + + yield return result; } - protected override bool Trigger(EnrichedUsageExceededEvent @event, UsageTrigger trigger) + public bool Trigger(Envelope @event, RuleContext context) { - return @event.CallsLimit == trigger.Limit; + var trigger = (UsageTrigger)context.Rule.Trigger; + + var usageEvent = (AppUsageExceeded)@event.Payload; + + return usageEvent.CallsLimit >= trigger.Limit; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs index 58a4275a8..9c447addd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs @@ -5,6 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; @@ -18,10 +22,12 @@ using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Schemas { - public sealed class SchemaChangedTriggerHandler : RuleTriggerHandler + public sealed class SchemaChangedTriggerHandler : IRuleTriggerHandler { private readonly IScriptEngine scriptEngine; + public Type TriggerType => typeof(SchemaChangedTrigger); + public SchemaChangedTriggerHandler(IScriptEngine scriptEngine) { Guard.NotNull(scriptEngine, nameof(scriptEngine)); @@ -29,9 +35,15 @@ namespace Squidex.Domain.Apps.Entities.Schemas this.scriptEngine = scriptEngine; } - protected override Task CreateEnrichedEventAsync(Envelope @event) + public bool Handles(AppEvent appEvent) + { + return appEvent is SchemaEvent; + } + + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + [EnumeratorCancellation] CancellationToken ct) { - EnrichedSchemaEvent? result = new EnrichedSchemaEvent(); + var result = new EnrichedSchemaEvent(); SimpleMapper.Map(@event.Payload, result); @@ -57,20 +69,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas result.Type = EnrichedSchemaEventType.Deleted; break; default: - result = null; - break; + yield break; } - if (result != null) - { - result.Name = $"Schema{result.Type}"; - } + await Task.Yield(); - return Task.FromResult(result); + yield return result; } - protected override bool Trigger(EnrichedSchemaEvent @event, SchemaChangedTrigger trigger) + public bool Trigger(EnrichedEvent @event, RuleContext context) { + var trigger = (SchemaChangedTrigger)context.Rule.Trigger; + if (string.IsNullOrWhiteSpace(trigger.Condition)) { return true; diff --git a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs index 5bb2d0ea9..b498884d2 100644 --- a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs +++ b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Squidex.Hosting; @@ -47,34 +48,20 @@ namespace Squidex.Infrastructure.EventSourcing var result = new List(); - await documentClient.QueryAsync(collectionUri, query, commit => + await foreach (var commit in documentClient.QueryAsync(collectionUri, query, default)) { - var eventStreamOffset = (int)commit.EventStreamOffset; - - var commitTimestamp = commit.Timestamp; - var commitOffset = 0; - - foreach (var @event in commit.Events) + foreach (var storedEvent in commit.Filtered().Reverse()) { - eventStreamOffset++; + result.Add(storedEvent); - var eventData = @event.ToEventData(); - var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); - - result.Add(new StoredEvent(streamName, eventToken, eventStreamOffset, eventData)); + if (result.Count == count) + { + break; + } } - - return Task.CompletedTask; - }); - - IEnumerable ordered = result.OrderBy(x => x.EventStreamNumber); - - if (result.Count > count) - { - ordered = ordered.Skip(result.Count - count); } - return ordered.ToList(); + return result; } } @@ -90,72 +77,89 @@ namespace Squidex.Infrastructure.EventSourcing var result = new List(); - await documentClient.QueryAsync(collectionUri, query, commit => + await foreach (var commit in documentClient.QueryAsync(collectionUri, query, default)) { - var eventStreamOffset = (int)commit.EventStreamOffset; - - var commitTimestamp = commit.Timestamp; - var commitOffset = 0; - - foreach (var @event in commit.Events) + foreach (var storedEvent in commit.Filtered().Reverse()) { - eventStreamOffset++; - - if (eventStreamOffset >= streamPosition) - { - var eventData = @event.ToEventData(); - var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); - - result.Add(new StoredEvent(streamName, eventToken, eventStreamOffset, eventData)); - } + result.Add(storedEvent); } - - return Task.CompletedTask; - }); + } return result; } } - public async Task QueryAsync(Func callback, string? streamFilter = null, string? position = null, CancellationToken ct = default) + public async IAsyncEnumerable QueryAllAsync( string? streamFilter = null, string? position = null, long take = long.MaxValue, + [EnumeratorCancellation] CancellationToken ct = default) { - Guard.NotNull(callback, nameof(callback)); - ThrowIfDisposed(); + if (take <= 0) + { + yield break; + } + StreamPosition lastPosition = position; - var filterDefinition = FilterBuilder.CreateByFilter(streamFilter, lastPosition); - var filterExpression = FilterBuilder.CreateExpression(null, null); + var filterDefinition = FilterBuilder.CreateByFilter(streamFilter, lastPosition, "ASC", take); - using (Profiler.TraceMethod()) + var taken = int.MaxValue; + + await foreach (var commit in documentClient.QueryAsync(collectionUri, filterDefinition, ct: ct)) { - await documentClient.QueryAsync(collectionUri, filterDefinition, async commit => + if (taken == take) { - var eventStreamOffset = (int)commit.EventStreamOffset; - - var commitTimestamp = commit.Timestamp; - var commitOffset = 0; + yield break; + } - foreach (var @event in commit.Events) + foreach (var storedEvent in commit.Filtered(lastPosition)) + { + if (taken == take) { - eventStreamOffset++; + yield break; + } - if (commitOffset > lastPosition.CommitOffset || commitTimestamp > lastPosition.Timestamp) - { - var eventData = @event.ToEventData(); + yield return storedEvent; - if (filterExpression(eventData)) - { - var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); + taken++; + } + } + } - await callback(new StoredEvent(commit.EventStream, eventToken, eventStreamOffset, eventData)); - } - } + public async IAsyncEnumerable QueryAllReverseAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue, + [EnumeratorCancellation] CancellationToken ct = default) + { + ThrowIfDisposed(); + + if (take <= 0) + { + yield break; + } - commitOffset++; + StreamPosition lastPosition = position; + + var filterDefinition = FilterBuilder.CreateByFilter(streamFilter, lastPosition, "DESC", take); + + var taken = long.MaxValue; + + await foreach (var commit in documentClient.QueryAsync(collectionUri, filterDefinition, ct: ct)) + { + if (taken == take) + { + yield break; + } + + foreach (var storedEvent in commit.Filtered(lastPosition)) + { + if (taken == take) + { + yield break; } - }, ct); + + yield return storedEvent; + + taken++; + } } } } diff --git a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs index e8329813e..68bf3d0d5 100644 --- a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs +++ b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs @@ -21,7 +21,7 @@ namespace Squidex.Infrastructure.EventSourcing private const int MaxWriteAttempts = 20; private const int MaxCommitSize = 10; - public Task DeleteStreamAsync(string streamName) + public async Task DeleteStreamAsync(string streamName) { Guard.NotNullOrEmpty(streamName, nameof(streamName)); @@ -34,12 +34,12 @@ namespace Squidex.Infrastructure.EventSourcing PartitionKey = new PartitionKey(streamName) }; - return documentClient.QueryAsync(collectionUri, query, commit => + await foreach (var commit in documentClient.QueryAsync(collectionUri, query)) { var documentUri = UriFactory.CreateDocumentUri(DatabaseId, Constants.Collection, commit.Id.ToString()); - return documentClient.DeleteDocumentAsync(documentUri, deleteOptions); - }); + await documentClient.DeleteDocumentAsync(documentUri, deleteOptions); + } } public Task AppendAsync(Guid commitId, string streamName, ICollection events) diff --git a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs index 89f3f2b6f..f24d50f8c 100644 --- a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs +++ b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using Microsoft.Azure.Documents; -using Squidex.Infrastructure.Json.Objects; namespace Squidex.Infrastructure.EventSourcing { @@ -70,10 +69,10 @@ namespace Squidex.Infrastructure.EventSourcing return new SqlQuerySpec(query, parameters); } - public static SqlQuerySpec ByStreamNameDesc(string streamName, long count) + public static SqlQuerySpec ByStreamNameDesc(string streamName, long take) { var query = - $"SELECT TOP {count}* " + + $"SELECT TOP {take}* " + $"FROM {Constants.Collection} e " + $"WHERE " + $" e.eventStream = @name " + @@ -87,19 +86,7 @@ namespace Squidex.Infrastructure.EventSourcing return new SqlQuerySpec(query, parameters); } - public static SqlQuerySpec CreateByProperty(string property, object value, StreamPosition streamPosition) - { - var filters = new List(); - - var parameters = new SqlParameterCollection(); - - filters.ForPosition(parameters, streamPosition); - filters.ForProperty(parameters, property, value); - - return BuildQuery(filters, parameters); - } - - public static SqlQuerySpec CreateByFilter(string? streamFilter, StreamPosition streamPosition) + public static SqlQuerySpec CreateByFilter(string? streamFilter, StreamPosition streamPosition, string sortOrder, long take) { var filters = new List(); @@ -108,23 +95,16 @@ namespace Squidex.Infrastructure.EventSourcing filters.ForPosition(parameters, streamPosition); filters.ForRegex(parameters, streamFilter); - return BuildQuery(filters, parameters); + return BuildQuery(filters, parameters, sortOrder, take); } - private static SqlQuerySpec BuildQuery(IEnumerable filters, SqlParameterCollection parameters) + private static SqlQuerySpec BuildQuery(IEnumerable filters, SqlParameterCollection parameters, string sortOrder, long take) { - var query = $"SELECT * FROM {Constants.Collection} e WHERE {string.Join(" AND ", filters)} ORDER BY e.timestamp"; + var query = $"SELECT TOP {take} * FROM {Constants.Collection} e WHERE {string.Join(" AND ", filters)} ORDER BY e.timestamp {sortOrder}"; return new SqlQuerySpec(query, parameters); } - private static void ForProperty(this ICollection filters, SqlParameterCollection parameters, string property, object value) - { - filters.Add($"ARRAY_CONTAINS(e.events, {{ \"header\": {{ \"{property}\": @value }} }}, true)"); - - parameters.Add(new SqlParameter("@value", value)); - } - private static void ForRegex(this ICollection filters, SqlParameterCollection parameters, string? streamFilter) { if (!StreamFilter.IsAll(streamFilter)) @@ -155,19 +135,5 @@ namespace Squidex.Infrastructure.EventSourcing parameters.Add(new SqlParameter("@time", streamPosition.Timestamp)); } - - public static EventPredicate CreateExpression(string? property, object? value) - { - if (!string.IsNullOrWhiteSpace(property)) - { - var jsonValue = JsonValue.Create(value); - - return x => x.Headers.TryGetValue(property, out var p) && p.Equals(jsonValue); - } - else - { - return x => true; - } - } } } diff --git a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs index 68d13782c..f5e074b55 100644 --- a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs +++ b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs @@ -6,7 +6,9 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Azure.Documents; @@ -39,29 +41,71 @@ namespace Squidex.Infrastructure.EventSourcing return default!; } - public static Task QueryAsync(this DocumentClient documentClient, Uri collectionUri, SqlQuerySpec querySpec, Func handler, CancellationToken ct = default) + public static async IAsyncEnumerable QueryAsync(this DocumentClient documentClient, Uri collectionUri, SqlQuerySpec querySpec, + [EnumeratorCancellation] CancellationToken ct = default) { var query = documentClient.CreateDocumentQuery(collectionUri, querySpec, CrossPartition); - return query.QueryAsync(handler, ct); - } - - public static async Task QueryAsync(this IQueryable queryable, Func handler, CancellationToken ct = default) - { - var documentQuery = queryable.AsDocumentQuery(); + var documentQuery = query.AsDocumentQuery(); using (documentQuery) { while (documentQuery.HasMoreResults && !ct.IsCancellationRequested) { - var items = await documentQuery.ExecuteNextAsync(ct); + var items = await documentQuery.ExecuteNextAsync(ct); foreach (var item in items) { - await handler(item); + yield return item; } } } } + + public static IEnumerable Filtered(this CosmosDbEventCommit commit, StreamPosition lastPosition) + { + var eventStreamOffset = commit.EventStreamOffset; + + var commitTimestamp = commit.Timestamp; + var commitOffset = 0; + + foreach (var @event in commit.Events) + { + eventStreamOffset++; + + if (commitOffset > lastPosition.CommitOffset || commitTimestamp > lastPosition.Timestamp) + { + var eventData = @event.ToEventData(); + var eventPosition = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); + + yield return new StoredEvent(commit.EventStream, eventPosition, eventStreamOffset, eventData); + } + + commitOffset++; + } + } + + public static IEnumerable Filtered(this CosmosDbEventCommit commit, long streamPosition = EtagVersion.Empty) + { + var eventStreamOffset = commit.EventStreamOffset; + + var commitTimestamp = commit.Timestamp; + var commitOffset = 0; + + foreach (var @event in commit.Events) + { + eventStreamOffset++; + + if (eventStreamOffset >= streamPosition) + { + var eventData = @event.ToEventData(); + var eventPosition = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); + + yield return new StoredEvent(commit.EventStream, eventPosition, eventStreamOffset, eventData); + } + + commitOffset++; + } + } } } diff --git a/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs b/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs index 88c640a08..929a2a23e 100644 --- a/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs +++ b/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using EventStore.ClientAPI; @@ -26,7 +27,7 @@ namespace Squidex.Infrastructure.EventSourcing private static readonly IReadOnlyList EmptyEvents = new List(); private readonly IEventStoreConnection connection; private readonly IJsonSerializer serializer; - private readonly string prefix; + private readonly string prefix = "squidex"; private readonly ProjectionClient projectionClient; public GetEventStore(IEventStoreConnection connection, IJsonSerializer serializer, string prefix, string projectionHost) @@ -37,9 +38,12 @@ namespace Squidex.Infrastructure.EventSourcing this.connection = connection; this.serializer = serializer; - this.prefix = prefix.Trim(' ', '-').Or("squidex"); + if (!string.IsNullOrWhiteSpace(prefix)) + { + this.prefix = prefix.Trim(' ', '-'); + } - projectionClient = new ProjectionClient(connection, prefix, projectionHost); + projectionClient = new ProjectionClient(connection, this.prefix, projectionHost); } public async Task InitializeAsync(CancellationToken ct = default) @@ -65,40 +69,40 @@ namespace Squidex.Infrastructure.EventSourcing return new GetEventStoreSubscription(connection, subscriber, serializer, projectionClient, position, prefix, streamFilter); } - public async Task QueryAsync(Func callback, string? streamFilter = null, string? position = null, CancellationToken ct = default) + public async IAsyncEnumerable QueryAllAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue, + [EnumeratorCancellation] CancellationToken ct = default) { - Guard.NotNull(callback, nameof(callback)); - - using (Profiler.TraceMethod()) + if (take <= 0) { - var streamName = await projectionClient.CreateProjectionAsync(streamFilter); + yield break; + } - var sliceStart = ProjectionClient.ParsePosition(position); + var streamName = await projectionClient.CreateProjectionAsync(streamFilter); - await QueryAsync(callback, streamName, sliceStart, ct); + var sliceStart = ProjectionClient.ParsePosition(position); + + await foreach (var storedEvent in QueryReverseAsync(streamName, sliceStart, take, ct)) + { + yield return storedEvent; } } - private async Task QueryAsync(Func callback, string streamName, long sliceStart, CancellationToken ct = default) + public async IAsyncEnumerable QueryAllReverseAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue, + [EnumeratorCancellation] CancellationToken ct = default) { - StreamEventsSlice currentSlice; - do + if (take <= 0) { - currentSlice = await connection.ReadStreamEventsForwardAsync(streamName, sliceStart, ReadPageSize, true); + yield break; + } - if (currentSlice.Status == SliceReadStatus.Success) - { - sliceStart = currentSlice.NextEventNumber; + var streamName = await projectionClient.CreateProjectionAsync(streamFilter); - foreach (var resolved in currentSlice.Events) - { - var storedEvent = Formatter.Read(resolved, prefix, serializer); + var sliceStart = ProjectionClient.ParsePosition(position); - await callback(storedEvent); - } - } + await foreach (var storedEvent in QueryAsync(streamName, sliceStart, take, ct)) + { + yield return storedEvent; } - while (!currentSlice.IsEndOfStream && !ct.IsCancellationRequested); } public async Task> QueryLatestAsync(string streamName, int count) @@ -114,35 +118,12 @@ namespace Squidex.Infrastructure.EventSourcing { var result = new List(); - var sliceStart = (long)StreamPosition.End; - - StreamEventsSlice currentSlice; - do - { - currentSlice = await connection.ReadStreamEventsBackwardAsync(GetStreamName(streamName), sliceStart, ReadPageSize, true); - - if (currentSlice.Status == SliceReadStatus.Success) - { - sliceStart = currentSlice.NextEventNumber; - - foreach (var resolved in currentSlice.Events) - { - var storedEvent = Formatter.Read(resolved, prefix, serializer); - - result.Add(storedEvent); - } - } - } - while (!currentSlice.IsEndOfStream); - - IEnumerable ordered = result.OrderBy(x => x.EventStreamNumber); - - if (result.Count > count) + await foreach (var storedEvent in QueryReverseAsync(streamName, StreamPosition.End, default)) { - ordered = ordered.Skip(result.Count - count); + result.Add(storedEvent); } - return ordered.ToList(); + return result.ToList(); } } @@ -154,29 +135,77 @@ namespace Squidex.Infrastructure.EventSourcing { var result = new List(); - var sliceStart = streamPosition >= 0 ? streamPosition : StreamPosition.Start; + await foreach (var storedEvent in QueryAsync(streamName, StreamPosition.End, default)) + { + result.Add(storedEvent); + } + + return result.ToList(); + } + } + + private async IAsyncEnumerable QueryAsync(string streamName, long sliceStart, long take = int.MaxValue, + [EnumeratorCancellation] CancellationToken ct = default) + { + var taken = take; - StreamEventsSlice currentSlice; - do + StreamEventsSlice currentSlice; + do + { + currentSlice = await connection.ReadStreamEventsForwardAsync(streamName, sliceStart, ReadPageSize, true); + + if (currentSlice.Status == SliceReadStatus.Success) { - currentSlice = await connection.ReadStreamEventsForwardAsync(GetStreamName(streamName), sliceStart, ReadPageSize, true); + sliceStart = currentSlice.NextEventNumber; - if (currentSlice.Status == SliceReadStatus.Success) + foreach (var resolved in currentSlice.Events) { - sliceStart = currentSlice.NextEventNumber; + var storedEvent = Formatter.Read(resolved, prefix, serializer); - foreach (var resolved in currentSlice.Events) - { - var storedEvent = Formatter.Read(resolved, prefix, serializer); + yield return storedEvent; - result.Add(storedEvent); + if (taken == take) + { + break; } + + taken++; } } - while (!currentSlice.IsEndOfStream); + } + while (!currentSlice.IsEndOfStream && !ct.IsCancellationRequested && taken < take); + } + + private async IAsyncEnumerable QueryReverseAsync(string streamName, long sliceStart, long take = int.MaxValue, + [EnumeratorCancellation] CancellationToken ct = default) + { + var taken = take; + + StreamEventsSlice currentSlice; + do + { + currentSlice = await connection.ReadStreamEventsBackwardAsync(streamName, sliceStart, ReadPageSize, true); + + if (currentSlice.Status == SliceReadStatus.Success) + { + sliceStart = currentSlice.NextEventNumber; - return result; + foreach (var resolved in currentSlice.Events.OrderByDescending(x => x.Event.EventNumber)) + { + var storedEvent = Formatter.Read(resolved, prefix, serializer); + + yield return storedEvent; + + if (taken == take) + { + break; + } + + taken++; + } + } } + while (!currentSlice.IsEndOfStream && !ct.IsCancellationRequested && taken < take); } public Task DeleteStreamAsync(string streamName) diff --git a/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionClient.cs b/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionClient.cs index 81f588814..554ccb2bb 100644 --- a/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionClient.cs +++ b/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionClient.cs @@ -9,6 +9,7 @@ using System; using System.Collections.Concurrent; using System.Linq; using System.Net; +using System.Net.Http; using System.Net.Sockets; using System.Threading.Tasks; using EventStore.ClientAPI; @@ -23,21 +24,21 @@ namespace Squidex.Infrastructure.EventSourcing { private readonly ConcurrentDictionary projections = new ConcurrentDictionary(); private readonly IEventStoreConnection connection; - private readonly string prefix; + private readonly string projectionPrefix; private readonly string projectionHost; private ProjectionsManager projectionsManager; - public ProjectionClient(IEventStoreConnection connection, string prefix, string projectionHost) + public ProjectionClient(IEventStoreConnection connection, string projectionPrefix, string projectionHost) { this.connection = connection; - this.prefix = prefix; + this.projectionPrefix = projectionPrefix; this.projectionHost = projectionHost; } private string CreateFilterProjectionName(string filter) { - return $"by-{prefix.Slugify()}-{filter.Slugify()}"; + return $"by-{projectionPrefix.Slugify()}-{filter.Slugify()}"; } public async Task CreateProjectionAsync(string? streamFilter = null) @@ -50,7 +51,7 @@ namespace Squidex.Infrastructure.EventSourcing $@"fromAll() .when({{ $any: function (s, e) {{ - if (e.streamId.indexOf('{prefix}') === 0 && /{streamFilter}/.test(e.streamId.substring({prefix.Length + 1}))) {{ + if (e.streamId.indexOf('{projectionPrefix}') === 0 && /{streamFilter}/.test(e.streamId.substring({projectionPrefix.Length + 1}))) {{ linkTo('{name}', e); }} }} @@ -93,14 +94,33 @@ namespace Squidex.Infrastructure.EventSourcing var endpoints = await Dns.GetHostAddressesAsync(addressParts[0]); var endpoint = new IPEndPoint(endpoints.First(x => x.AddressFamily == AddressFamily.InterNetwork), port); - projectionsManager = - new ProjectionsManager( - connection.Settings.Log, endpoint, - connection.Settings.OperationTimeout); - try + async Task ConnectToSchemaAsync(string schema) { + projectionsManager = + new ProjectionsManager( + connection.Settings.Log, endpoint, + connection.Settings.OperationTimeout, + null, + schema); + await projectionsManager.ListAllAsync(connection.Settings.DefaultUserCredentials); } + + try + { + try + { + await ConnectToSchemaAsync("https"); + } + catch (HttpRequestException) + { + await ConnectToSchemaAsync("http"); + } + catch (AggregateException ex) when (ex.Flatten().InnerException is HttpRequestException) + { + await ConnectToSchemaAsync("http"); + } + } catch (Exception ex) { var error = new ConfigurationError($"GetEventStore cannot connect to event store projections: {projectionHost}."); diff --git a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/Filtering.cs b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/FilterExtensions.cs similarity index 97% rename from backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/Filtering.cs rename to backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/FilterExtensions.cs index 2c96a52be..da66bffb5 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/Filtering.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/FilterExtensions.cs @@ -10,7 +10,7 @@ using MongoDB.Driver; namespace Squidex.Infrastructure.EventSourcing { - internal static class Filtering + internal static class FilterExtensions { public static FilterDefinition ByPosition(StreamPosition streamPosition) { @@ -81,7 +81,7 @@ namespace Squidex.Infrastructure.EventSourcing } } - public static IEnumerable Filtered(this MongoEventCommit commit, long streamPosition) + public static IEnumerable Filtered(this MongoEventCommit commit, long streamPosition = EtagVersion.Empty) { var eventStreamOffset = commit.EventStreamOffset; diff --git a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs index e72393b41..26ab00340 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs @@ -114,7 +114,7 @@ namespace Squidex.Infrastructure.EventSourcing { using (var combined = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, stopToken.Token)) { - await eventStore.QueryAsync(async storedEvent => + await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, position, ct: combined.Token)) { var now = SystemClock.Instance.GetCurrentInstant(); @@ -130,7 +130,7 @@ namespace Squidex.Infrastructure.EventSourcing lastRawPosition = storedEvent.EventPosition; } - }, streamFilter, position, combined.Token); + } } } @@ -141,7 +141,7 @@ namespace Squidex.Infrastructure.EventSourcing { var result = new EmptyPipelineDefinition>(); - var byStream = Filtering.ByChangeInStream(streamFilter); + var byStream = FilterExtensions.ByChangeInStream(streamFilter); if (byStream != null) { diff --git a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs index 7eda87401..3dbd2605e 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using MongoDB.Driver; @@ -23,17 +24,6 @@ namespace Squidex.Infrastructure.EventSourcing { private static readonly List EmptyEvents = new List(); - public Task CreateIndexAsync(string property) - { - Guard.NotNullOrEmpty(property, nameof(property)); - - return Collection.Indexes.CreateOneAsync( - new CreateIndexModel( - Index - .Ascending(CreateIndexPath(property)) - .Ascending(TimestampField))); - } - public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string? streamFilter = null, string? position = null) { Guard.NotNull(subscriber, nameof(subscriber)); @@ -64,21 +54,9 @@ namespace Squidex.Infrastructure.EventSourcing Filter.Eq(EventStreamField, streamName)) .Sort(Sort.Descending(TimestampField)).Limit(count).ToListAsync(); - var result = new List(); + var result = commits.Select(x => x.Filtered()).Reverse().SelectMany(x => x).TakeLast(count).ToList(); - foreach (var commit in commits) - { - result.AddRange(commit.Filtered(long.MinValue)); - } - - IEnumerable ordered = result.OrderBy(x => x.EventStreamNumber); - - if (result.Count > count) - { - ordered = ordered.Skip(result.Count - count); - } - - return ordered.ToList(); + return result; } } @@ -125,35 +103,95 @@ namespace Squidex.Infrastructure.EventSourcing } } - public Task QueryAsync(Func callback, string? streamFilter = null, string? position = null, CancellationToken ct = default) + public async IAsyncEnumerable QueryAllReverseAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue, + [EnumeratorCancellation] CancellationToken ct = default) { - Guard.NotNull(callback, nameof(callback)); + if (take <= 0) + { + yield break; + } StreamPosition lastPosition = position; var filterDefinition = CreateFilter(streamFilter, lastPosition); - return QueryAsync(callback, lastPosition, filterDefinition, ct); + var find = + Collection.Find(filterDefinition, options: Batching.Options) + .Limit((int)take).Sort(Sort.Descending(TimestampField)); + + var taken = 0; + + using (var cursor = await find.ToCursorAsync(ct)) + { + while (taken < take && await cursor.MoveNextAsync(ct)) + { + foreach (var current in cursor.Current) + { + foreach (var @event in current.Filtered(position).Reverse()) + { + yield return @event; + + taken++; + + if (taken == take) + { + break; + } + } + + if (taken == take) + { + break; + } + } + } + } } - private async Task QueryAsync(Func callback, StreamPosition position, EventFilter filter, CancellationToken ct = default) + public async IAsyncEnumerable QueryAllAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue, + [EnumeratorCancellation] CancellationToken ct = default) { - using (Profiler.TraceMethod()) + StreamPosition lastPosition = position; + + var filterDefinition = CreateFilter(streamFilter, lastPosition); + + var find = + Collection.Find(filterDefinition) + .Limit((int)take).Sort(Sort.Ascending(TimestampField)); + + var taken = 0; + + using (var cursor = await find.ToCursorAsync(ct)) { - await Collection.Find(filter, options: Batching.Options).Sort(Sort.Ascending(TimestampField)).ForEachPipedAsync(async commit => + while (taken < take && await cursor.MoveNextAsync(ct)) { - foreach (var @event in commit.Filtered(position)) + foreach (var current in cursor.Current) { - await callback(@event); + foreach (var @event in current.Filtered(position)) + { + yield return @event; + + taken++; + + if (taken == take) + { + break; + } + } + + if (taken == take) + { + break; + } } - }, ct); + } } } private static EventFilter CreateFilter(string? streamFilter, StreamPosition streamPosition) { - var byPosition = Filtering.ByPosition(streamPosition); - var byStream = Filtering.ByStream(streamFilter); + var byPosition = FilterExtensions.ByPosition(streamPosition); + var byStream = FilterExtensions.ByStream(streamFilter); if (byStream != null) { @@ -162,10 +200,5 @@ namespace Squidex.Infrastructure.EventSourcing return byPosition; } - - private static string CreateIndexPath(string property) - { - return $"Events.Metadata.{property}"; - } } } \ No newline at end of file diff --git a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs index 545bd0179..eac71f6a6 100644 --- a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -8,14 +8,16 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; - -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously namespace Squidex.Infrastructure { public static class CollectionExtensions { + public static bool SetEquals(this IReadOnlyCollection source, IReadOnlyCollection other) + { + return source.Count == other.Count && source.Intersect(other).Count() == other.Count; + } + public static IEnumerable> Batch(this IEnumerable source, int size) { TSource[]? bucket = null; @@ -48,34 +50,14 @@ namespace Squidex.Infrastructure } } - public static async Task> ToListAsync(this IAsyncEnumerable source) - { - var result = new List(); - - await foreach (var item in source) - { - result.Add(item); - } - - return result; - } - - public static async IAsyncEnumerable ToAsyncEnumerable(this IEnumerable source) - { - foreach (var item in source) - { - yield return item; - } - } - - public static bool SetEquals(this IReadOnlyCollection source, IReadOnlyCollection other) + public static bool SetEquals(this IReadOnlyCollection source, IReadOnlyCollection other, IEqualityComparer comparer) { - return source.Count == other.Count && source.Intersect(other).Count() == other.Count; + return source.Count == other.Count && source.Intersect(other, comparer).Count() == other.Count; } - public static bool SetEquals(this IReadOnlyCollection source, IReadOnlyCollection other, IEqualityComparer comparer) + public static IEnumerable Reverse(this IEnumerable source, bool reverse) { - return source.Count == other.Count && source.Intersect(other, comparer).Count() == other.Count; + return reverse ? source.Reverse() : source; } public static IResultList SortSet(this IResultList input, Func idProvider, IReadOnlyList ids) where T : class diff --git a/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs b/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs index 1cd68835b..22e506198 100644 --- a/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs +++ b/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs @@ -59,12 +59,12 @@ namespace Squidex.Infrastructure.Commands await InsertManyAsync(store, async target => { - await eventStore.QueryAsync(async storedEvent => + await foreach (var storedEvent in eventStore.QueryAllAsync(filter, ct: ct)) { var id = storedEvent.Data.Headers.AggregateId(); await target(id); - }, filter, ct: ct); + } }, ct); } diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/EventData.cs b/backend/src/Squidex.Infrastructure/EventSourcing/EventData.cs index 756d7ae63..0aaaf6d42 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/EventData.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/EventData.cs @@ -5,27 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + namespace Squidex.Infrastructure.EventSourcing { - public sealed class EventData - { - public EnvelopeHeaders Headers { get; } - - public string Payload { get; } - - public string Type { get; set; } - - public EventData(string type, EnvelopeHeaders headers, string payload) - { - Guard.NotNull(type, nameof(type)); - Guard.NotNull(headers, nameof(headers)); - Guard.NotNull(payload, nameof(payload)); - - Headers = headers; - - Payload = payload; - - Type = type; - } - } + public sealed record EventData(string Type, EnvelopeHeaders Headers, string Payload); } \ No newline at end of file diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs b/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs index 776273f8a..62eb88fd5 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs @@ -14,11 +14,13 @@ namespace Squidex.Infrastructure.EventSourcing { public interface IEventStore { - Task> QueryLatestAsync(string streamName, int count); + Task> QueryLatestAsync(string streamName, int take = int.MaxValue); Task> QueryAsync(string streamName, long streamPosition = 0); - Task QueryAsync(Func callback, string? streamFilter = null, string? position = null, CancellationToken ct = default); + IAsyncEnumerable QueryAllReverseAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue, CancellationToken ct = default); + + IAsyncEnumerable QueryAllAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue, CancellationToken ct = default); Task AppendAsync(Guid commitId, string streamName, ICollection events); @@ -42,7 +44,7 @@ namespace Squidex.Infrastructure.EventSourcing foreach (var streamName in streamNames) { - result[streamName] = await QueryAsync(streamName); + result[streamName] = await QueryAsync(streamName, 0); } return result; diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs b/backend/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs index 3e0dc7006..4864762bc 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs @@ -28,12 +28,12 @@ namespace Squidex.Infrastructure.EventSourcing { try { - await eventStore.QueryAsync(async storedEvent => + await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, position, ct: ct)) { await eventSubscriber.OnEventAsync(this, storedEvent); position = storedEvent.EventPosition; - }, streamFilter, position, ct); + } } catch (Exception ex) { diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs b/backend/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs index 8ede858a2..71cb87540 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs @@ -5,30 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + namespace Squidex.Infrastructure.EventSourcing { - public sealed class StoredEvent + public sealed record StoredEvent(string StreamName, string EventPosition, long EventStreamNumber, EventData Data) { - public string StreamName { get; } - - public string EventPosition { get; } - - public long EventStreamNumber { get; } - - public EventData Data { get; } - - public StoredEvent(string streamName, string eventPosition, long eventStreamNumber, EventData data) - { - Guard.NotNullOrEmpty(streamName, nameof(streamName)); - Guard.NotNullOrEmpty(eventPosition, nameof(eventPosition)); - Guard.NotNull(data, nameof(data)); - - Data = data; - - EventPosition = eventPosition; - EventStreamNumber = eventStreamNumber; - - StreamName = streamName; - } } } diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index b6141614f..e7a06c120 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -32,7 +32,7 @@ - + diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs index a37f4b393..391a1c562 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs @@ -53,7 +53,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger) { - var schemas = trigger.Schemas.Select(x => SimpleMapper.Map(x, new ContentChangedRuleTriggerSchemaDto())).ToArray(); + var schemas = trigger.Schemas?.Select(ContentChangedRuleTriggerSchemaDto.FromTrigger).ToArray(); return new ContentChangedRuleTriggerDto { Schemas = schemas, HandleAll = trigger.HandleAll }; } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs new file mode 100644 index 000000000..df6d38f4e --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Entities.Rules.Runner; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Rules.Models +{ + public sealed record SimulatedRuleEventDto + { + /// + /// The name of the event. + /// + [Required] + public string EventName { get; set; } + + /// + /// The data for the action. + /// + public string? ActionName { get; set; } + + /// + /// The name of the action. + /// + public string? ActionData { get; set; } + + /// + /// The name of the event. + /// + public string? Error { get; set; } + + /// + /// The reason why the event has been skipped. + /// + [Required] + public SkipReason SkipReason { get; set; } + + public static SimulatedRuleEventDto FromSimulatedRuleEvent(SimulatedRuleEvent ruleEvent) + { + return SimpleMapper.Map(ruleEvent, new SimulatedRuleEventDto()); + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventsDto.cs new file mode 100644 index 000000000..c9cad3a73 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventsDto.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Domain.Apps.Entities.Rules.Runner; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Validation; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Rules.Models +{ + public sealed class SimulatedRuleEventsDto : Resource + { + /// + /// The simulated rule events. + /// + [LocalizedRequired] + public SimulatedRuleEventDto[] Items { get; set; } + + /// + /// The total number of simulated rule events. + /// + public long Total { get; set; } + + public static SimulatedRuleEventsDto FromSimulatedRuleEvents(IList events) + { + var result = new SimulatedRuleEventsDto + { + Total = events.Count, + Items = events.Select(SimulatedRuleEventDto.FromSimulatedRuleEvent).ToArray() + }; + + return result; + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs index 7d8b22f89..52ef13473 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs @@ -9,8 +9,6 @@ using System.Linq; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Infrastructure.Collections; -using Squidex.Infrastructure.Reflection; -using Squidex.Infrastructure.Validation; namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers { @@ -19,8 +17,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers /// /// The schema settings. /// - [LocalizedRequired] - public ContentChangedRuleTriggerSchemaDto[] Schemas { get; set; } + public ContentChangedRuleTriggerSchemaDto[]? Schemas { get; set; } /// /// Determines whether the trigger should handle all content changes events. @@ -29,7 +26,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers public override RuleTrigger ToTrigger() { - var schemas = Schemas.Select(x => SimpleMapper.Map(x, new ContentChangedTriggerSchemaV2())).ToReadOnlyCollection(); + var schemas = Schemas?.Select(x => x.ToTrigger()).ToReadOnlyCollection(); return new ContentChangedTriggerV2 { HandleAll = HandleAll, Schemas = schemas }; } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs index 4c54f0f6b..7a575ed6f 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs @@ -5,7 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers { @@ -20,5 +22,15 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers /// Javascript condition when to trigger. /// public string? Condition { get; set; } + + public ContentChangedTriggerSchemaV2 ToTrigger() + { + return SimpleMapper.Map(this, new ContentChangedTriggerSchemaV2()); + } + + public static ContentChangedRuleTriggerSchemaDto FromTrigger(ContentChangedTriggerSchemaV2 trigger) + { + return SimpleMapper.Map(trigger, new ContentChangedRuleTriggerSchemaDto()); + } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index e93e1d825..b15866212 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -15,6 +15,7 @@ using Microsoft.Net.Http.Headers; using NodaTime; using Squidex.Areas.Api.Controllers.Rules.Models; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.Rules.Repositories; @@ -32,13 +33,15 @@ namespace Squidex.Areas.Api.Controllers.Rules [ApiExplorerSettings(GroupName = nameof(Rules))] public sealed class RulesController : ApiController { + private readonly EventJsonSchemaGenerator eventJsonSchemaGenerator; + private readonly IAppProvider appProvider; + private readonly IRuleEventRepository ruleEventsRepository; private readonly IRuleQueryService ruleQuery; private readonly IRuleRunnerService ruleRunnerService; - private readonly IRuleEventRepository ruleEventsRepository; - private readonly EventJsonSchemaGenerator eventJsonSchemaGenerator; private readonly RuleRegistry ruleRegistry; public RulesController(ICommandBus commandBus, + IAppProvider appProvider, IRuleEventRepository ruleEventsRepository, IRuleQueryService ruleQuery, IRuleRunnerService ruleRunnerService, @@ -46,6 +49,7 @@ namespace Squidex.Areas.Api.Controllers.Rules EventJsonSchemaGenerator eventJsonSchemaGenerator) : base(commandBus) { + this.appProvider = appProvider; this.ruleEventsRepository = ruleEventsRepository; this.ruleQuery = ruleQuery; this.ruleRunnerService = ruleRunnerService; @@ -260,6 +264,36 @@ namespace Squidex.Areas.Api.Controllers.Rules return NoContent(); } + /// + /// Simulate a rule. + /// + /// The name of the app. + /// The id of the rule to simulate. + /// + /// 200 => Rule simulated. + /// 404 => Rule or app not found. + /// + [HttpGet] + [Route("apps/{app}/rules/{id}/simulate/")] + [ProducesResponseType(typeof(SimulatedRuleEventsDto), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous(Permissions.AppRulesEvents)] + [ApiCosts(5)] + public async Task Simulate(string app, DomainId id) + { + var rule = await appProvider.GetRuleAsync(AppId, id); + + if (rule == null) + { + return NotFound(); + } + + var simulation = await ruleRunnerService.SimulateAsync(rule, HttpContext.RequestAborted); + + var response = SimulatedRuleEventsDto.FromSimulatedRuleEvents(simulation); + + return Ok(response); + } + /// /// Delete a rule. /// diff --git a/backend/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs b/backend/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs index e179710df..f0115b68c 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Collections.Generic; -using Squidex.Domain.Apps.Core.Apps; namespace Squidex.Areas.Api.Controllers.UI { diff --git a/backend/src/Squidex/Config/Domain/EventSourcingServices.cs b/backend/src/Squidex/Config/Domain/EventSourcingServices.cs index 6b0940af8..c40378a11 100644 --- a/backend/src/Squidex/Config/Domain/EventSourcingServices.cs +++ b/backend/src/Squidex/Config/Domain/EventSourcingServices.cs @@ -5,20 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Linq; -using EventStore.ClientAPI; -using Microsoft.Azure.Documents.Client; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; -using Newtonsoft.Json; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Diagnostics; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing.Grains; -using Squidex.Infrastructure.Json; using Squidex.Infrastructure.States; namespace Squidex.Config.Domain @@ -42,44 +36,6 @@ namespace Squidex.Config.Domain return new MongoEventStore(mongDatabase, c.GetRequiredService()); }) .As(); - }, - ["CosmosDb"] = () => - { - var cosmosDbConfiguration = config.GetRequiredValue("eventStore:cosmosDB:configuration"); - var cosmosDbMasterKey = config.GetRequiredValue("eventStore:cosmosDB:masterKey"); - var cosmosDbDatabase = config.GetRequiredValue("eventStore:cosmosDB:database"); - - services.AddSingletonAs(c => new DocumentClient(new Uri(cosmosDbConfiguration), cosmosDbMasterKey, c.GetRequiredService())) - .AsSelf(); - - services.AddSingletonAs(c => new CosmosDbEventStore( - c.GetRequiredService(), - cosmosDbMasterKey, - cosmosDbDatabase, - c.GetRequiredService())) - .As(); - - services.AddHealthChecks() - .AddCheck("CosmosDB", tags: new[] { "node" }); - }, - ["GetEventStore"] = () => - { - var eventStoreConfiguration = config.GetRequiredValue("eventStore:getEventStore:configuration"); - var eventStoreProjectionHost = config.GetRequiredValue("eventStore:getEventStore:projectionHost"); - var eventStorePrefix = config.GetValue("eventStore:getEventStore:prefix"); - - services.AddSingletonAs(_ => EventStoreConnection.Create(eventStoreConfiguration)) - .As(); - - services.AddSingletonAs(c => new GetEventStore( - c.GetRequiredService(), - c.GetRequiredService(), - eventStorePrefix, - eventStoreProjectionHost)) - .As(); - - services.AddHealthChecks() - .AddCheck("EventStore", tags: new[] { "node" }); } }); diff --git a/backend/src/Squidex/Config/Domain/RuleServices.cs b/backend/src/Squidex/Config/Domain/RuleServices.cs index 41e527982..e365a5d11 100644 --- a/backend/src/Squidex/Config/Domain/RuleServices.cs +++ b/backend/src/Squidex/Config/Domain/RuleServices.cs @@ -72,7 +72,7 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - services.AddSingletonAs() + services.AddSingletonAs() .As(); services.AddSingletonAs() diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index c6fdd4bdd..5f6d69cce 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -24,8 +24,6 @@ - - @@ -47,6 +45,7 @@ + diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index 9361f90e3..a4565523f 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -494,7 +494,7 @@ /* * Define the type of the event store. * - * Supported: MongoDb, GetEventStore, CosmosDb + * Supported: MongoDb */ "type": "MongoDb", "mongoDb": { @@ -505,40 +505,6 @@ */ "configuration": "mongodb://localhost", - /* - * The name of the event store database. - */ - "database": "Squidex" - }, - "getEventStore": { - /* - * The connection string to your EventStore. - * - * Read Mode: http://docs.geteventstore.com/dotnet-api/4.0.0/connecting-to-a-server/ - */ - "configuration": "ConnectTo=tcp://admin:changeit@localhost:1113; HeartBeatTimeout=500; MaxReconnections=-1", - - /* - * The host name of your EventStore where projection requests will be sent to. - */ - "projectionHost": "localhost", - - /* - * Prefix for all streams and projections (for multiple installations). - */ - "prefix": "squidex" - }, - "cosmosDb": { - /* - * The connection string to your CosmosDB instance. - */ - "configuration": "https://localhost:8081", - - /* - * The primary access key. - */ - "masterKey": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", - /* * The name of the event store database. */ diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs index c3cb1999a..e46647491 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs @@ -93,6 +93,57 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules eventEnricher, TestUtils.DefaultSerializer, clock, log, typeNameRegistry); } + [Fact] + public void Should_calculate_event_name_from_trigger_handler() + { + var @event = new ContentCreated(); + + A.CallTo(() => ruleTriggerHandler.Handles(@event)) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.GetName(@event)) + .Returns("custom-name"); + + var name = sut.GetName(@event); + + Assert.Equal("custom-name", name); + } + + [Fact] + public void Should_calculate_default_name_if_trigger_handler_returns_no_name() + { + var @event = new ContentCreated(); + + A.CallTo(() => ruleTriggerHandler.Handles(@event)) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.GetName(@event)) + .Returns(null); + + var name = sut.GetName(@event); + + Assert.Equal("ContentCreated", name); + + A.CallTo(() => ruleTriggerHandler.GetName(@event)) + .MustHaveHappened(); + } + + [Fact] + public void Should_calculate_default_name_if_trigger_handler_cannot_not_handle_event() + { + var @event = new ContentCreated(); + + A.CallTo(() => ruleTriggerHandler.Handles(@event)) + .Returns(false); + + var name = sut.GetName(@event); + + Assert.Equal("ContentCreated", name); + + A.CallTo(() => ruleTriggerHandler.GetName(@event)) + .MustNotHaveHappened(); + } + [Fact] public void Should_not_run_from_snapshots_if_no_trigger_handler_registered() { @@ -107,7 +158,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(false); - var result = sut.CanCreateSnapshotEvents(ValidRule()); + var result = sut.CanCreateSnapshotEvents(Rule()); Assert.False(result); } @@ -118,7 +169,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); - var result = sut.CanCreateSnapshotEvents(ValidRule()); + var result = sut.CanCreateSnapshotEvents(Rule()); Assert.True(result); } @@ -129,11 +180,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(false); - var jobs = await sut.CreateSnapshotJobsAsync(ValidRule(), ruleId, appId.Id).ToListAsync(); + var jobs = await sut.CreateSnapshotJobsAsync(Rule()).ToListAsync(); Assert.Empty(jobs); - A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A._, A._)) + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(A._, A._)) .MustNotHaveHappened(); } @@ -143,11 +194,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); - var jobs = await sut.CreateSnapshotJobsAsync(ValidRule().Disable(), ruleId, appId.Id).ToListAsync(); + var jobs = await sut.CreateSnapshotJobsAsync(Rule(disable: true)).ToListAsync(); Assert.Empty(jobs); - A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A._, A._)) + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(A._, A._)) .MustNotHaveHappened(); } @@ -157,11 +208,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); - var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidTrigger(), ruleId, appId.Id).ToListAsync(); + var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidTrigger()).ToListAsync(); Assert.Empty(jobs); - A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A._, A._)) + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(A._, A._)) .MustNotHaveHappened(); } @@ -171,33 +222,33 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); - var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidAction(), ruleId, appId.Id).ToListAsync(); + var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidAction()).ToListAsync(); Assert.Empty(jobs); - A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A._, A._)) + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(A._, A._)) .MustNotHaveHappened(); } [Fact] public async Task Should_create_jobs_from_snapshots() { - var rule = ValidRule(); + var context = Rule(); A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, rule.Trigger)) + A.CallTo(() => ruleTriggerHandler.Trigger(A._, context)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(rule.Trigger, appId.Id)) + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(context, default)) .Returns(new List { new EnrichedContentEvent { AppId = appId }, new EnrichedContentEvent { AppId = appId } }.ToAsyncEnumerable()); - var result = await sut.CreateSnapshotJobsAsync(rule, ruleId, appId.Id).ToListAsync(); + var result = await sut.CreateSnapshotJobsAsync(context).ToListAsync(); Assert.Equal(2, result.Count(x => x.Job != null && x.Exception == null)); } @@ -205,179 +256,265 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules [Fact] public async Task Should_create_jobs_with_exceptions_from_snapshots() { - var rule = ValidRule(); + var context = Rule(); A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, rule.Trigger)) + A.CallTo(() => ruleTriggerHandler.Trigger(A._, context)) .Throws(new InvalidOperationException()); - A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(rule.Trigger, appId.Id)) + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(context, default)) .Returns(new List { new EnrichedContentEvent { AppId = appId }, new EnrichedContentEvent { AppId = appId } }.ToAsyncEnumerable()); - var result = await sut.CreateSnapshotJobsAsync(rule, ruleId, appId.Id).ToListAsync(); + var result = await sut.CreateSnapshotJobsAsync(context).ToListAsync(); Assert.Equal(2, result.Count(x => x.Job == null && x.Exception != null)); } [Fact] - public async Task Should_not_create_job_if_rule_disabled() + public async Task Should_create_debug_rob_if_rule_disabled() { var @event = Envelope.Create(new ContentCreated()); - var jobs = await sut.CreateJobsAsync(ValidRule().Disable(), ruleId, @event); + var (_, _, reason) = await sut.CreateJobsAsync(@event, Rule(disable: true)).SingleAsync(); - Assert.Empty(jobs); + Assert.Equal(SkipReason.Disabled, reason); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, A._, ruleId)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } [Fact] - public async Task Should_not_create_job_for_invalid_event() + public async Task Should_create_debug_job_for_invalid_event() { var @event = Envelope.Create(new InvalidEvent()); - var jobs = await sut.CreateJobsAsync(ValidRule(), ruleId, @event); + var (_, _, reason) = await sut.CreateJobsAsync(@event, Rule()).SingleAsync(); - Assert.Empty(jobs); + Assert.Equal(SkipReason.EventMismatch, reason); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, A._, ruleId)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } [Fact] - public async Task Should_not_create_job_if_no_trigger_handler_registered() + public async Task Should_create_debug_job_if_no_trigger_handler_registered() { var @event = Envelope.Create(new ContentCreated()); - var jobs = await sut.CreateJobsAsync(RuleInvalidTrigger(), ruleId, @event); + var (_, _, reason) = await sut.CreateJobsAsync(@event, RuleInvalidTrigger()).SingleAsync(); - Assert.Empty(jobs); + Assert.Equal(SkipReason.NoTrigger, reason); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, A._, ruleId)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } [Fact] - public async Task Should_not_create_job_if_no_action_handler_registered() + public async Task Should_create_debug_job_if_trigger_handler_does_not_handle_event() { var @event = Envelope.Create(new ContentCreated()); - var jobs = await sut.CreateJobsAsync(RuleInvalidAction(), ruleId, @event); + var (_, _, reason) = await sut.CreateJobsAsync(@event, Rule()).SingleAsync(); - Assert.Empty(jobs); + Assert.Equal(SkipReason.WrongEventForTrigger, reason); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, A._, ruleId)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } [Fact] - public async Task Should_not_create_job_if_too_old() + public async Task Should_create_debug_job_if_no_action_handler_registered() { - var @event = Envelope.Create(new ContentCreated()).SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3))); + var @event = Envelope.Create(new ContentCreated()); - var jobs = await sut.CreateJobsAsync(ValidRule(), ruleId, @event, true); + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) + .Returns(true); - Assert.Empty(jobs); + var (_, _, reason) = await sut.CreateJobsAsync(@event, RuleInvalidAction()).SingleAsync(); + + Assert.Equal(SkipReason.NoAction, reason); + + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_create_debug_job_if_too_old() + { + var @event = + Envelope.Create(new ContentCreated()) + .SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3))); + + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) + .Returns(true); + + var (_, _, reason) = await sut.CreateJobsAsync(@event, Rule(ignoreState: true)).SingleAsync(); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, A._, ruleId)) + Assert.Equal(SkipReason.TooOld, reason); + + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } [Fact] public async Task Should_create_job_if_too_old_but_stale_events_are_not_ignored() { - var @event = Envelope.Create(new ContentCreated()).SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3))); + var context = Rule(ignoreState: false); + + var @event = + Envelope.Create(new ContentCreated()) + .SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3))); + + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, context)) + .Returns(true); - var jobs = await sut.CreateJobsAsync(ValidRule(), ruleId, @event, false); + A.CallTo(() => ruleTriggerHandler.Trigger(A._, context)) + .Returns(true); + + var jobs = await sut.CreateJobsAsync(@event, context).ToListAsync(); Assert.Empty(jobs); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, A._, ruleId)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustHaveHappened(); } [Fact] - public async Task Should_not_create_job_if_event_created_by_rule() + public async Task Should_create_debug_job_if_event_created_by_rule() { - var rule = ValidRule(); + var context = Rule(); var @event = Envelope.Create(new ContentCreated { FromRule = true }); - var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); + var (_, _, reason) = await sut.CreateJobsAsync(@event, context).SingleAsync(); - Assert.Empty(jobs); + Assert.Equal(SkipReason.FromRule, reason); - A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A>._)) + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A>._, A._, default)) .MustNotHaveHappened(); } [Fact] - public async Task Should_not_create_job_if_not_triggered_with_precheck() + public async Task Should_create_debug_job_if_not_triggered_with_precheck() { - var rule = ValidRule(); + var context = Rule(); var @event = Envelope.Create(new ContentCreated()); - A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context)) .Returns(false); - var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); + var (_, _, reason) = await sut.CreateJobsAsync(@event, context).SingleAsync(); - Assert.Empty(jobs); + Assert.Equal(SkipReason.ConditionDoesNotMatch, reason); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A>._)) + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A>._, A._, default)) .MustNotHaveHappened(); } [Fact] - public async Task Should_not_create_job_if_enriched_event_not_created() + public async Task Should_create_debug_job_if_condition_check_failed() { - var rule = ValidRule(); + var context = Rule(); var @event = Envelope.Create(new ContentCreated()); - A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event))) - .Returns(new List()); + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context)) + .Throws(new InvalidOperationException()); + + var (_, _, reason) = await sut.CreateJobsAsync(@event, context).SingleAsync(); + + Assert.Equal(SkipReason.Failed, reason); + } - var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); + [Fact] + public async Task Should_not_create_jobs_if_enriched_event_not_created() + { + var context = Rule(); + + var @event = Envelope.Create(new ContentCreated()); + + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context)) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default)) + .Returns(AsyncEnumerable.Empty()); + + var jobs = await sut.CreateJobsAsync(@event, context).ToListAsync(); Assert.Empty(jobs); } [Fact] - public async Task Should_not_create_job_if_not_triggered() + public async Task Should_create_debug_job_if_not_triggered() { - var rule = ValidRule(); + var context = Rule(); var enrichedEvent = new EnrichedContentEvent { AppId = appId }; var @event = Envelope.Create(new ContentCreated()); - A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event))) - .Returns(new List { enrichedEvent }); + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context)) + .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, rule.Trigger)) + A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, context)) .Returns(false); - var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default)) + .Returns(new List { enrichedEvent }.ToAsyncEnumerable()); - Assert.Empty(jobs); + var (_, _, reason) = await sut.CreateJobsAsync(@event, context).SingleAsync(); + + Assert.Equal(SkipReason.ConditionDoesNotMatch, reason); + } + + [Fact] + public async Task Should_create_debug_job_if_enrichment_failed() + { + var now = clock.GetCurrentInstant(); + + var context = Rule(); + + var @event = + Envelope.Create(new ContentCreated()) + .SetTimestamp(now); + + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context)) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default)) + .Throws(new InvalidOperationException()); + + var (_, _, reason) = await sut.CreateJobsAsync(@event, context).SingleAsync(); + + Assert.Equal(SkipReason.Failed, reason); } [Fact] @@ -385,29 +522,32 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules { var now = clock.GetCurrentInstant(); - var rule = ValidRule(); + var context = Rule(); var enrichedEvent = new EnrichedContentEvent { AppId = appId }; - var @event = Envelope.Create(new ContentCreated()).SetTimestamp(now); + var @event = + Envelope.Create(new ContentCreated()) + .SetTimestamp(now); - A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event))) - .Returns(new List { enrichedEvent }); + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context)) + .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, rule.Trigger)) + A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, context)) .Returns(true); - A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent, rule.Action)) - .Returns((actionDescription, new ValidData { Value = 10 })); + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default)) + .Returns(new List { enrichedEvent }.ToAsyncEnumerable()); - var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); + A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action)) + .Returns((actionDescription, new ValidData { Value = 10 })); - var (job, _) = jobs.Single(); + var (job, _, _) = await sut.CreateJobsAsync(@event, context).SingleAsync(); - AssertJob(now, enrichedEvent, job); + AssertJob(now, enrichedEvent, job!); A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, MatchPayload(@event))) .MustHaveHappened(); @@ -418,31 +558,34 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules { var now = clock.GetCurrentInstant(); - var rule = ValidRule(); + var context = Rule(); var enrichedEvent = new EnrichedContentEvent { AppId = appId }; - var @event = Envelope.Create(new ContentCreated()).SetTimestamp(now); + var @event = + Envelope.Create(new ContentCreated()) + .SetTimestamp(now); - A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event))) - .Returns(new List { enrichedEvent }); + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context)) + .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, rule.Trigger)) + A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, context)) .Returns(true); - A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent, rule.Action)) - .Throws(new InvalidOperationException()); + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default)) + .Returns(new List { enrichedEvent }.ToAsyncEnumerable()); - var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); + A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action)) + .Throws(new InvalidOperationException()); - var (job, ex) = jobs.Single(); + var (job, ex, _) = await sut.CreateJobsAsync(@event, context).SingleAsync(); Assert.NotNull(ex); - Assert.NotNull(job.ActionData); - Assert.NotNull(job.Description); + Assert.NotNull(job?.ActionData); + Assert.NotNull(job?.Description); A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, MatchPayload(@event))) .MustHaveHappened(); @@ -453,35 +596,40 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules { var now = clock.GetCurrentInstant(); - var rule = ValidRule(); + var context = Rule(); var enrichedEvent1 = new EnrichedContentEvent { AppId = appId }; var enrichedEvent2 = new EnrichedContentEvent { AppId = appId }; - var @event = Envelope.Create(new ContentCreated()).SetTimestamp(now); + var @event = + Envelope.Create(new ContentCreated()) + .SetTimestamp(now); - A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) + A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event))) - .Returns(new List { enrichedEvent1, enrichedEvent2 }); + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context)) + .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent1, rule.Trigger)) + A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent1, context)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent2, rule.Trigger)) + A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent2, context)) .Returns(true); - A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent1, rule.Action)) + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default)) + .Returns(new List { enrichedEvent1, enrichedEvent2 }.ToAsyncEnumerable()); + + A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent1, context.Rule.Action)) .Returns((actionDescription, new ValidData { Value = 10 })); - A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent2, rule.Action)) + A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent2, context.Rule.Action)) .Returns((actionDescription, new ValidData { Value = 10 })); - var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); + var jobs = await sut.CreateJobsAsync(@event, context, default).ToListAsync(); - AssertJob(now, enrichedEvent1, jobs[0].Job); - AssertJob(now, enrichedEvent1, jobs[1].Job); + AssertJob(now, enrichedEvent1, jobs[0].Job!); + AssertJob(now, enrichedEvent1, jobs[1].Job!); A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent1, MatchPayload(@event))) .MustHaveHappened(); @@ -547,22 +695,45 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules Assert.Equal(ex, result.Result.Exception); } - private static Rule RuleInvalidAction() + private RuleContext RuleInvalidAction() { - return new Rule(new ContentChangedTriggerV2(), new InvalidAction()); + return new RuleContext + { + AppId = appId, + Rule = new Rule(new ContentChangedTriggerV2(), new InvalidAction()), + RuleId = ruleId + }; } - private static Rule RuleInvalidTrigger() + private RuleContext RuleInvalidTrigger() { - return new Rule(new InvalidTrigger(), new ValidAction()); + return new RuleContext + { + AppId = appId, + Rule = new Rule(new InvalidTrigger(), new ValidAction()), + RuleId = ruleId + }; } - private static Rule ValidRule() + private RuleContext Rule(bool disable = false, bool ignoreState = true) { - return new Rule(new ContentChangedTriggerV2(), new ValidAction()); + var rule = new Rule(new ContentChangedTriggerV2(), new ValidAction()); + + if (disable) + { + rule = rule.Disable(); + } + + return new RuleContext + { + AppId = appId, + Rule = rule, + RuleId = ruleId, + IgnoreStale = ignoreState + }; } - private static Envelope MatchPayload(Envelope @event) + private static Envelope MatchPayload(Envelope @event) { return A>.That.Matches(x => x.Payload == @event.Payload); } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj index c6da57e8c..e8cc7c00e 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj @@ -18,7 +18,6 @@ - diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs index 30aae1f54..09a154c9a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs @@ -11,6 +11,7 @@ using System.Linq; using System.Threading.Tasks; using FakeItEasy; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; @@ -29,7 +30,6 @@ namespace Squidex.Domain.Apps.Entities.Assets private readonly IScriptEngine scriptEngine = A.Fake(); private readonly IAssetLoader assetLoader = A.Fake(); private readonly IAssetRepository assetRepository = A.Fake(); - private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly IRuleTriggerHandler sut; public AssetChangedTriggerHandlerTests() @@ -51,93 +51,78 @@ namespace Squidex.Domain.Apps.Entities.Assets yield return new object[] { new AssetDeleted(), EnrichedAssetEventType.Deleted }; } + [Fact] + public void Should_return_true_if_asking_for_snapshot_support() + { + Assert.True(sut.CanCreateSnapshotEvents); + } + + [Fact] + public void Should_handle_asset_event() + { + Assert.True(sut.Handles(new AssetCreated())); + } + + [Fact] + public void Should_not_handle_asset_moved_event() + { + Assert.False(sut.Handles(new AssetMoved())); + } + + [Fact] + public void Should_not_handle_other_event() + { + Assert.False(sut.Handles(new ContentCreated())); + } + [Fact] public async Task Should_create_events_from_snapshots() { - var trigger = new AssetChangedTriggerV2(); + var ctx = Context(); - A.CallTo(() => assetRepository.StreamAll(appId.Id)) + A.CallTo(() => assetRepository.StreamAll(ctx.AppId.Id, default)) .Returns(new List { new AssetEntity(), new AssetEntity() }.ToAsyncEnumerable()); - var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync(); + var result = await sut.CreateSnapshotEventsAsync(ctx, default).ToListAsync(); var typed = result.OfType().ToList(); Assert.Equal(2, typed.Count); - Assert.Equal(2, typed.Count(x => x.Type == EnrichedAssetEventType.Created)); + Assert.Equal(2, typed.Count(x => x.Type == EnrichedAssetEventType.Created && x.Name == "AssetQueried")); } [Theory] [MemberData(nameof(TestEvents))] public async Task Should_create_enriched_events(AssetEvent @event, EnrichedAssetEventType type) { - @event.AppId = appId; + var ctx = Context(); + + @event.AppId = ctx.AppId; var envelope = Envelope.Create(@event).SetEventStreamNumber(12); - A.CallTo(() => assetLoader.GetAsync(appId.Id, @event.AssetId, 12)) + A.CallTo(() => assetLoader.GetAsync(ctx.AppId.Id, @event.AssetId, 12)) .Returns(new AssetEntity()); - var result = await sut.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync(); var enrichedEvent = result.Single() as EnrichedAssetEvent; Assert.Equal(type, enrichedEvent!.Type); } - [Fact] - public async Task Should_skip_moved_event() - { - var envelope = Envelope.Create(new AssetMoved()); - - var result = await sut.CreateEnrichedEventsAsync(envelope); - - Assert.Empty(result); - } - - [Fact] - public void Should_not_trigger_precheck_if_event_type_not_correct() - { - TestForCondition(string.Empty, trigger => - { - var result = sut.Trigger(new ContentCreated(), trigger, DomainId.NewGuid()); - - Assert.False(result); - }); - } - - [Fact] - public void Should_trigger_precheck_if_event_type_correct() - { - TestForCondition(string.Empty, trigger => - { - var result = sut.Trigger(new AssetCreated(), trigger, DomainId.NewGuid()); - - Assert.True(result); - }); - } - - [Fact] - public void Should_not_trigger_check_if_event_type_not_correct() - { - TestForCondition(string.Empty, trigger => - { - var result = sut.Trigger(new EnrichedContentEvent(), trigger); - - Assert.False(result); - }); - } - [Fact] public void Should_trigger_check_if_condition_is_empty() { - TestForCondition(string.Empty, trigger => + TestForCondition(string.Empty, ctx => { - var result = sut.Trigger(new EnrichedAssetEvent(), trigger); + var @event = new EnrichedAssetEvent(); + + var result = sut.Trigger(@event, ctx); Assert.True(result); }); @@ -146,9 +131,11 @@ namespace Squidex.Domain.Apps.Entities.Assets [Fact] public void Should_trigger_check_if_condition_matchs() { - TestForCondition("true", trigger => + TestForCondition("true", ctx => { - var result = sut.Trigger(new EnrichedAssetEvent(), trigger); + var @event = new EnrichedAssetEvent(); + + var result = sut.Trigger(@event, ctx); Assert.True(result); }); @@ -157,19 +144,24 @@ namespace Squidex.Domain.Apps.Entities.Assets [Fact] public void Should_not_trigger_check_if_condition_does_not_matchs() { - TestForCondition("false", trigger => + TestForCondition("false", ctx => { - var result = sut.Trigger(new EnrichedAssetEvent(), trigger); + var @event = new EnrichedAssetEvent(); + + var result = sut.Trigger(@event, ctx); Assert.False(result); }); } - private void TestForCondition(string condition, Action action) + private void TestForCondition(string condition, Action action) { - var trigger = new AssetChangedTriggerV2 { Condition = condition }; + var trigger = new AssetChangedTriggerV2 + { + Condition = condition + }; - action(trigger); + action(Context(trigger)); if (string.IsNullOrWhiteSpace(condition)) { @@ -182,5 +174,17 @@ namespace Squidex.Domain.Apps.Entities.Assets .MustHaveHappened(); } } + + private static RuleContext Context(RuleTrigger? trigger = null) + { + trigger ??= new AssetChangedTriggerV2(); + + return new RuleContext + { + AppId = NamedId.Of(DomainId.NewGuid(), "my-app"), + Rule = new Rule(trigger, A.Fake()), + RuleId = DomainId.NewGuid() + }; + } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RepairFilesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RepairFilesTests.cs index 8d972ea43..f4e9e1f14 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RepairFilesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RepairFilesTests.cs @@ -5,8 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using FakeItEasy; using Squidex.Assets; @@ -107,7 +108,14 @@ namespace Squidex.Domain.Apps.Entities.Assets private void SetupEvent(IEvent? @event) { - var storedEvent = new StoredEvent("stream", "0", -1, new EventData("type", new EnvelopeHeaders(), "payload")); + var storedEvent = + new StoredEvent("stream", "0", -1, + new EventData("type", new EnvelopeHeaders(), "payload")); + + var storedEvents = new List + { + storedEvent + }; if (@event != null) { @@ -120,13 +128,8 @@ namespace Squidex.Domain.Apps.Entities.Assets .Returns(null); } - A.CallTo(() => eventStore.QueryAsync(A>._, "^asset\\-", null, default)) - .Invokes(x => - { - var callback = x.GetArgument>(0)!; - - callback(storedEvent).Wait(); - }); + A.CallTo(() => eventStore.QueryAllAsync("^asset\\-", null, long.MaxValue, default)) + .Returns(storedEvents.ToAsyncEnumerable()); } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs index a52874ef0..585c9ae90 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs @@ -13,6 +13,7 @@ using FakeItEasy; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; @@ -50,21 +51,41 @@ namespace Squidex.Domain.Apps.Entities.Comments Assert.False(sut.CanCreateSnapshotEvents); } + [Fact] + public void Should_handle_comment_event() + { + Assert.True(sut.Handles(new CommentCreated())); + } + + [Fact] + public void Should_not_handle_comment_update_event() + { + Assert.False(sut.Handles(new CommentUpdated())); + } + + [Fact] + public void Should_not_handle_other_event() + { + Assert.False(sut.Handles(new ContentCreated())); + } + [Fact] public async Task Should_create_enriched_events() { + var ctx = Context(); + var user1 = UserMocks.User("1"); var user2 = UserMocks.User("2"); var users = new List { user1, user2 }; var userIds = users.Select(x => x.Id).ToArray(); - var envelope = Envelope.Create(new CommentCreated { Mentions = userIds }); + var @event = new CommentCreated { Mentions = userIds }; A.CallTo(() => userResolver.QueryManyAsync(userIds)) .Returns(users.ToDictionary(x => x.Id)); - var result = await sut.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(Envelope.Create(@event), ctx, default).ToListAsync(); Assert.Equal(2, result.Count); @@ -80,15 +101,17 @@ namespace Squidex.Domain.Apps.Entities.Comments [Fact] public async Task Should_not_create_enriched_events_if_users_cannot_be_resolved() { + var ctx = Context(); + var user1 = UserMocks.User("1"); var user2 = UserMocks.User("2"); var users = new List { user1, user2 }; var userIds = users.Select(x => x.Id).ToArray(); - var envelope = Envelope.Create(new CommentCreated { Mentions = userIds }); + var @event = new CommentCreated { Mentions = userIds }; - var result = await sut.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(Envelope.Create(@event), ctx, default).ToListAsync(); Assert.Empty(result); } @@ -96,22 +119,11 @@ namespace Squidex.Domain.Apps.Entities.Comments [Fact] public async Task Should_not_create_enriched_events_if_mentions_is_null() { - var envelope = Envelope.Create(new CommentCreated { Mentions = null }); - - var result = await sut.CreateEnrichedEventsAsync(envelope); + var ctx = Context(); - Assert.Empty(result); - - A.CallTo(() => userResolver.QueryManyAsync(A._)) - .MustNotHaveHappened(); - } + var @event = new CommentCreated { Mentions = null }; - [Fact] - public async Task Should_not_create_enriched_events_if_mentions_is_empty() - { - var envelope = Envelope.Create(new CommentCreated { Mentions = Array.Empty() }); - - var result = await sut.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(Envelope.Create(@event), ctx, default).ToListAsync(); Assert.Empty(result); @@ -120,24 +132,13 @@ namespace Squidex.Domain.Apps.Entities.Comments } [Fact] - public async Task Should_skip_udated_event() + public async Task Should_not_create_enriched_events_if_mentions_is_empty() { - var envelope = Envelope.Create(new CommentUpdated()); - - var result = await sut.CreateEnrichedEventsAsync(envelope); - - Assert.Empty(result); - - A.CallTo(() => userResolver.QueryManyAsync(A._)) - .MustNotHaveHappened(); - } + var ctx = Context(); - [Fact] - public async Task Should_skip_deleted_event() - { - var envelope = Envelope.Create(new CommentDeleted()); + var @event = new CommentCreated { Mentions = Array.Empty() }; - var result = await sut.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(Envelope.Create(@event), ctx, default).ToListAsync(); Assert.Empty(result); @@ -145,45 +146,27 @@ namespace Squidex.Domain.Apps.Entities.Comments .MustNotHaveHappened(); } - [Fact] - public void Should_not_trigger_precheck_if_event_type_not_correct() - { - TestForCondition(string.Empty, trigger => - { - var result = sut.Trigger(new ContentCreated(), trigger, DomainId.NewGuid()); - - Assert.False(result); - }); - } - [Fact] public void Should_trigger_precheck_if_event_type_correct() { - TestForCondition(string.Empty, trigger => + TestForCondition(string.Empty, ctx => { - var result = sut.Trigger(new CommentCreated(), trigger, DomainId.NewGuid()); - - Assert.True(result); - }); - } + var @event = new CommentCreated(); - [Fact] - public void Should_not_trigger_check_if_event_type_not_correct() - { - TestForCondition(string.Empty, trigger => - { - var result = sut.Trigger(new EnrichedContentEvent(), trigger); + var result = sut.Trigger(Envelope.Create(@event), ctx); - Assert.False(result); + Assert.True(result); }); } [Fact] public void Should_trigger_check_if_condition_is_empty() { - TestForCondition(string.Empty, trigger => + TestForCondition(string.Empty, ctx => { - var result = sut.Trigger(new EnrichedCommentEvent(), trigger); + var @event = new EnrichedCommentEvent(); + + var result = sut.Trigger(@event, ctx); Assert.True(result); }); @@ -192,20 +175,24 @@ namespace Squidex.Domain.Apps.Entities.Comments [Fact] public void Should_trigger_check_if_condition_matchs() { - TestForCondition("true", trigger => + TestForCondition("true", ctx => { - var result = sut.Trigger(new EnrichedCommentEvent(), trigger); + var @event = new EnrichedCommentEvent(); + + var result = sut.Trigger(new EnrichedCommentEvent(), ctx); Assert.True(result); }); } [Fact] - public void Should_not_trigger_check_if_condition_does_not_matchs() + public void Should_not_trigger_check_if_condition_does_not_match() { - TestForCondition("false", trigger => + TestForCondition("false", ctx => { - var result = sut.Trigger(new EnrichedCommentEvent(), trigger); + var @event = new EnrichedCommentEvent(); + + var result = sut.Trigger(@event, ctx); Assert.False(result); }); @@ -214,24 +201,30 @@ namespace Squidex.Domain.Apps.Entities.Comments [Fact] public void Should_trigger_check_if_email_is_correct() { - TestForRealCondition("event.mentionedUser.email == '1@email.com'", (handler, trigger) => + TestForRealCondition("event.mentionedUser.email == '1@email.com'", (handler, ctx) => { - var user = UserMocks.User("1", "1@email.com"); + var @event = new EnrichedCommentEvent + { + MentionedUser = UserMocks.User("1", "1@email.com") + }; - var result = handler.Trigger(new EnrichedCommentEvent { MentionedUser = user }, trigger); + var result = handler.Trigger(@event, ctx); Assert.True(result); }); } [Fact] - public void Should_not_trigger_check_if_email_is_correct() + public void Should_not_trigger_check_if_email_is_not_correct() { - TestForRealCondition("event.mentionedUser.email == 'other@squidex.io'", (handler, trigger) => + TestForRealCondition("event.mentionedUser.email == 'other@squidex.io'", (handler, ctx) => { - var user = UserMocks.User("1"); + var @event = new EnrichedCommentEvent + { + MentionedUser = UserMocks.User("1", "1@email.com") + }; - var result = handler.Trigger(new EnrichedCommentEvent { MentionedUser = user }, trigger); + var result = handler.Trigger(@event, ctx); Assert.False(result); }); @@ -240,11 +233,14 @@ namespace Squidex.Domain.Apps.Entities.Comments [Fact] public void Should_trigger_check_if_text_is_urgent() { - TestForRealCondition("event.text.indexOf('urgent') >= 0", (handler, trigger) => + TestForRealCondition("event.text.indexOf('urgent') >= 0", (handler, ctx) => { - var text = "Hey man, this is really urgent."; + var @event = new EnrichedCommentEvent + { + Text = "very_urgent_text" + }; - var result = handler.Trigger(new EnrichedCommentEvent { Text = text }, trigger); + var result = handler.Trigger(@event, ctx); Assert.True(result); }); @@ -253,17 +249,20 @@ namespace Squidex.Domain.Apps.Entities.Comments [Fact] public void Should_not_trigger_check_if_text_is_not_urgent() { - TestForRealCondition("event.text.indexOf('urgent') >= 0", (handler, trigger) => + TestForRealCondition("event.text.indexOf('urgent') >= 0", (handler, ctx) => { - var text = "Hey man, just an information for you."; + var @event = new EnrichedCommentEvent + { + Text = "just_gossip" + }; - var result = handler.Trigger(new EnrichedCommentEvent { Text = text }, trigger); + var result = handler.Trigger(@event, ctx); Assert.False(result); }); } - private void TestForRealCondition(string condition, Action action) + private void TestForRealCondition(string condition, Action action) { var trigger = new CommentTrigger { @@ -274,17 +273,17 @@ namespace Squidex.Domain.Apps.Entities.Comments var handler = new CommentTriggerHandler(new JintScriptEngine(memoryCache), userResolver); - action(handler, trigger); + action(handler, Context(trigger)); } - private void TestForCondition(string condition, Action action) + private void TestForCondition(string condition, Action action) { var trigger = new CommentTrigger { Condition = condition }; - action(trigger); + action(Context(trigger)); if (string.IsNullOrWhiteSpace(condition)) { @@ -297,5 +296,17 @@ namespace Squidex.Domain.Apps.Entities.Comments .MustHaveHappened(); } } + + private static RuleContext Context(RuleTrigger? trigger = null) + { + trigger ??= new CommentTrigger(); + + return new RuleContext + { + AppId = NamedId.Of(DomainId.NewGuid(), "my-app"), + Rule = new Rule(trigger, A.Fake()), + RuleId = DomainId.NewGuid() + }; + } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs index a3c7c4ccf..637d184b9 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs @@ -13,6 +13,7 @@ using System.Threading.Tasks; using FakeItEasy; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; @@ -32,10 +33,8 @@ namespace Squidex.Domain.Apps.Entities.Contents private readonly IScriptEngine scriptEngine = A.Fake(); private readonly IContentLoader contentLoader = A.Fake(); private readonly IContentRepository contentRepository = A.Fake(); - private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly NamedId schemaMatch = NamedId.Of(DomainId.NewGuid(), "my-schema1"); private readonly NamedId schemaNonMatch = NamedId.Of(DomainId.NewGuid(), "my-schema2"); - private readonly DomainId ruleId = DomainId.NewGuid(); private readonly IRuleTriggerHandler sut; public ContentChangedTriggerHandlerTests() @@ -65,24 +64,47 @@ namespace Squidex.Domain.Apps.Entities.Contents Assert.True(sut.CanCreateSnapshotEvents); } + [Fact] + public void Should_handle_content_event() + { + Assert.True(sut.Handles(new ContentCreated())); + } + + [Fact] + public void Should_not_handle_other_event() + { + Assert.False(sut.Handles(new AssetMoved())); + } + + [Fact] + public void Should_calculate_name() + { + var @event = new ContentCreated { SchemaId = schemaMatch }; + + Assert.Equal("ContentCreated(MySchema1)", sut.GetName(@event)); + } + [Fact] public async Task Should_create_events_from_snapshots() { - var trigger = new ContentChangedTriggerV2(); + var ctx = Context(); - A.CallTo(() => contentRepository.StreamAll(appId.Id, null)) + A.CallTo(() => contentRepository.StreamAll(ctx.AppId.Id, null, default)) .Returns(new List { new ContentEntity { SchemaId = schemaMatch }, - new ContentEntity { SchemaId = schemaMatch } + new ContentEntity { SchemaId = schemaNonMatch } }.ToAsyncEnumerable()); - var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync(); + var result = await sut.CreateSnapshotEventsAsync(ctx, default).ToListAsync(); var typed = result.OfType().ToList(); Assert.Equal(2, typed.Count); Assert.Equal(2, typed.Count(x => x.Type == EnrichedContentEventType.Created)); + + Assert.Equal("ContentQueried(MySchema1)", typed[0].Name); + Assert.Equal("ContentQueried(MySchema2)", typed[1].Name); } [Fact] @@ -99,14 +121,16 @@ namespace Squidex.Domain.Apps.Entities.Contents }) }; - A.CallTo(() => contentRepository.StreamAll(appId.Id, A>.That.Is(schemaMatch.Id))) + var ctx = Context(trigger); + + A.CallTo(() => contentRepository.StreamAll(ctx.AppId.Id, A>.That.Is(schemaMatch.Id), default)) .Returns(new List { new ContentEntity { SchemaId = schemaMatch }, new ContentEntity { SchemaId = schemaMatch } }.ToAsyncEnumerable()); - var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync(); + var result = await sut.CreateSnapshotEventsAsync(ctx, default).ToListAsync(); var typed = result.OfType().ToList(); @@ -118,15 +142,17 @@ namespace Squidex.Domain.Apps.Entities.Contents [MemberData(nameof(TestEvents))] public async Task Should_create_enriched_events(ContentEvent @event, EnrichedContentEventType type) { - @event.AppId = appId; + var ctx = Context(); + + @event.AppId = ctx.AppId; @event.SchemaId = schemaMatch; var envelope = Envelope.Create(@event).SetEventStreamNumber(12); - A.CallTo(() => contentLoader.GetAsync(appId.Id, @event.ContentId, 12)) - .Returns(new ContentEntity { AppId = appId, SchemaId = schemaMatch }); + A.CallTo(() => contentLoader.GetAsync(ctx.AppId.Id, @event.ContentId, 12)) + .Returns(new ContentEntity { AppId = ctx.AppId, SchemaId = schemaMatch }); - var result = await sut.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync(); var enrichedEvent = result.Single() as EnrichedContentEvent; @@ -136,20 +162,22 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public async Task Should_enrich_with_old_data_if_updated() { - var @event = new ContentUpdated { AppId = appId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatch }; + var ctx = Context(); + + var @event = new ContentUpdated { AppId = ctx.AppId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatch }; var envelope = Envelope.Create(@event).SetEventStreamNumber(12); var dataNow = new ContentData(); var dataOld = new ContentData(); - A.CallTo(() => contentLoader.GetAsync(appId.Id, @event.ContentId, 12)) - .Returns(new ContentEntity { AppId = appId, SchemaId = schemaMatch, Version = 12, Data = dataNow, Id = @event.ContentId }); + A.CallTo(() => contentLoader.GetAsync(ctx.AppId.Id, @event.ContentId, 12)) + .Returns(new ContentEntity { AppId = ctx.AppId, SchemaId = schemaMatch, Version = 12, Data = dataNow, Id = @event.ContentId }); - A.CallTo(() => contentLoader.GetAsync(appId.Id, @event.ContentId, 11)) - .Returns(new ContentEntity { AppId = appId, SchemaId = schemaMatch, Version = 11, Data = dataOld }); + A.CallTo(() => contentLoader.GetAsync(ctx.AppId.Id, @event.ContentId, 11)) + .Returns(new ContentEntity { AppId = ctx.AppId, SchemaId = schemaMatch, Version = 11, Data = dataOld }); - var result = await sut.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync(); var enrichedEvent = result.Single() as EnrichedContentEvent; @@ -157,23 +185,14 @@ namespace Squidex.Domain.Apps.Entities.Contents Assert.Same(dataOld, enrichedEvent!.DataOld); } - [Fact] - public void Should_not_trigger_precheck_if_event_type_not_correct() - { - TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger => - { - var result = sut.Trigger(new AssetCreated(), trigger, ruleId); - - Assert.False(result); - }); - } - [Fact] public void Should_not_trigger_precheck_if_trigger_contains_no_schemas() { - TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger => + TestForTrigger(handleAll: false, schemaId: null, condition: null, action: ctx => { - var result = sut.Trigger(new ContentCreated { SchemaId = schemaMatch }, trigger, ruleId); + var @event = new ContentCreated { SchemaId = schemaMatch }; + + var result = sut.Trigger(Envelope.Create(@event), ctx); Assert.False(result); }); @@ -182,9 +201,11 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public void Should_trigger_precheck_if_handling_all_events() { - TestForTrigger(handleAll: true, schemaId: schemaMatch, condition: null, action: trigger => + TestForTrigger(handleAll: true, schemaId: schemaMatch, condition: null, action: ctx => { - var result = sut.Trigger(new ContentCreated { SchemaId = schemaMatch }, trigger, ruleId); + var @event = new ContentCreated { SchemaId = schemaMatch }; + + var result = sut.Trigger(Envelope.Create(@event), ctx); Assert.True(result); }); @@ -193,9 +214,11 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public void Should_trigger_precheck_if_condition_is_empty() { - TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: string.Empty, action: trigger => + TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: string.Empty, action: ctx => { - var result = sut.Trigger(new ContentCreated { SchemaId = schemaMatch }, trigger, ruleId); + var @event = new ContentCreated { SchemaId = schemaMatch }; + + var result = sut.Trigger(Envelope.Create(@event), ctx); Assert.True(result); }); @@ -204,20 +227,11 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public void Should_not_trigger_precheck_if_schema_id_does_not_match() { - TestForTrigger(handleAll: false, schemaId: schemaNonMatch, condition: null, action: trigger => + TestForTrigger(handleAll: false, schemaId: schemaNonMatch, condition: null, action: ctx => { - var result = sut.Trigger(new ContentCreated { SchemaId = schemaMatch }, trigger, ruleId); - - Assert.False(result); - }); - } + var @event = new ContentCreated { SchemaId = schemaMatch }; - [Fact] - public void Should_not_trigger_check_if_event_type_not_correct() - { - TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger => - { - var result = sut.Trigger(new EnrichedAssetEvent(), trigger); + var result = sut.Trigger(Envelope.Create(@event), ctx); Assert.False(result); }); @@ -226,9 +240,11 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public void Should_not_trigger_check_if_trigger_contains_no_schemas() { - TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger => + TestForTrigger(handleAll: false, schemaId: null, condition: null, action: ctx => { - var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); + var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + + var result = sut.Trigger(@event, ctx); Assert.False(result); }); @@ -237,9 +253,11 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public void Should_trigger_check_if_handling_all_events() { - TestForTrigger(handleAll: true, schemaId: schemaMatch, condition: null, action: trigger => + TestForTrigger(handleAll: true, schemaId: schemaMatch, condition: null, action: ctx => { - var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); + var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + + var result = sut.Trigger(@event, ctx); Assert.True(result); }); @@ -248,9 +266,11 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public void Should_trigger_check_if_condition_is_empty() { - TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: string.Empty, action: trigger => + TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: string.Empty, action: ctx => { - var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); + var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + + var result = sut.Trigger(@event, ctx); Assert.True(result); }); @@ -259,9 +279,11 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public void Should_trigger_check_if_condition_matchs() { - TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: "true", action: trigger => + TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: "true", action: ctx => { - var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); + var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + + var result = sut.Trigger(@event, ctx); Assert.True(result); }); @@ -270,26 +292,30 @@ namespace Squidex.Domain.Apps.Entities.Contents [Fact] public void Should_not_trigger_check_if_schema_id_does_not_match() { - TestForTrigger(handleAll: false, schemaId: schemaNonMatch, condition: null, action: trigger => + TestForTrigger(handleAll: false, schemaId: schemaNonMatch, condition: null, action: ctx => { - var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); + var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + + var result = sut.Trigger(@event, ctx); Assert.False(result); }); } [Fact] - public void Should_not_trigger_check_if_condition_does_not_matchs() + public void Should_not_trigger_check_if_condition_does_not_match() { - TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: "false", action: trigger => + TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: "false", action: ctx => { - var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); + var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + + var result = sut.Trigger(@event, ctx); Assert.False(result); }); } - private void TestForTrigger(bool handleAll, NamedId? schemaId, string? condition, Action action) + private void TestForTrigger(bool handleAll, NamedId? schemaId, string? condition, Action action) { var trigger = new ContentChangedTriggerV2 { HandleAll = handleAll }; @@ -304,7 +330,7 @@ namespace Squidex.Domain.Apps.Entities.Contents }); } - action(trigger); + action(Context(trigger)); if (string.IsNullOrWhiteSpace(condition)) { @@ -317,5 +343,17 @@ namespace Squidex.Domain.Apps.Entities.Contents .MustHaveHappened(); } } + + private static RuleContext Context(RuleTrigger? trigger = null) + { + trigger ??= new ContentChangedTriggerV2(); + + return new RuleContext + { + AppId = NamedId.Of(DomainId.NewGuid(), "my-app"), + Rule = new Rule(trigger, A.Fake()), + RuleId = DomainId.NewGuid() + }; + } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs index cb5822a2b..96425fbd0 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs @@ -9,7 +9,6 @@ using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; -using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Rules; using Squidex.Infrastructure; @@ -28,14 +27,22 @@ namespace Squidex.Domain.Apps.Entities.Rules Assert.False(sut.CanCreateSnapshotEvents); } + [Fact] + public void Should_calculate_name() + { + var @event = new RuleManuallyTriggered(); + + Assert.Equal("Manual", sut.GetName(@event)); + } + [Fact] public async Task Should_create_event_with_name() { - var envelope = Envelope.Create(new RuleManuallyTriggered()); + var @event = new RuleManuallyTriggered(); - var result = await sut.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(Envelope.Create(@event), default, default).ToListAsync(); - Assert.Equal("Manual", result.Single().Name); + Assert.NotEmpty(result); } [Fact] @@ -43,9 +50,9 @@ namespace Squidex.Domain.Apps.Entities.Rules { var actor = RefToken.User("me"); - var envelope = Envelope.Create(new RuleManuallyTriggered { Actor = actor }); + var @event = new RuleManuallyTriggered { Actor = actor }; - var result = await sut.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(Envelope.Create(@event), default, default).ToListAsync(); Assert.Equal(actor, ((EnrichedUserEventBase)result.Single()).Actor); } @@ -53,7 +60,17 @@ namespace Squidex.Domain.Apps.Entities.Rules [Fact] public void Should_always_trigger() { - Assert.True(sut.Trigger(new EnrichedManualEvent(), new ManualTrigger())); + var @event = new RuleManuallyTriggered(); + + Assert.True(sut.Trigger(Envelope.Create(@event), default)); + } + + [Fact] + public void Should_always_trigger_enriched_event() + { + var @event = new EnrichedUsageExceededEvent(); + + Assert.True(sut.Trigger(@event, default)); } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs index dbbc00408..9ed33d93a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using FakeItEasy; using Microsoft.Extensions.Caching.Memory; @@ -74,6 +75,27 @@ namespace Squidex.Domain.Apps.Entities.Rules Assert.Equal(nameof(RuleEnqueuer), consumer.Name); } + [Fact] + public async Task Should_not_insert_job_if_null() + { + var @event = Envelope.Create(new ContentCreated { AppId = appId }); + + var rule = CreateRule(); + + var job = new RuleJob + { + Created = now + }; + + A.CallTo(() => ruleService.CreateJobsAsync(@event, A.That.Matches(x => x.Rule == rule.RuleDef), default)) + .Returns(new List { new JobResult(null) }.ToAsyncEnumerable()); + + await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event); + + A.CallTo(() => ruleEventRepository.EnqueueAsync(A._, (Exception?)null)) + .MustNotHaveHappened(); + } + [Fact] public async Task Should_update_repository_if_enqueing() { @@ -81,10 +103,13 @@ namespace Squidex.Domain.Apps.Entities.Rules var rule = CreateRule(); - var job = new RuleJob { Created = now }; + var job = new RuleJob + { + Created = now + }; - A.CallTo(() => ruleService.CreateJobsAsync(rule.RuleDef, rule.Id, @event, true)) - .Returns(new List<(RuleJob, Exception?)> { (job, null) }); + A.CallTo(() => ruleService.CreateJobsAsync(@event, A.That.Matches(x => x.Rule == rule.RuleDef), default)) + .Returns(new List { new JobResult(job) }.ToAsyncEnumerable()); await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event); @@ -93,11 +118,14 @@ namespace Squidex.Domain.Apps.Entities.Rules } [Fact] - public async Task Should_update_repositories_with_jobs_from_service() + public async Task Should_update_repository_with_jobs_from_service() { var @event = Envelope.Create(new ContentCreated { AppId = appId }); - var job1 = new RuleJob { Created = now }; + var job1 = new RuleJob + { + Created = now + }; SetupRules(@event, job1); @@ -130,11 +158,11 @@ namespace Squidex.Domain.Apps.Entities.Rules A.CallTo(() => appProvider.GetRulesAsync(appId.Id)) .Returns(new List { rule1, rule2 }); - A.CallTo(() => ruleService.CreateJobsAsync(rule1.RuleDef, rule1.Id, @event, true)) - .Returns(new List<(RuleJob, Exception?)> { (job1, null) }); + A.CallTo(() => ruleService.CreateJobsAsync(@event, A.That.Matches(x => x.Rule == rule1.RuleDef), default)) + .Returns(new List { new JobResult(job1) }.ToAsyncEnumerable()); - A.CallTo(() => ruleService.CreateJobsAsync(rule2.RuleDef, rule2.Id, @event, true)) - .Returns(new List<(RuleJob, Exception?)>()); + A.CallTo(() => ruleService.CreateJobsAsync(@event, A.That.Matches(x => x.Rule == rule2.RuleDef), default)) + .Returns(new List().ToAsyncEnumerable()); } private static RuleEntity CreateRule() diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs index 615d51420..d3096f39d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs @@ -7,7 +7,9 @@ using System.Linq; using System.Threading.Tasks; +using FakeItEasy; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Events; @@ -20,7 +22,6 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking { public class UsageTriggerHandlerTests { - private readonly DomainId ruleId = DomainId.NewGuid(); private readonly IRuleTriggerHandler sut = new UsageTriggerHandler(); [Fact] @@ -30,48 +31,66 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking } [Fact] - public void Should_not_trigger_precheck_if_event_type_not_correct() + public void Should_handle_usage_event() { - var result = sut.Trigger(new ContentCreated(), new UsageTrigger(), ruleId); - - Assert.False(result); + Assert.True(sut.Handles(new AppUsageExceeded())); } [Fact] - public void Should_not_trigger_precheck_if_rule_id_not_matchs() + public void Should_not_handle_other_event() { - var result = sut.Trigger(new AppUsageExceeded { RuleId = DomainId.NewGuid() }, new UsageTrigger(), ruleId); - - Assert.True(result); + Assert.False(sut.Handles(new ContentCreated())); } [Fact] - public void Should_trigger_precheck_if_event_type_correct_and_rule_id_matchs() + public async Task Should_create_enriched_event() { - var result = sut.Trigger(new AppUsageExceeded { RuleId = ruleId }, new UsageTrigger(), ruleId); + var ctx = Context(); - Assert.True(result); + var @event = new AppUsageExceeded { CallsCurrent = 80, CallsLimit = 120 }; + + var result = await sut.CreateEnrichedEventsAsync(Envelope.Create(@event), ctx, default).ToListAsync(); + + var enrichedEvent = result.Single() as EnrichedUsageExceededEvent; + + Assert.Equal(@event.CallsCurrent, enrichedEvent!.CallsCurrent); + Assert.Equal(@event.CallsLimit, enrichedEvent!.CallsLimit); } [Fact] - public void Should_not_trigger_check_if_event_type_not_correct() + public void Should_not_trigger_precheck_if_rule_id_not_matchs() { - var result = sut.Trigger(new EnrichedContentEvent(), new UsageTrigger()); + var ctx = Context(); - Assert.False(result); + var @event = new AppUsageExceeded(); + + var result = sut.Trigger(Envelope.Create(@event), ctx); + + Assert.True(result); } [Fact] - public async Task Should_create_enriched_event() + public void Should_trigger_precheck_if_event_type_correct_and_rule_id_matchs() { - var @event = new AppUsageExceeded { CallsCurrent = 80, CallsLimit = 120 }; + var ctx = Context(); - var result = await sut.CreateEnrichedEventsAsync(Envelope.Create(@event)); + var @event = new AppUsageExceeded { RuleId = ctx.RuleId }; - var enrichedEvent = result.Single() as EnrichedUsageExceededEvent; + var result = sut.Trigger(Envelope.Create(@event), ctx); - Assert.Equal(@event.CallsCurrent, enrichedEvent!.CallsCurrent); - Assert.Equal(@event.CallsLimit, enrichedEvent!.CallsLimit); + Assert.True(result); + } + + private static RuleContext Context(RuleTrigger? trigger = null) + { + trigger ??= new UsageTrigger(); + + return new RuleContext + { + AppId = NamedId.Of(DomainId.NewGuid(), "my-app"), + Rule = new Rule(trigger, A.Fake()), + RuleId = DomainId.NewGuid() + }; } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs index 9ee9b24c7..abc7a096a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs @@ -11,6 +11,7 @@ using System.Linq; using System.Threading.Tasks; using FakeItEasy; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; @@ -54,58 +55,54 @@ namespace Squidex.Domain.Apps.Entities.Schemas Assert.False(sut.CanCreateSnapshotEvents); } + [Fact] + public void Should_handle_schema_event() + { + Assert.True(sut.Handles(new SchemaCreated())); + } + + [Fact] + public void Should_not_handle_other_event() + { + Assert.False(sut.Handles(new AppCreated())); + } + [Theory] [MemberData(nameof(TestEvents))] public async Task Should_create_enriched_events(SchemaEvent @event, EnrichedSchemaEventType type) { + var ctx = Context(); + var envelope = Envelope.Create(@event).SetEventStreamNumber(12); - var result = await sut.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync(); var enrichedEvent = result.Single() as EnrichedSchemaEvent; Assert.Equal(type, enrichedEvent!.Type); } - [Fact] - public void Should_not_trigger_precheck_if_event_type_not_correct() - { - TestForCondition(string.Empty, trigger => - { - var result = sut.Trigger(new AppCreated(), trigger, DomainId.NewGuid()); - - Assert.False(result); - }); - } - [Fact] public void Should_trigger_precheck_if_event_type_correct() { - TestForCondition(string.Empty, trigger => + TestForCondition(string.Empty, ctx => { - var result = sut.Trigger(new SchemaCreated(), trigger, DomainId.NewGuid()); + var @event = new SchemaCreated(); - Assert.True(result); - }); - } + var result = sut.Trigger(Envelope.Create(@event), ctx); - [Fact] - public void Should_not_trigger_check_if_event_type_not_correct() - { - TestForCondition(string.Empty, trigger => - { - var result = sut.Trigger(new EnrichedContentEvent(), trigger); - - Assert.False(result); + Assert.True(result); }); } [Fact] public void Should_trigger_check_if_condition_is_empty() { - TestForCondition(string.Empty, trigger => + TestForCondition(string.Empty, ctx => { - var result = sut.Trigger(new EnrichedSchemaEvent(), trigger); + var @event = new EnrichedSchemaEvent(); + + var result = sut.Trigger(@event, ctx); Assert.True(result); }); @@ -114,30 +111,37 @@ namespace Squidex.Domain.Apps.Entities.Schemas [Fact] public void Should_trigger_check_if_condition_matchs() { - TestForCondition("true", trigger => + TestForCondition("true", ctx => { - var result = sut.Trigger(new EnrichedSchemaEvent(), trigger); + var @event = new EnrichedSchemaEvent(); + + var result = sut.Trigger(@event, ctx); Assert.True(result); }); } [Fact] - public void Should_not_trigger_check_if_condition_does_not_matchs() + public void Should_not_trigger_check_if_condition_does_not_match() { - TestForCondition("false", trigger => + TestForCondition("false", ctx => { - var result = sut.Trigger(new EnrichedSchemaEvent(), trigger); + var @event = new EnrichedSchemaEvent(); + + var result = sut.Trigger(@event, ctx); Assert.False(result); }); } - private void TestForCondition(string condition, Action action) + private void TestForCondition(string condition, Action action) { - var trigger = new SchemaChangedTrigger { Condition = condition }; + var trigger = new SchemaChangedTrigger + { + Condition = condition + }; - action(trigger); + action(Context(trigger)); if (string.IsNullOrWhiteSpace(condition)) { @@ -150,5 +154,17 @@ namespace Squidex.Domain.Apps.Entities.Schemas .MustHaveHappened(); } } + + private static RuleContext Context(RuleTrigger? trigger = null) + { + trigger ??= new SchemaChangedTrigger(); + + return new RuleContext + { + AppId = NamedId.Of(DomainId.NewGuid(), "my-app"), + Rule = new Rule(trigger, A.Fake()), + RuleId = DomainId.NewGuid() + }; + } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj index 0b063ea89..cf4ab61d4 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj @@ -27,7 +27,6 @@ - diff --git a/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj b/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj index 860a1e021..ff20815dd 100644 --- a/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj +++ b/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj @@ -16,7 +16,6 @@ - diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreFixture.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreFixture.cs deleted file mode 100644 index a1f3fe641..000000000 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreFixture.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Microsoft.Azure.Documents.Client; -using Squidex.Infrastructure.TestHelpers; - -namespace Squidex.Infrastructure.EventSourcing -{ - public sealed class CosmosDbEventStoreFixture : IDisposable - { - private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; - private const string EmulatorUri = "https://localhost:8081"; - private readonly DocumentClient client; - - public CosmosDbEventStore EventStore { get; } - - public CosmosDbEventStoreFixture() - { - client = new DocumentClient(new Uri(EmulatorUri), EmulatorKey, TestUtils.DefaultSettings()); - - EventStore = new CosmosDbEventStore(client, EmulatorKey, "Test", TestUtils.DefaultSerializer); - EventStore.InitializeAsync().Wait(); - } - - public void Dispose() - { - client.DeleteDatabaseAsync(UriFactory.CreateDatabaseUri("Test")).Wait(); - client.Dispose(); - } - } -} diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreTests.cs deleted file mode 100644 index 0ad7353f1..000000000 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Xunit; - -#pragma warning disable SA1300 // Element should begin with upper-case letter - -namespace Squidex.Infrastructure.EventSourcing -{ - [Trait("Category", "Dependencies")] - public class CosmosDbEventStoreTests : EventStoreTests, IClassFixture - { - public CosmosDbEventStoreFixture _ { get; } - - protected override int SubscriptionDelayInMs { get; } = 1000; - - public CosmosDbEventStoreTests(CosmosDbEventStoreFixture fixture) - { - _ = fixture; - } - - public override CosmosDbEventStore CreateStore() - { - return _.EventStore; - } - } -} diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs index 433a832d7..4da7d99d7 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs @@ -99,7 +99,7 @@ namespace Squidex.Infrastructure.EventSourcing await Sut.AppendAsync(Guid.NewGuid(), streamName, events); var readEvents1 = await QueryAsync(streamName); - var readEvents2 = await QueryWithCallbackAsync(streamName); + var readEvents2 = await QueryAllAsync(streamName); var expected = new[] { @@ -128,7 +128,7 @@ namespace Squidex.Infrastructure.EventSourcing }); var readEvents1 = await QueryAsync(streamName); - var readEvents2 = await QueryWithCallbackAsync(streamName); + var readEvents2 = await QueryAllAsync(streamName); var expected = new[] { @@ -176,6 +176,7 @@ namespace Squidex.Infrastructure.EventSourcing CreateEventData(2) }; + // Append and read in parallel. await QueryWithSubscriptionAsync(streamName, async () => { await Sut.AppendAsync(Guid.NewGuid(), streamName, events1); @@ -187,6 +188,7 @@ namespace Squidex.Infrastructure.EventSourcing CreateEventData(2) }; + // Append and read in parallel. var readEventsFromPosition = await QueryWithSubscriptionAsync(streamName, async () => { await Sut.AppendAsync(Guid.NewGuid(), streamName, events2); @@ -228,7 +230,7 @@ namespace Squidex.Infrastructure.EventSourcing var firstRead = await QueryAsync(streamName); var readEvents1 = await QueryAsync(streamName, 1); - var readEvents2 = await QueryWithCallbackAsync(streamName, firstRead[0].EventPosition); + var readEvents2 = await QueryAllAsync(streamName, firstRead[0].EventPosition); var expected = new[] { @@ -279,9 +281,9 @@ namespace Squidex.Infrastructure.EventSourcing } [Theory] - [InlineData(30)] - [InlineData(1000)] - public async Task Should_read_latest_events(int count) + [InlineData(5, 30)] + [InlineData(5, 300)] + public async Task Should_read_latest_events(int commitSize, int count) { var streamName = $"test-{Guid.NewGuid()}"; @@ -292,29 +294,60 @@ namespace Squidex.Infrastructure.EventSourcing events.Add(CreateEventData(i)); } - for (var i = 0; i < events.Count / 2; i++) + for (var i = 0; i < events.Count / commitSize; i++) { - var commit = events.Skip(i * 2).Take(2); + var commit = events.Skip(i * commitSize).Take(commitSize); await Sut.AppendAsync(Guid.NewGuid(), streamName, commit.ToArray()); } - var offset = 25; + var allExpected = events.Select((x, i) => new StoredEvent(streamName, "Position", i, events[i])).ToArray(); - var take = count - offset; + var takeStep = count / 10; - var expected1 = events - .Skip(offset) - .Select((x, i) => new StoredEvent(streamName, "Position", i + offset, events[i + offset])) - .ToArray(); + for (var take = 0; take < count; take += takeStep) + { + var expected = allExpected.TakeLast(take).ToArray(); + + var readEvents = await Sut.QueryLatestAsync(streamName, take); + + ShouldBeEquivalentTo(readEvents, expected); + } + } + + [Theory] + [InlineData(5, 30)] + [InlineData(5, 300)] + public async Task Should_read_reverse(int commitSize, int count) + { + var streamName = $"test-{Guid.NewGuid()}"; + + var events = new List(); + + for (var i = 0; i < count; i++) + { + events.Add(CreateEventData(i)); + } + + for (var i = 0; i < events.Count / commitSize; i++) + { + var commit = events.Skip(i * commitSize).Take(commitSize); + + await Sut.AppendAsync(Guid.NewGuid(), streamName, commit.ToArray()); + } + + var allExpected = events.Select((x, i) => new StoredEvent(streamName, "Position", i, events[i])).ToArray(); - var expected2 = Array.Empty(); + var takeStep = count / 10; + + for (var take = 0; take < count; take += takeStep) + { + var expected = allExpected.Reverse().Take(take).ToArray(); - var readEvents1 = await Sut.QueryLatestAsync(streamName, take); - var readEvents2 = await Sut.QueryLatestAsync(streamName, 0); + var readEvents = await Sut.QueryAllReverseAsync(streamName, null, take).ToArrayAsync(); - ShouldBeEquivalentTo(readEvents1, expected1); - ShouldBeEquivalentTo(readEvents2, expected2); + ShouldBeEquivalentTo(readEvents, expected); + } } [Fact] @@ -347,36 +380,19 @@ namespace Squidex.Infrastructure.EventSourcing return new EventData($"Type{i}", new EnvelopeHeaders(), i.ToString()); } - private async Task?> QueryWithCallbackAsync(string? streamFilter = null, string? position = null) + private async Task?> QueryAllAsync(string? streamFilter = null, string? position = null) { - using (var cts = new CancellationTokenSource(30000)) - { - while (!cts.IsCancellationRequested) - { - var readEvents = new List(); - - await Sut.QueryAsync(x => - { - readEvents.Add(x); + var readEvents = new List(); - return Task.CompletedTask; - }, streamFilter, position, cts.Token); - - await Task.Delay(500, cts.Token); - - if (readEvents.Count > 0) - { - return readEvents; - } - } - - cts.Token.ThrowIfCancellationRequested(); - - return null; + await foreach (var storedEvent in Sut.QueryAllAsync(streamFilter, position)) + { + readEvents.Add(storedEvent); } + + return readEvents; } - private async Task?> QueryWithSubscriptionAsync(string streamFilter, Func? action = null, bool fromBeginning = false) + private async Task?> QueryWithSubscriptionAsync(string streamFilter, Func? subscriptionRunning = null, bool fromBeginning = false) { var subscriber = new EventSubscriber(); @@ -385,9 +401,9 @@ namespace Squidex.Infrastructure.EventSourcing { subscription = Sut.CreateSubscription(subscriber, streamFilter, fromBeginning ? null : subscriptionPosition); - if (action != null) + if (subscriptionRunning != null) { - await action(); + await subscriptionRunning(); } using (var cts = new CancellationTokenSource(30000)) @@ -396,7 +412,7 @@ namespace Squidex.Infrastructure.EventSourcing { subscription.WakeUp(); - await Task.Delay(500, cts.Token); + await Task.Delay(2000, cts.Token); if (subscriber.Events.Count > 0) { @@ -419,7 +435,7 @@ namespace Squidex.Infrastructure.EventSourcing private static void ShouldBeEquivalentTo(IEnumerable? actual, params StoredEvent[] expected) { - actual.Should().BeEquivalentTo(expected, opts => opts.Excluding(x => x.EventPosition)); + actual.Should().BeEquivalentTo(expected, opts => opts.ComparingByMembers().Including(x => x.EventStreamNumber)); } } } diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreFixture.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreFixture.cs deleted file mode 100644 index 1ebbf01b5..000000000 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreFixture.cs +++ /dev/null @@ -1,64 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Threading.Tasks; -using EventStore.ClientAPI; -using EventStore.ClientAPI.Projections; -using Squidex.Infrastructure.TestHelpers; - -namespace Squidex.Infrastructure.EventSourcing -{ - public sealed class GetEventStoreFixture : IDisposable - { - private readonly IEventStoreConnection connection; - - public GetEventStore EventStore { get; } - - public GetEventStoreFixture() - { - connection = EventStoreConnection.Create("ConnectTo=tcp://admin:changeit@localhost:1113; HeartBeatTimeout=500; MaxReconnections=-1"); - - EventStore = new GetEventStore(connection, TestUtils.DefaultSerializer, "test", "localhost"); - EventStore.InitializeAsync().Wait(); - } - - public void Dispose() - { - CleanupAsync().Wait(); - - connection.Dispose(); - } - - private async Task CleanupAsync() - { - var endpoints = await Dns.GetHostAddressesAsync("localhost"); - var endpoint = new IPEndPoint(endpoints.First(x => x.AddressFamily == AddressFamily.InterNetwork), 2113); - - var credentials = connection.Settings.DefaultUserCredentials; - - var projectionsManager = - new ProjectionsManager( - connection.Settings.Log, endpoint, - connection.Settings.OperationTimeout); - - foreach (var projection in await projectionsManager.ListAllAsync(credentials)) - { - var name = projection.Name; - - if (name.StartsWith("by-test", StringComparison.OrdinalIgnoreCase)) - { - await projectionsManager.DisableAsync(name, credentials); - await projectionsManager.DeleteAsync(name, true, credentials); - } - } - } - } -} diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreTests.cs deleted file mode 100644 index 00090dfef..000000000 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Xunit; - -#pragma warning disable SA1300 // Element should begin with upper-case letter - -namespace Squidex.Infrastructure.EventSourcing -{ - [Trait("Category", "Dependencies")] - public class GetEventStoreTests : EventStoreTests, IClassFixture - { - public GetEventStoreFixture _ { get; } - - protected override int SubscriptionDelayInMs { get; } = 1000; - - public GetEventStoreTests(GetEventStoreFixture fixture) - { - _ = fixture; - } - - public override GetEventStore CreateStore() - { - return _.EventStore; - } - } -} diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/PollingSubscriptionTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/PollingSubscriptionTests.cs index a0fb493f8..e8effa498 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/PollingSubscriptionTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/PollingSubscriptionTests.cs @@ -26,7 +26,7 @@ namespace Squidex.Infrastructure.EventSourcing await WaitAndStopAsync(sut); - A.CallTo(() => eventStore.QueryAsync(A>._, "^my-stream", position, A._)) + A.CallTo(() => eventStore.QueryAllAsync("^my-stream", position, A._, A._)) .MustHaveHappenedOnceExactly(); } @@ -35,7 +35,7 @@ namespace Squidex.Infrastructure.EventSourcing { var ex = new InvalidOperationException(); - A.CallTo(() => eventStore.QueryAsync(A>._, "^my-stream", position, A._)) + A.CallTo(() => eventStore.QueryAllAsync("^my-stream", position, A._, A._)) .Throws(ex); var sut = new PollingSubscription(eventStore, eventSubscriber, "^my-stream", position); @@ -51,7 +51,7 @@ namespace Squidex.Infrastructure.EventSourcing { var ex = new OperationCanceledException(); - A.CallTo(() => eventStore.QueryAsync(A>._, "^my-stream", position, A._)) + A.CallTo(() => eventStore.QueryAllAsync("^my-stream", position, A._, A._)) .Throws(ex); var sut = new PollingSubscription(eventStore, eventSubscriber, "^my-stream", position); @@ -67,7 +67,7 @@ namespace Squidex.Infrastructure.EventSourcing { var ex = new AggregateException(new OperationCanceledException()); - A.CallTo(() => eventStore.QueryAsync(A>._, "^my-stream", position, A._)) + A.CallTo(() => eventStore.QueryAllAsync("^my-stream", position, A._, A._)) .Throws(ex); var sut = new PollingSubscription(eventStore, eventSubscriber, "^my-stream", position); @@ -87,7 +87,7 @@ namespace Squidex.Infrastructure.EventSourcing await WaitAndStopAsync(sut); - A.CallTo(() => eventStore.QueryAsync(A>._, "^my-stream", position, A._)) + A.CallTo(() => eventStore.QueryAllAsync("^my-stream", position, A._, A._)) .MustHaveHappened(2, Times.Exactly); } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj index 3c05aa80c..6eeb6a867 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj +++ b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj @@ -8,8 +8,6 @@ enable - - @@ -25,7 +23,6 @@ runtime; build; native; contentfiles; analyzers - diff --git a/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj b/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj index 55c0e7861..5c22a977a 100644 --- a/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj +++ b/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj @@ -15,7 +15,6 @@ - diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj index a77e16655..94accf014 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj +++ b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj @@ -5,7 +5,6 @@ - diff --git a/backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj b/backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj index e24496280..f1e2ca074 100644 --- a/backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj +++ b/backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj @@ -5,7 +5,6 @@ - diff --git a/frontend/app/features/administration/pages/users/users-page.component.html b/frontend/app/features/administration/pages/users/users-page.component.html index f0dd57538..d0b200887 100644 --- a/frontend/app/features/administration/pages/users/users-page.component.html +++ b/frontend/app/features/administration/pages/users/users-page.component.html @@ -36,13 +36,13 @@   - {{ 'common.name' | sqxTranslate }} + {{ 'common.name' | sqxTranslate }} - {{ 'common.email' | sqxTranslate }} + {{ 'common.email' | sqxTranslate }} - {{ 'common.actions' | sqxTranslate }} + {{ 'common.actions' | sqxTranslate }} diff --git a/frontend/app/features/apps/pages/apps-page.component.html b/frontend/app/features/apps/pages/apps-page.component.html index 66209487f..5a9fec029 100644 --- a/frontend/app/features/apps/pages/apps-page.component.html +++ b/frontend/app/features/apps/pages/apps-page.component.html @@ -21,7 +21,7 @@ -
+
diff --git a/frontend/app/features/content/pages/content/content-history-page.component.html b/frontend/app/features/content/pages/content/content-history-page.component.html index 82ea48fb8..2262c8381 100644 --- a/frontend/app/features/content/pages/content/content-history-page.component.html +++ b/frontend/app/features/content/pages/content/content-history-page.component.html @@ -123,7 +123,7 @@
-

{{ 'common.history2' | sqxTranslate }}

+

{{ 'common.history' | sqxTranslate }}

@Input() public url: string; + @Input() + public set disabled(value: boolean | null | undefined) { + this.setDisabledState(value === true); + } + public assetsDialog = new DialogModel(); public fullscreen: boolean; @@ -198,9 +203,7 @@ export class IFrameEditorComponent extends StatefulControlComponent this.sendValue(); } - public setDisabledState(isDisabled: boolean): void { - super.setDisabledState(isDisabled); - + public onDisabled() { this.sendDisabled(); } diff --git a/frontend/app/features/content/shared/forms/stock-photo-editor.component.ts b/frontend/app/features/content/shared/forms/stock-photo-editor.component.ts index bb03d2cef..78398c181 100644 --- a/frontend/app/features/content/shared/forms/stock-photo-editor.component.ts +++ b/frontend/app/features/content/shared/forms/stock-photo-editor.component.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'; import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { StatefulControlComponent, StockPhotoDto, StockPhotoService, thumbnail, Types, value$ } from '@app/shared'; import { of } from 'rxjs'; @@ -33,6 +33,11 @@ interface State { changeDetection: ChangeDetectionStrategy.OnPush }) export class StockPhotoEditorComponent extends StatefulControlComponent implements OnInit { + @Input() + public set disabled(value: boolean | null | undefined) { + this.setDisabledState(value === true); + } + public valueControl = new FormControl(''); public stockPhotoThumbnail = value$(this.valueControl).pipe(map(v => thumbnail(v, 400) || v)); @@ -80,9 +85,7 @@ export class StockPhotoEditorComponent extends StatefulControlComponent
- +
diff --git a/frontend/app/features/rules/declarations.ts b/frontend/app/features/rules/declarations.ts index 07763a929..bece8a1f5 100644 --- a/frontend/app/features/rules/declarations.ts +++ b/frontend/app/features/rules/declarations.ts @@ -5,11 +5,14 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -export * from './pages/events/pipes'; +export * from './pages/events/rule-event.component'; export * from './pages/events/rule-events-page.component'; export * from './pages/rule/rule-page.component'; export * from './pages/rules/rule.component'; export * from './pages/rules/rules-page.component'; +export * from './pages/simulator/rule-simulator-page.component'; +export * from './pages/simulator/simulated-rule-event.component'; +export * from './shared/actions/formattable-input.component'; export * from './shared/actions/generic-action.component'; export * from './shared/rule-element.component'; export * from './shared/rule-icon.component'; diff --git a/frontend/app/features/rules/module.ts b/frontend/app/features/rules/module.ts index c760b6a18..85fb52231 100644 --- a/frontend/app/features/rules/module.ts +++ b/frontend/app/features/rules/module.ts @@ -10,8 +10,11 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { HelpComponent, RuleMustExistGuard, SqxFrameworkModule, SqxSharedModule } from '@app/shared'; -import { AssetChangedTriggerComponent, CommentTriggerComponent, ContentChangedTriggerComponent, GenericActionComponent, RuleComponent, RuleElementComponent, RuleEventBadgeClassPipe, RuleEventsPageComponent, RuleIconComponent, RulesPageComponent, SchemaChangedTriggerComponent, UsageTriggerComponent } from './declarations'; +import { AssetChangedTriggerComponent, CommentTriggerComponent, ContentChangedTriggerComponent, GenericActionComponent, RuleComponent, RuleElementComponent, RuleEventsPageComponent, RuleIconComponent, RuleSimulatorPageComponent, RulesPageComponent, SchemaChangedTriggerComponent, UsageTriggerComponent } from './declarations'; +import { RuleEventComponent } from './pages/events/rule-event.component'; import { RulePageComponent } from './pages/rule/rule-page.component'; +import { SimulatedRuleEventComponent } from './pages/simulator/simulated-rule-event.component'; +import { FormattableInputComponent } from './shared/actions/formattable-input.component'; const routes: Routes = [ { @@ -39,6 +42,10 @@ const routes: Routes = [ path: 'events', component: RuleEventsPageComponent }, + { + path: 'simulator', + component: RuleSimulatorPageComponent + }, { path: 'help', component: HelpComponent, @@ -60,15 +67,18 @@ const routes: Routes = [ AssetChangedTriggerComponent, CommentTriggerComponent, ContentChangedTriggerComponent, + FormattableInputComponent, GenericActionComponent, RuleComponent, RuleElementComponent, - RuleEventBadgeClassPipe, + RuleEventComponent, RuleEventsPageComponent, RuleIconComponent, RulePageComponent, + RuleSimulatorPageComponent, RulesPageComponent, SchemaChangedTriggerComponent, + SimulatedRuleEventComponent, UsageTriggerComponent ] }) diff --git a/frontend/app/features/rules/pages/events/pipes.ts b/frontend/app/features/rules/pages/events/pipes.ts deleted file mode 100644 index be3726155..000000000 --- a/frontend/app/features/rules/pages/events/pipes.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. - */ - -import { Pipe, PipeTransform } from '@angular/core'; - -@Pipe({ - name: 'sqxRuleEventBadgeClass', - pure: true -}) -export class RuleEventBadgeClassPipe implements PipeTransform { - public transform(status: string) { - if (status === 'Retry') { - return 'warning'; - } else if (status === 'Failed' || status === 'Cancelled') { - return 'danger'; - } else if (status === 'Pending') { - return 'secondary'; - } else { - return status.toLowerCase(); - } - } -} \ No newline at end of file diff --git a/frontend/app/features/rules/pages/events/rule-event.component.html b/frontend/app/features/rules/pages/events/rule-event.component.html new file mode 100644 index 000000000..4858f25b7 --- /dev/null +++ b/frontend/app/features/rules/pages/events/rule-event.component.html @@ -0,0 +1,53 @@ + + + {{event.jobResult}} + + + {{event.eventName}} + + + {{event.description}} + + + {{event.created | sqxFromNow}} + + + + + + + +
+

{{ 'rules.ruleEvents.lastInvokedLabel' | sqxTranslate }}

+
+ +
+
+ {{event.result}} +
+
+ {{ 'rules.ruleEvents.numAttemptsLabel' | sqxTranslate }}: {{event.numCalls}} +
+
+ {{ 'rules.ruleEvents.nextAttemptLabel' | sqxTranslate }}: {{event.nextAttempt | sqxFromNow}} +
+
+ + + +
+
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/frontend/app/features/rules/pages/events/rule-event.component.scss b/frontend/app/features/rules/pages/events/rule-event.component.scss new file mode 100644 index 000000000..d911d9a59 --- /dev/null +++ b/frontend/app/features/rules/pages/events/rule-event.component.scss @@ -0,0 +1,39 @@ +h3 { + font-size: 1rem; + font-weight: 500; + margin-bottom: 1rem; +} + +.expanded { + border-bottom: 0; +} + +.event { + &-stats { + font-size: .8rem; + } + + &-dump { + margin-top: 1rem; + } + + &-header { + & { + background: $color-table-footer; + border: 0; + margin: -.75rem -1.25rem; + margin-bottom: 1rem; + padding: .75rem 1.25rem; + position: relative; + } + + &::before { + @include caret-top($color-table-footer); + @include absolute(-1.1rem, 1.8rem, auto, auto); + } + + h3 { + margin: 0; + } + } +} \ No newline at end of file diff --git a/frontend/app/features/rules/pages/events/rule-event.component.ts b/frontend/app/features/rules/pages/events/rule-event.component.ts new file mode 100644 index 000000000..9e33bce77 --- /dev/null +++ b/frontend/app/features/rules/pages/events/rule-event.component.ts @@ -0,0 +1,54 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +// tslint:disable: component-selector + +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { RuleEventDto } from '@app/shared'; + +@Component({ + selector: '[sqxRuleEvent]', + styleUrls: ['./rule-event.component.scss'], + templateUrl: './rule-event.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class RuleEventComponent { + @Input('sqxRuleEvent') + public event: RuleEventDto; + + @Input() + public expanded: boolean; + + @Output() + public expandedChange = new EventEmitter(); + + @Output() + public enqueue = new EventEmitter(); + + @Output() + public cancel = new EventEmitter(); + + public get jobResultClass() { + return getClass(this.event.jobResult); + } + + public get resultClass() { + return getClass(this.event.result); + } +} + +function getClass(result: string) { + if (result === 'Retry') { + return 'warning'; + } else if (result === 'Failed' || result === 'Cancelled') { + return 'danger'; + } else if (result === 'Pending') { + return 'secondary'; + } else { + return result.toLowerCase(); + } +} diff --git a/frontend/app/features/rules/pages/events/rule-events-page.component.html b/frontend/app/features/rules/pages/events/rule-events-page.component.html index ad7cd915f..adffca58a 100644 --- a/frontend/app/features/rules/pages/events/rule-events-page.component.html +++ b/frontend/app/features/rules/pages/events/rule-events-page.component.html @@ -1,6 +1,6 @@ - + {{ 'common.events' | sqxTranslate }} @@ -20,75 +20,27 @@ - {{ 'common.status' | sqxTranslate }} + {{ 'common.status' | sqxTranslate }} - {{ 'common.event' | sqxTranslate }} + {{ 'common.event' | sqxTranslate }} - {{ 'common.description' | sqxTranslate }} + {{ 'common.description' | sqxTranslate }} - {{ 'common.created' | sqxTranslate }} + {{ 'common.created' | sqxTranslate }} - +   - - - - {{event.jobResult}} - - - {{event.eventName}} - - - {{event.description}} - - - {{event.created | sqxFromNow}} - - - - - - - -
-

{{ 'rules.ruleEvents.lastInvokedLabel' | sqxTranslate }}

-
- -
-
- {{event.result}} -
-
- {{ 'rules.ruleEvents.numAttemptsLabel' | sqxTranslate }}: {{event.numCalls}} -
-
- {{ 'rules.ruleEvents.nextAttemptLabel' | sqxTranslate }}: {{event.nextAttempt | sqxFromNow}} -
-
- - - -
-
-
-
- -
-
- - - + diff --git a/frontend/app/features/rules/pages/events/rule-events-page.component.scss b/frontend/app/features/rules/pages/events/rule-events-page.component.scss index b6b104d3a..e69de29bb 100644 --- a/frontend/app/features/rules/pages/events/rule-events-page.component.scss +++ b/frontend/app/features/rules/pages/events/rule-events-page.component.scss @@ -1,41 +0,0 @@ -h3 { - margin-bottom: 1rem; -} - -.expanded { - border-bottom: 0; -} - -.event { - &-stats { - font-size: .8rem; - } - - &-dump { - font-family: monospace; - font-size: .8rem; - font-weight: normal; - height: 20rem; - margin-top: 1rem; - } - - &-header { - & { - background: $color-table-footer; - border: 0; - margin: -.75rem -1.25rem; - margin-bottom: 1rem; - padding: .75rem 1.25rem; - position: relative; - } - - &::before { - @include caret-top($color-table-footer); - @include absolute(-1.1rem, 1.8rem, auto, auto); - } - - h3 { - margin: 0; - } - } -} \ No newline at end of file diff --git a/frontend/app/features/rules/pages/events/rule-events-page.component.ts b/frontend/app/features/rules/pages/events/rule-events-page.component.ts index 6d37c0ec3..b1df9a70c 100644 --- a/frontend/app/features/rules/pages/events/rule-events-page.component.ts +++ b/frontend/app/features/rules/pages/events/rule-events-page.component.ts @@ -49,7 +49,11 @@ export class RuleEventsPageComponent implements OnInit { } public selectEvent(id: string) { - this.selectedEventId = this.selectedEventId !== id ? id : null; + if (this.selectedEventId === id) { + this.selectedEventId = null; + } else { + this.selectedEventId = id; + } } public trackByRuleEvent(_index: number, ruleEvent: RuleEventDto) { diff --git a/frontend/app/features/rules/pages/rule/rule-page.component.html b/frontend/app/features/rules/pages/rule/rule-page.component.html index b207b83af..25c902387 100644 --- a/frontend/app/features/rules/pages/rule/rule-page.component.html +++ b/frontend/app/features/rules/pages/rule/rule-page.component.html @@ -33,66 +33,54 @@

{{ 'rules.ruleSyntax.if' | sqxTranslate }}

-
- +
+
-
+
-
+

...

- + - {{ 'rules.wizard.triggerHint' | sqxTranslate }} + {{ 'rules.triggerHint' | sqxTranslate }} - + - - + - - + + [trigger]="currentTrigger.values" + [triggerForm]="currentTrigger.form"> - - + - - + - +
-
+
@@ -108,39 +96,35 @@

{{ 'rules.ruleSyntax.then' | sqxTranslate }}

-
- +
+
-
+
-
+

...

- + - {{ 'rules.wizard.actionHint' | sqxTranslate }} + {{ 'rules.actionHint' | sqxTranslate }} - - + - +
-
+
@@ -153,9 +137,15 @@
- - - + + + + + + + + + diff --git a/frontend/app/features/rules/pages/rule/rule-page.component.ts b/frontend/app/features/rules/pages/rule/rule-page.component.ts index 064a3b815..d30158e3c 100644 --- a/frontend/app/features/rules/pages/rule/rule-page.component.ts +++ b/frontend/app/features/rules/pages/rule/rule-page.component.ts @@ -6,9 +6,11 @@ */ import { Component, OnInit } from '@angular/core'; -import { FormGroup } from '@angular/forms'; +import { FormBuilder } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { ALL_TRIGGERS, Form, ResourceOwner, RuleDto, RuleElementDto, RulesService, RulesState, SchemasState, TriggerType } from '@app/shared'; +import { ActionForm, ALL_TRIGGERS, ResourceOwner, RuleDto, RuleElementDto, RulesService, RulesState, SchemasState, TriggerForm } from '@app/shared'; + +type ComponentState = { type: string; values: any; form: T; }; @Component({ selector: 'sqx-rule-page', @@ -21,31 +23,25 @@ export class RulePageComponent extends ResourceOwner implements OnInit { public rule?: RuleDto | null; - public formAction?: Form; - public formTrigger?: Form; - - public actionProperties?: any; - public actionType: string; - - public triggerProperties?: any; - public triggerType: string; + public currentAction?: ComponentState; + public currentTrigger?: ComponentState; public isEnabled = false; + public isEditable = false; public get actionElement() { - return this.supportedActions[this.actionType]; + return this.supportedActions[this.currentAction?.type || '']; } public get triggerElement() { - return this.supportedTriggers[this.triggerType]; + return this.supportedTriggers[this.currentTrigger?.type || '']; } - public isEditable = false; - constructor( public readonly rulesState: RulesState, public readonly rulesService: RulesService, public readonly schemasState: SchemasState, + private readonly formBuilder: FormBuilder, private readonly route: ActivatedRoute, private readonly router: Router ) { @@ -53,11 +49,11 @@ export class RulePageComponent extends ResourceOwner implements OnInit { } public ngOnInit() { - this.rulesState.load(); - this.rulesService.getActions() .subscribe(actions => { this.supportedActions = actions; + + this.initFromRule(); }); this.own( @@ -65,115 +61,103 @@ export class RulePageComponent extends ResourceOwner implements OnInit { .subscribe(rule => { this.rule = rule; - if (rule) { - this.isEditable = rule.canUpdate; - this.isEnabled = rule.isEnabled; - - this.selectAction(rule.action); - this.selectTrigger(rule.trigger); - } else { - this.isEditable = true; - this.isEnabled = false; - - this.resetAction(); - this.resetTrigger(); - } - - this.formTrigger?.setEnabled(this.isEditable); + this.initFromRule(); })); this.schemasState.loadIfNotLoaded(); } - public selectActionType(actionType: string) { - this.selectAction({ actionType }); - } + private initFromRule() { + if (this.rule && this.supportedActions) { + this.isEditable = this.rule.canUpdate; + this.isEnabled = this.rule.isEnabled; - public selectTriggerType(triggerType: TriggerType) { - this.selectTrigger({ triggerType }); - } + this.selectAction(this.rule.actionType, this.rule.action); + this.selectTrigger(this.rule.triggerType, this.rule.trigger); + } else { + this.isEditable = true; + this.isEnabled = false; - public resetAction() { - this.actionProperties = undefined; - this.actionType = undefined!; + this.resetAction(); + this.resetTrigger(); + } - this.formAction = undefined; } - public resetTrigger() { - this.triggerProperties = undefined; - this.triggerType = undefined!; + public selectAction(type: string, values = {}) { + const form = new ActionForm(this.supportedActions[type], type); - this.formTrigger = undefined; + form.setEnabled(this.isEditable); + form.load(values); + + this.currentAction = { form, type, values }; } - private selectAction(target: { actionType: string } & any) { - const { actionType, ...properties } = target; + public selectTrigger(type: string, values = {}) { + const form = new TriggerForm(this.formBuilder, type); - this.actionProperties = properties; - this.actionType = actionType; + form.setEnabled(this.isEditable); + form.load(values); - this.formAction = new Form(new FormGroup({})); - this.formAction.setEnabled(this.isEditable); + this.currentTrigger = { form, type, values }; } - private selectTrigger(target: { triggerType: string } & any) { - const { triggerType, ...properties } = target; - - this.triggerProperties = properties; - this.triggerType = triggerType; + public resetAction() { + this.currentAction = undefined; + } - this.formTrigger = new Form(new FormGroup({})); - this.formTrigger.setEnabled(this.isEditable); + public resetTrigger() { + this.currentTrigger = undefined; } public save() { - if (!this.isEditable || !this.formAction || !this.formTrigger) { + if (!this.isEditable || !this.currentAction || !this.currentTrigger) { return; } - const ruleTrigger = this.formTrigger.submit(); - const ruleAction = this.formAction.submit(); + const action = this.currentAction.form.submit(); - if (!ruleTrigger || !ruleAction) { + if (!action) { return; } - const request = { - trigger: { - triggerType: this.triggerType, - ...ruleTrigger - }, - action: { - actionType: this.actionType, - ...ruleAction - }, - isEnabled: this.isEnabled - }; + const trigger = this.currentTrigger.form.submit(); + + if (!trigger || !action) { + return; + } + + const request: any = { trigger, action, isEnabled: this.isEnabled }; if (this.rule) { this.rulesState.update(this.rule, request) .subscribe(() => { - this.formAction?.submitCompleted({ noReset: true }); - this.formTrigger?.submitCompleted({ noReset: true }); + this.submitCompleted(); }, error => { - this.formAction?.submitFailed(error); - this.formTrigger?.submitFailed(error); + this.submitFailed(error); }); } else { this.rulesState.create(request) .subscribe(rule => { - this.formAction?.submitCompleted({ noReset: true }); - this.formTrigger?.submitCompleted({ noReset: true }); + this.submitCompleted(); this.router.navigate([rule.id], { relativeTo: this.route.parent, replaceUrl: true }); }, error => { - this.formAction?.submitFailed(error); - this.formTrigger?.submitFailed(error); + this.submitFailed(error); }); } } + private submitCompleted() { + this.currentAction?.form.submitCompleted({ noReset: true }); + this.currentTrigger?.form.submitCompleted({ noReset: true }); + } + + private submitFailed(error: any) { + this.currentAction?.form?.submitFailed(error); + this.currentTrigger?.form?.submitFailed(error); + } + public back() { this.router.navigate(['../'], { relativeTo: this.route, replaceUrl: true }); } diff --git a/frontend/app/features/rules/pages/simulator/rule-simulator-page.component.html b/frontend/app/features/rules/pages/simulator/rule-simulator-page.component.html new file mode 100644 index 000000000..4b2926ff6 --- /dev/null +++ b/frontend/app/features/rules/pages/simulator/rule-simulator-page.component.html @@ -0,0 +1,42 @@ + + + + + {{ 'rules.simulator' | sqxTranslate }} + + + + + + + + + + + + + + + + + + + + + +
+ {{ 'common.status' | sqxTranslate }} + + {{ 'common.event' | sqxTranslate }} + + {{ 'common.skipped' | sqxTranslate }} +  
+
+
+
+
\ No newline at end of file diff --git a/frontend/app/features/rules/pages/simulator/rule-simulator-page.component.scss b/frontend/app/features/rules/pages/simulator/rule-simulator-page.component.scss new file mode 100644 index 000000000..b6b104d3a --- /dev/null +++ b/frontend/app/features/rules/pages/simulator/rule-simulator-page.component.scss @@ -0,0 +1,41 @@ +h3 { + margin-bottom: 1rem; +} + +.expanded { + border-bottom: 0; +} + +.event { + &-stats { + font-size: .8rem; + } + + &-dump { + font-family: monospace; + font-size: .8rem; + font-weight: normal; + height: 20rem; + margin-top: 1rem; + } + + &-header { + & { + background: $color-table-footer; + border: 0; + margin: -.75rem -1.25rem; + margin-bottom: 1rem; + padding: .75rem 1.25rem; + position: relative; + } + + &::before { + @include caret-top($color-table-footer); + @include absolute(-1.1rem, 1.8rem, auto, auto); + } + + h3 { + margin: 0; + } + } +} \ No newline at end of file diff --git a/frontend/app/features/rules/pages/simulator/rule-simulator-page.component.ts b/frontend/app/features/rules/pages/simulator/rule-simulator-page.component.ts new file mode 100644 index 000000000..bb0818bd7 --- /dev/null +++ b/frontend/app/features/rules/pages/simulator/rule-simulator-page.component.ts @@ -0,0 +1,46 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { ResourceOwner, RuleSimulatorState, SimulatedRuleEventDto } from '@app/shared'; + +@Component({ + selector: 'sqx-simulator-events-page', + styleUrls: ['./rule-simulator-page.component.scss'], + templateUrl: './rule-simulator-page.component.html' +}) +export class RuleSimulatorPageComponent extends ResourceOwner implements OnInit { + public selectedRuleEvent?: SimulatedRuleEventDto | null; + + constructor( + private route: ActivatedRoute, + public readonly ruleSimulatorState: RuleSimulatorState + ) { + super(); + } + + public ngOnInit() { + this.own( + this.route.queryParams + .subscribe(query => { + this.ruleSimulatorState.selectRule(query['ruleId']); + })); + } + + public simulate() { + this.ruleSimulatorState.load(); + } + + public selectEvent(event: SimulatedRuleEventDto) { + if (this.selectedRuleEvent === event) { + this.selectedRuleEvent = null; + } else { + this.selectedRuleEvent = event; + } + } +} \ No newline at end of file diff --git a/frontend/app/features/rules/pages/simulator/simulated-rule-event.component.html b/frontend/app/features/rules/pages/simulator/simulated-rule-event.component.html new file mode 100644 index 000000000..91044360d --- /dev/null +++ b/frontend/app/features/rules/pages/simulator/simulated-rule-event.component.html @@ -0,0 +1,32 @@ + + + {{status}} + + + {{event.eventName}} + + + {{event.skipReason}} + + + + + + + +
+ + + +
+ +
+ + + +
+ + + \ No newline at end of file diff --git a/frontend/app/features/rules/pages/simulator/simulated-rule-event.component.scss b/frontend/app/features/rules/pages/simulator/simulated-rule-event.component.scss new file mode 100644 index 000000000..65dc8410a --- /dev/null +++ b/frontend/app/features/rules/pages/simulator/simulated-rule-event.component.scss @@ -0,0 +1,4 @@ + +.expanded { + border-bottom: 0; +} \ No newline at end of file diff --git a/frontend/app/features/rules/pages/simulator/simulated-rule-event.component.ts b/frontend/app/features/rules/pages/simulator/simulated-rule-event.component.ts new file mode 100644 index 000000000..118e2e9ed --- /dev/null +++ b/frontend/app/features/rules/pages/simulator/simulated-rule-event.component.ts @@ -0,0 +1,62 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +// tslint:disable: component-selector + +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { SimulatedRuleEventDto } from '@app/shared'; + +@Component({ + selector: '[sqxSimulatedRuleEvent]', + styleUrls: ['./simulated-rule-event.component.scss'], + templateUrl: './simulated-rule-event.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SimulatedRuleEventComponent { + @Input('sqxSimulatedRuleEvent') + public event: SimulatedRuleEventDto; + + @Input() + public expanded: boolean; + + @Output() + public expandedChange = new EventEmitter(); + + public get data() { + let result = this.event.actionData; + + if (result) { + try { + result = JSON.stringify(JSON.parse(result), null, 2); + } catch { + result = this.event.actionData; + } + } + + return result; + } + + public get status() { + if (this.event.error) { + return 'Failed'; + } else if (this.event.skipReason !== 'None') { + return 'Skipped'; + } else { + return 'Success'; + } + } + + public get statusClass() { + if (this.event.error) { + return 'danger'; + } else if (this.event.skipReason !== 'None') { + return 'warning'; + } else { + return 'success'; + } + } +} diff --git a/frontend/app/features/rules/shared/actions/formattable-input.component.html b/frontend/app/features/rules/shared/actions/formattable-input.component.html new file mode 100644 index 000000000..1d9f28e70 --- /dev/null +++ b/frontend/app/features/rules/shared/actions/formattable-input.component.html @@ -0,0 +1,21 @@ + +
+ + +
+ +
+
+
+ + +
+ +
+ + +
\ No newline at end of file diff --git a/frontend/app/features/rules/shared/actions/formattable-input.component.scss b/frontend/app/features/rules/shared/actions/formattable-input.component.scss new file mode 100644 index 000000000..8ddcccd61 --- /dev/null +++ b/frontend/app/features/rules/shared/actions/formattable-input.component.scss @@ -0,0 +1,30 @@ +.input-group-apppend { + & { + z-index: 1; + } + + .custom-select { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + color: $color-text-decent; + margin-left: -1px; + + &:focus { + z-index: 3; + } + } +} + +.btn-group { + margin-bottom: -1px; + + .btn { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + color: $color-text-decent; + + &.active { + color: $color-text; + } + } +} \ No newline at end of file diff --git a/frontend/app/features/rules/shared/actions/formattable-input.component.ts b/frontend/app/features/rules/shared/actions/formattable-input.component.ts new file mode 100644 index 000000000..20fab4c5e --- /dev/null +++ b/frontend/app/features/rules/shared/actions/formattable-input.component.ts @@ -0,0 +1,150 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { AfterViewInit, ChangeDetectionStrategy, Component, forwardRef, Input, ViewChild } from '@angular/core'; +import { ControlValueAccessor, DefaultValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { CodeEditorComponent, Types } from '@app/framework'; + +export const SQX_FORMATTABLE_INPUT_CONTROL_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FormattableInputComponent), multi: true +}; + +type TemplateMode = 'Text' | 'Script' | 'Liquid'; + +const MODES: ReadonlyArray = ['Text', 'Script', 'Liquid']; + +@Component({ + selector: 'sqx-formattable-input', + styleUrls: ['./formattable-input.component.scss'], + templateUrl: './formattable-input.component.html', + providers: [ + SQX_FORMATTABLE_INPUT_CONTROL_VALUE_ACCESSOR + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FormattableInputComponent implements ControlValueAccessor, AfterViewInit { + private fnChanged = (_: any) => { /* NOOP */ }; + private fnTouched = () => { /* NOOP */ }; + private value?: string; + + @Input() + public type: 'Text' | 'Code'; + + @Input() + public formattable = true; + + @ViewChild(DefaultValueAccessor) + public inputEditor: DefaultValueAccessor; + + @ViewChild(CodeEditorComponent) + public codeEditor: CodeEditorComponent; + + public disabled = false; + + public get valueAccessor(): ControlValueAccessor { + return this.codeEditor || this.inputEditor; + } + + public modes = MODES; + public mode: TemplateMode = 'Text'; + + public aceMode = 'ace/editor/text'; + + public ngAfterViewInit() { + this.valueAccessor.registerOnChange((value: any) => { + this.value = value; + + this.fnChanged(this.convertValue(value)); + }); + + this.valueAccessor.registerOnTouched(() => { + this.fnTouched(); + }); + + this.valueAccessor.writeValue(this.value); + } + + public writeValue(obj: any) { + let mode: TemplateMode = 'Text'; + + if (Types.isString(obj)) { + this.value = obj; + + if (obj.endsWith(')')) { + const lower = obj.toLowerCase(); + + if (lower.startsWith('liquid(')) { + this.value = obj.substr(7, obj.length - 8); + + mode = 'Liquid'; + } else if (lower.startsWith('script(')) { + this.value = obj.substr(7, obj.length - 8); + + mode = 'Script'; + } + } + } else { + this.value = undefined; + } + + this.setMode(mode, false); + + this.valueAccessor?.writeValue(this.value); + } + + public setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + + this.valueAccessor?.setDisabledState?.(isDisabled); + } + + public setMode(mode: TemplateMode, emit = true) { + if (this.mode !== mode) { + this.mode = mode; + + if (mode === 'Script') { + this.aceMode = 'ace/mode/javascript'; + } else if (mode === 'Liquid') { + this.aceMode = 'ace/mode/liquid'; + } else { + this.aceMode = 'ace/editor/text'; + } + + if (emit) { + this.fnChanged(this.convertValue(this.value)); + this.fnTouched(); + } + } + } + + public registerOnChange(fn: any) { + this.fnChanged = fn; + } + + public registerOnTouched(fn: any) { + this.fnTouched = fn; + } + + private convertValue(value: string | undefined) { + if (!value) { + return value; + } + + value = value.trim(); + + switch (this.mode) { + case 'Liquid': { + return `Liquid(${value})`; + } + case 'Script': { + return `Script(${value})`; + } + } + + return value; + } +} \ No newline at end of file diff --git a/frontend/app/features/rules/shared/actions/generic-action.component.html b/frontend/app/features/rules/shared/actions/generic-action.component.html index dac793d01..8ef6f55de 100644 --- a/frontend/app/features/rules/shared/actions/generic-action.component.html +++ b/frontend/app/features/rules/shared/actions/generic-action.component.html @@ -1,15 +1,16 @@ -
-
+
+
- - + + + - +
diff --git a/frontend/app/features/rules/shared/actions/generic-action.component.ts b/frontend/app/features/rules/shared/actions/generic-action.component.ts index 22d8ee0ae..3e923337c 100644 --- a/frontend/app/features/rules/shared/actions/generic-action.component.ts +++ b/frontend/app/features/rules/shared/actions/generic-action.component.ts @@ -5,39 +5,16 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { RuleElementDto } from '@app/shared'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ActionForm } from '@app/shared'; @Component({ selector: 'sqx-generic-action', styleUrls: ['./generic-action.component.scss'], - templateUrl: './generic-action.component.html' + templateUrl: './generic-action.component.html', + changeDetection: ChangeDetectionStrategy.OnPush }) -export class GenericActionComponent implements OnChanges { +export class GenericActionComponent { @Input() - public definition: RuleElementDto; - - @Input() - public action: any; - - @Input() - public actionForm: FormGroup; - - public ngOnChanges(changes: SimpleChanges) { - if (changes['actionForm'] || changes['definition']) { - for (const property of this.definition.properties) { - const validator = - property.isRequired ? - Validators.required : - Validators.nullValidator; - - const control = new FormControl('', validator); - - this.actionForm.setControl(property.name, control); - } - } - - this.actionForm.patchValue(this.action); - } + public actionForm: ActionForm; } \ No newline at end of file diff --git a/frontend/app/features/rules/shared/triggers/asset-changed-trigger.component.html b/frontend/app/features/rules/shared/triggers/asset-changed-trigger.component.html index ef8cac61f..c62fcf7a3 100644 --- a/frontend/app/features/rules/shared/triggers/asset-changed-trigger.component.html +++ b/frontend/app/features/rules/shared/triggers/asset-changed-trigger.component.html @@ -1,4 +1,4 @@ -
+
diff --git a/frontend/app/features/rules/shared/triggers/asset-changed-trigger.component.ts b/frontend/app/features/rules/shared/triggers/asset-changed-trigger.component.ts index 494ec3977..f3b83e4a3 100644 --- a/frontend/app/features/rules/shared/triggers/asset-changed-trigger.component.ts +++ b/frontend/app/features/rules/shared/triggers/asset-changed-trigger.component.ts @@ -5,27 +5,18 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; -import { FormControl, FormGroup } from '@angular/forms'; +import { Component, Input } from '@angular/core'; +import { TriggerForm } from '@app/shared'; @Component({ selector: 'sqx-asset-changed-trigger', styleUrls: ['./asset-changed-trigger.component.scss'], templateUrl: './asset-changed-trigger.component.html' }) -export class AssetChangedTriggerComponent implements OnChanges { +export class AssetChangedTriggerComponent { @Input() public trigger: any; @Input() - public triggerForm: FormGroup; - - public ngOnChanges(changes: SimpleChanges) { - if (changes['triggerForm']) { - this.triggerForm.setControl('condition', - new FormControl()); - } - - this.triggerForm.patchValue(this.trigger); - } + public triggerForm: TriggerForm; } \ No newline at end of file diff --git a/frontend/app/features/rules/shared/triggers/comment-trigger.component.html b/frontend/app/features/rules/shared/triggers/comment-trigger.component.html index 7cbf53e6d..3504317e1 100644 --- a/frontend/app/features/rules/shared/triggers/comment-trigger.component.html +++ b/frontend/app/features/rules/shared/triggers/comment-trigger.component.html @@ -1,4 +1,4 @@ -
+
diff --git a/frontend/app/features/rules/shared/triggers/comment-trigger.component.ts b/frontend/app/features/rules/shared/triggers/comment-trigger.component.ts index eff96065e..8e50aa12a 100644 --- a/frontend/app/features/rules/shared/triggers/comment-trigger.component.ts +++ b/frontend/app/features/rules/shared/triggers/comment-trigger.component.ts @@ -5,27 +5,18 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; -import { FormControl, FormGroup } from '@angular/forms'; +import { Component, Input } from '@angular/core'; +import { TriggerForm } from '@app/shared'; @Component({ selector: 'sqx-comment-trigger', styleUrls: ['./comment-trigger.component.scss'], templateUrl: './comment-trigger.component.html' }) -export class CommentTriggerComponent implements OnChanges { +export class CommentTriggerComponent { @Input() public trigger: any; @Input() - public triggerForm: FormGroup; - - public ngOnChanges(changes: SimpleChanges) { - if (changes['triggerForm']) { - this.triggerForm.setControl('condition', - new FormControl()); - } - - this.triggerForm.patchValue(this.trigger); - } + public triggerForm: TriggerForm; } \ No newline at end of file diff --git a/frontend/app/features/rules/shared/triggers/content-changed-trigger.component.html b/frontend/app/features/rules/shared/triggers/content-changed-trigger.component.html index 23e30f4c8..c3000f97d 100644 --- a/frontend/app/features/rules/shared/triggers/content-changed-trigger.component.html +++ b/frontend/app/features/rules/shared/triggers/content-changed-trigger.component.html @@ -1,4 +1,4 @@ - + @@ -7,12 +7,12 @@ - - + + @@ -22,13 +22,13 @@ @@ -38,12 +38,12 @@
-
- +
@@ -73,7 +73,7 @@ -
+
- Schema - - Condition - + + + +
-