diff --git a/Changelog.md b/Changelog.md index 118eb667a..9260c7e5c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -13,6 +13,7 @@ - You can now import learning worlds from .zip archives. - New list of previously uploaded learning worlds that appears after logging in to the LMS. - Previously uploaded worlds can now be deleted from the LMS and the AdLerBackend. +- Before a learning world is uploaded, the system now checks whether a learning world with the same name already exists on the backend. If there is a duplicate, the author can decide whether to replace the learning world or create a copy. - Authors can now assign an enrolment key for their learning world. - Learning outcomes can now be created for learning spaces using an input form. - A new button in the header bar shows an overview of all learning outcomes in the selected world. diff --git a/IntegrationTest/Dialogues/ReplaceCopyLmsWorldDialogIt.cs b/IntegrationTest/Dialogues/ReplaceCopyLmsWorldDialogIt.cs new file mode 100644 index 000000000..9a0059666 --- /dev/null +++ b/IntegrationTest/Dialogues/ReplaceCopyLmsWorldDialogIt.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using Bunit; +using MudBlazor; +using NUnit.Framework; +using Presentation.Components.Dialogues; +using Shared; + +namespace IntegrationTest.Dialogues; + +[TestFixture] +public class ReplaceCopyLmsWorldDialogIt : MudDialogTestFixture +{ + [Test] + public async Task CopyButtonPressed_CallsDialogAndReturnsResult() + { + var dialog = await OpenDialogAndGetDialogReferenceAsync(); + + + var buttons = DialogProvider.FindComponents(); + buttons[1].Find("button").Click(); + + var result = await dialog.Result; + Assert.That(result.Data, Is.EqualTo(ReplaceCopyLmsWorldDialogResult.Copy)); + Assert.That(result.Canceled, Is.False); + } + + [Test] + public async Task CancelButtonPressed_CallsDialogAndReturnsResult() + { + var dialog = await OpenDialogAndGetDialogReferenceAsync(); + + var buttons = DialogProvider.FindComponents(); + buttons[2].Find("button").Click(); + + var result = await dialog.Result; + Assert.That(result.Data, Is.Null); + Assert.That(result.Canceled, Is.True); + } +} \ No newline at end of file diff --git a/Presentation/Components/Dialogues/ReplaceCopyLmsWorldDialog.razor b/Presentation/Components/Dialogues/ReplaceCopyLmsWorldDialog.razor new file mode 100644 index 000000000..c1bf9cbc9 --- /dev/null +++ b/Presentation/Components/Dialogues/ReplaceCopyLmsWorldDialog.razor @@ -0,0 +1,59 @@ +@using Microsoft.Extensions.Localization +@using System.Diagnostics.CodeAnalysis +@using Shared + + + @((MarkupString)Localizer["ReplaceCopyLmsWorldDialog.DialogContent", LmsWorldName].ToString()) + + + @Localizer["ReplaceCopyLmsWorldDialog.ReplaceButtonText"] + @Localizer["ReplaceCopyLmsWorldDialog.CopyButtonText"] + @Localizer["ReplaceCopyLmsWorldDialog.CancelButtonText"] + + + +@code { + [CascadingParameter] public MudDialogInstance? MudDialog { get; set; } + + [Inject, AllowNull] //can never be null, DI will throw exception on unresolved types - n.stich + IDialogService DialogService { get; set; } + + [Inject, AllowNull] //can never be null, DI will throw exception on unresolved types - n.stich + private IStringLocalizer Localizer { get; set; } + + [Parameter] public string LmsWorldName { get; set; } = string.Empty; + + private async void Replace() + { + var options = new DialogOptions + { + CloseButton = true, + CloseOnEscapeKey = true, + DisableBackdropClick = true, + }; + var parameters = new DialogParameters + { + { "DialogText", Localizer["ReplaceCopyLmsWorldDialog.ConfirmationDialogText"].ToString() }, + { "SubmitButtonText", Localizer["ReplaceCopyLmsWorldDialog.ConfirmationDialogSubmitButtonText"].ToString() } + }; + var dialog = await DialogService.ShowAsync(Localizer["ReplaceCopyLmsWorldDialog.ConfirmationDialogTitle"].ToString(), parameters, + options); + var result = await dialog.Result; + + if (result.Data is bool && (bool)result.Data) + { + MudDialog!.Close(DialogResult.Ok(ReplaceCopyLmsWorldDialogResult.Replace)); + } + } + + private void Copy() + { + MudDialog!.Close(DialogResult.Ok(ReplaceCopyLmsWorldDialogResult.Copy)); + } + + private void Cancel() + { + MudDialog!.Close(DialogResult.Cancel()); + } + +} \ No newline at end of file diff --git a/Presentation/Presentation.csproj b/Presentation/Presentation.csproj index a3ed5b604..9bd30984d 100644 --- a/Presentation/Presentation.csproj +++ b/Presentation/Presentation.csproj @@ -79,6 +79,16 @@ + + True + True + OverwriteLmsWorldDialog.de.resx + + + True + True + OverwriteLmsWorldDialog.en.resx + @@ -195,6 +205,14 @@ ResXFileCodeGenerator ConditionToggleSwitch.en.Designer.cs + + ResXFileCodeGenerator + OverwriteLmsWorldDialog.de.Designer.cs + + + ResXFileCodeGenerator + OverwriteLmsWorldDialog.en.Designer.cs + ResXFileCodeGenerator CreateEditManualLearningOutcome.de.Designer.cs diff --git a/Presentation/Resources/Components/Dialogues/ReplaceCopyLmsWorldDialog.de.resx b/Presentation/Resources/Components/Dialogues/ReplaceCopyLmsWorldDialog.de.resx new file mode 100644 index 000000000..70de7ebbd --- /dev/null +++ b/Presentation/Resources/Components/Dialogues/ReplaceCopyLmsWorldDialog.de.resx @@ -0,0 +1,47 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + Lernwelt ersetzen + + + Kopie von Lernwelt erstellen + + + Abbrechen + + + Auf dem LMS Moodle existiert bereits eine Lernwelt mit dem Namen <b>{0}</b>. Möchten Sie diese Lernwelt ersetzen oder eine Kopie erstellen? + + + Sind Sie sicher, dass Sie die bestehende Lernwelt ersetzen möchten? Alle Daten der bisherigen Lernwelt, einschließlich des Nutzerfortschritts, werden damit unwiderruflich gelöscht. + + + Sind Sie sicher? + + + Ja + + \ No newline at end of file diff --git a/Presentation/Resources/Components/Dialogues/ReplaceCopyLmsWorldDialog.en.resx b/Presentation/Resources/Components/Dialogues/ReplaceCopyLmsWorldDialog.en.resx new file mode 100644 index 000000000..943d4606b --- /dev/null +++ b/Presentation/Resources/Components/Dialogues/ReplaceCopyLmsWorldDialog.en.resx @@ -0,0 +1,47 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + Replace learning world + + + Cancel + + + Create Copy of learning world + + + A learning world with the name <b>{0}</b>. already exists on the LMS Moodle. Would you like to replace it or create a copy? + + + Are you sure you want to replace the existing learning world? All data from the previous learning world, including user progress, will be irrevocably deleted. + + + Are you sure? + + + Yes + + \ No newline at end of file diff --git a/Presentation/Resources/View/HeaderBar.de.resx b/Presentation/Resources/View/HeaderBar.de.resx index 8c0d53ef8..b5b53ecaa 100644 --- a/Presentation/Resources/View/HeaderBar.de.resx +++ b/Presentation/Resources/View/HeaderBar.de.resx @@ -144,6 +144,9 @@ Tutorial + + Lernwelt existiert bereits + Lernziele dieser Lernwelt diff --git a/Presentation/Resources/View/HeaderBar.en.resx b/Presentation/Resources/View/HeaderBar.en.resx index b11638dc6..457cf1571 100644 --- a/Presentation/Resources/View/HeaderBar.en.resx +++ b/Presentation/Resources/View/HeaderBar.en.resx @@ -144,6 +144,9 @@ Tutorial + + Learning world already exists + Learning outcomes of this learning world diff --git a/Presentation/View/HeaderBar.razor b/Presentation/View/HeaderBar.razor index 41200a9f9..85ff3fbd5 100644 --- a/Presentation/View/HeaderBar.razor +++ b/Presentation/View/HeaderBar.razor @@ -8,6 +8,7 @@ @using Presentation.PresentationLogic.LearningWorld @using Presentation.PresentationLogic.Mediator @using Presentation.PresentationLogic.SelectedViewModels +@using Shared @using Shared.Exceptions @using Presentation.PresentationLogic.API @using Presentation.PresentationLogic.AuthoringToolWorkspace @@ -25,7 +26,7 @@
- +
@@ -117,9 +118,8 @@ } - - + @Localizer["HeaderBar.Help.UserManual"] @Localizer["HeaderBar.Help.Tutorial"] @@ -250,12 +250,7 @@ Localizer["Dialog.UploadLearningWorld.DialogText", world.Name].ToString() }, }; - var options = new DialogOptions - { - CloseButton = true, - CloseOnEscapeKey = true, - DisableBackdropClick = true, - }; + var options = CreateUnskippableDialogOptions(); var dialog = await DialogService.ShowAsync(Localizer["DialogService.UploadLearningWorld.Dialog"].ToString(), parameters, options); @@ -264,6 +259,13 @@ //if not cancelled, upload LearningWorld if (result.Canceled) return; + var existingLmsWorlds = await PresentationLogic.GetLmsWorldList(); + if (existingLmsWorlds.Any(lmsWorld => lmsWorld.WorldName == world.Name)) + { + var confirmReplaceOrCreateCopyAsync = await ConfirmReplaceOrCreateCopyAsync(world.Name, existingLmsWorlds); + if (!confirmReplaceOrCreateCopyAsync) return; + } + //present progress dialog var cancellationTokenSource = new CancellationTokenSource(); var progress = new Progress(); @@ -310,6 +312,45 @@ StateHasChanged(); } + private async Task ConfirmReplaceOrCreateCopyAsync(string worldName, List lmsWorlds) + { + var options = CreateUnskippableDialogOptions(); + var parameters = new DialogParameters + { + { "LmsWorldName", worldName } + }; + var replaceCopyLmsWorldDialog = await DialogService.ShowAsync(@Localizer["ConfirmReplaceOrCreateCopyDialog.Title"], parameters, options); + var result = await replaceCopyLmsWorldDialog.Result; + + if (result.Canceled) + { + return false; + } + + switch ((ReplaceCopyLmsWorldDialogResult)result.Data) + { + case ReplaceCopyLmsWorldDialogResult.Replace: + await PresentationLogic.DeleteLmsWorld(lmsWorlds.First(lmsWorld => lmsWorld.WorldName == worldName)); + break; + case ReplaceCopyLmsWorldDialogResult.Copy: + break; + } + + return true; + } + + private static DialogOptions CreateUnskippableDialogOptions() + { + var options = new DialogOptions + { + CloseButton = true, + CloseOnEscapeKey = true, + DisableBackdropClick = true, + }; + return options; + } + + private void ShowUploadSuccessfulDialog(UploadResponseViewModel response) { var options = new DialogOptions diff --git a/PresentationTest/View/HeaderBarUt.cs b/PresentationTest/View/HeaderBarUt.cs index 10f484694..974480432 100644 --- a/PresentationTest/View/HeaderBarUt.cs +++ b/PresentationTest/View/HeaderBarUt.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; @@ -19,6 +20,7 @@ using Presentation.Components; using Presentation.Components.Culture; using Presentation.Components.Dialogues; +using Presentation.PresentationLogic; using Presentation.PresentationLogic.API; using Presentation.PresentationLogic.AuthoringToolWorkspace; using Presentation.PresentationLogic.LearningContent.AdaptivityContent.Action; @@ -146,6 +148,7 @@ public void ExportButton_Clicked_LMSConnected_PositiveDialogResponse_CallsPresen world.LearningSpaces.Add(space); _selectedViewModelsProvider.LearningWorld.Returns(world); _presentationLogic.IsLmsConnected().Returns(true); + _presentationLogic.GetLmsWorldList().Returns(new List()); var dialogReference = Substitute.For(); dialogReference.Result.Returns(DialogResult.Ok(true)); _dialogService @@ -335,6 +338,7 @@ public void ExportButton_Clicked_ConstructBackupThrowsOperationCanceledException world.LearningSpaces.Add(space); _selectedViewModelsProvider.LearningWorld.Returns(world); _presentationLogic.IsLmsConnected().Returns(true); + _presentationLogic.GetLmsWorldList().Returns(new List()); _presentationLogic .ConstructAndUploadBackupAsync(world, Arg.Any>(), Arg.Any()) .Throws(new OperationCanceledException()); @@ -363,6 +367,7 @@ public void ExportButton_Clicked_ConstructBackupThrowsGeneratorException_ErrorSe world.LearningSpaces.Add(space); _selectedViewModelsProvider.LearningWorld.Returns(world); _presentationLogic.IsLmsConnected().Returns(true); + _presentationLogic.GetLmsWorldList().Returns(new List()); _presentationLogic .ConstructAndUploadBackupAsync(world, Arg.Any>(), Arg.Any()) .Throws(new GeneratorException()); @@ -439,6 +444,116 @@ public void ExportButton_Clicked_CancelDialog_Aborts() _snackbar.DidNotReceive().Add(Arg.Any(), Arg.Any()); } + [Test] + public void ExportButton_Clicked_ExistingWorld_Replace_CallsPresentationLogic() + { + var world = new LearningWorldViewModel("a", "f", "d", "e", "f", "d", "h", "i"); + var space = new LearningSpaceViewModel("a", "f", Theme.CampusAschaffenburg, 1); + var element = new LearningElementViewModel("a", null!, "s", "e", LearningElementDifficultyEnum.Easy, + ElementModel.l_h5p_blackboard_1, points: 1); + space.LearningSpaceLayout.LearningElements.Add(0, element); + world.LearningSpaces.Add(space); + _selectedViewModelsProvider.LearningWorld.Returns(world); + _presentationLogic.IsLmsConnected().Returns(true); + var lmsWorldList = new List + { new LmsWorldViewModel { WorldId = 1, WorldName = world.Name } }; + _presentationLogic.GetLmsWorldList().Returns(lmsWorldList); + var genericCancellationConfirmationDialogReference = Substitute.For(); + genericCancellationConfirmationDialogReference.Result.Returns(DialogResult.Ok(true)); + _dialogService + .ShowAsync(Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(genericCancellationConfirmationDialogReference); + + var replaceCopyLmsWorldDialogReference = Substitute.For(); + replaceCopyLmsWorldDialogReference.Result.Returns(DialogResult.Ok(ReplaceCopyLmsWorldDialogResult.Replace)); + _dialogService + .ShowAsync(Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(replaceCopyLmsWorldDialogReference); + var systemUnderTest = GetRenderedComponent(); + + var button = systemUnderTest.FindOrFail("button[title='3DWorld.Generate.Hover']"); + button.Click(); + _presentationLogic.Received().DeleteLmsWorld(lmsWorldList.First()); + _presentationLogic.Received() + .ConstructAndUploadBackupAsync(world, Arg.Any>(), Arg.Any()); + _snackbar.Received().Add("Export.SnackBar.Message", Arg.Any()); + } + + [Test] + public void ExportButton_Clicked_ExistingWorld_Copy_CallsPresentationLogic() + { + var world = new LearningWorldViewModel("a", "f", "d", "e", "f", "d", "h", "i"); + var space = new LearningSpaceViewModel("a", "f", Theme.CampusAschaffenburg, 1); + var element = new LearningElementViewModel("a", null!, "s", "e", LearningElementDifficultyEnum.Easy, + ElementModel.l_h5p_blackboard_1, points: 1); + space.LearningSpaceLayout.LearningElements.Add(0, element); + world.LearningSpaces.Add(space); + _selectedViewModelsProvider.LearningWorld.Returns(world); + _presentationLogic.IsLmsConnected().Returns(true); + var lmsWorldList = new List + { new LmsWorldViewModel { WorldId = 1, WorldName = world.Name } }; + _presentationLogic.GetLmsWorldList().Returns(lmsWorldList); + var genericCancellationConfirmationDialogReference = Substitute.For(); + genericCancellationConfirmationDialogReference.Result.Returns(DialogResult.Ok(true)); + _dialogService + .ShowAsync(Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(genericCancellationConfirmationDialogReference); + + var replaceCopyLmsWorldDialogReference = Substitute.For(); + replaceCopyLmsWorldDialogReference.Result.Returns(DialogResult.Ok(ReplaceCopyLmsWorldDialogResult.Copy)); + _dialogService + .ShowAsync(Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(replaceCopyLmsWorldDialogReference); + var systemUnderTest = GetRenderedComponent(); + + var button = systemUnderTest.FindOrFail("button[title='3DWorld.Generate.Hover']"); + button.Click(); + _presentationLogic.DidNotReceive().DeleteLmsWorld(lmsWorldList.First()); + _presentationLogic.Received() + .ConstructAndUploadBackupAsync(world, Arg.Any>(), Arg.Any()); + _snackbar.Received().Add("Export.SnackBar.Message", Arg.Any()); + } + + [Test] + public void ExportButton_Clicked_ExistingWorld_CancelReplaceCopyDialog_Aborts() + { + var world = new LearningWorldViewModel("a", "f", "d", "e", "f", "d", "h", "i"); + var space = new LearningSpaceViewModel("a", "f", Theme.CampusAschaffenburg, 1); + var element = new LearningElementViewModel("a", null!, "s", "e", LearningElementDifficultyEnum.Easy, + ElementModel.l_h5p_blackboard_1, points: 1); + space.LearningSpaceLayout.LearningElements.Add(0, element); + world.LearningSpaces.Add(space); + _selectedViewModelsProvider.LearningWorld.Returns(world); + _presentationLogic.IsLmsConnected().Returns(true); + var lmsWorldList = new List + { new LmsWorldViewModel { WorldId = 1, WorldName = world.Name } }; + _presentationLogic.GetLmsWorldList().Returns(lmsWorldList); + var genericCancellationConfirmationDialogReference = Substitute.For(); + genericCancellationConfirmationDialogReference.Result.Returns(DialogResult.Ok(true)); + _dialogService + .ShowAsync(Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(genericCancellationConfirmationDialogReference); + + var replaceCopyLmsWorldDialogReference = Substitute.For(); + replaceCopyLmsWorldDialogReference.Result.Returns(DialogResult.Cancel()); + _dialogService + .ShowAsync(Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(replaceCopyLmsWorldDialogReference); + var systemUnderTest = GetRenderedComponent(); + + var button = systemUnderTest.FindOrFail("button[title='3DWorld.Generate.Hover']"); + button.Click(); + _presentationLogic.DidNotReceive().DeleteLmsWorld(lmsWorldList.First()); + _presentationLogic.DidNotReceive() + .ConstructAndUploadBackupAsync(world, Arg.Any>(), Arg.Any()); + } + [Test] public void UndoButton_Clicked_CallsPresentationLogic() { diff --git a/Shared/ReplaceCopyLmsWorldDialogResult.cs b/Shared/ReplaceCopyLmsWorldDialogResult.cs new file mode 100644 index 000000000..5dd660f0c --- /dev/null +++ b/Shared/ReplaceCopyLmsWorldDialogResult.cs @@ -0,0 +1,7 @@ +namespace Shared; + +public enum ReplaceCopyLmsWorldDialogResult +{ + Replace, + Copy +} \ No newline at end of file