From 0b8046179bd54edfc05f448f2fbb33877b36ac88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Rua?= <140734849+joao4all@users.noreply.github.com> Date: Tue, 23 Jul 2024 16:16:11 +0100 Subject: [PATCH] Feat #701 Allow tabs to be reordered and move between split screens + #700 Limit split screen to 2 views max (#708) --- .../Tabs/TabsPanelComponentTestFixture.cs | 77 +++++++- COMETwebapp.Tests/Pages/TabsTestFixture.cs | 63 +++--- .../Pages/TabsViewModelTestFixture.cs | 55 +++--- .../Components/Shared/SortableList.razor | 35 ++++ .../Components/Shared/SortableList.razor.cs | 186 ++++++++++++++++++ .../Components/Shared/SortableList.razor.css | 13 ++ .../Components/Shared/SortableList.razor.js | 46 +++++ .../Components/Tabs/TabsPanelComponent.razor | 45 +++-- .../Tabs/TabsPanelComponent.razor.cs | 56 ++++-- COMETwebapp/Model/ITabHandler.cs | 37 ---- COMETwebapp/Model/TabPanelInformation.cs | 56 +++++- .../Model/TabbedApplicationInformation.cs | 5 - COMETwebapp/Pages/Tabs.razor | 26 ++- COMETwebapp/Pages/Tabs.razor.cs | 35 ++-- COMETwebapp/Pages/_Host.cshtml | 1 + .../SideBarEntry/ApplicationsSideBar.razor.cs | 7 +- .../ViewModels/Pages/ITabsViewModel.cs | 22 +-- COMETwebapp/ViewModels/Pages/TabsViewModel.cs | 135 ++++--------- COMETwebapp/wwwroot/Scripts/sortable.min.js | 2 + 19 files changed, 610 insertions(+), 292 deletions(-) create mode 100644 COMETwebapp/Components/Shared/SortableList.razor create mode 100644 COMETwebapp/Components/Shared/SortableList.razor.cs create mode 100644 COMETwebapp/Components/Shared/SortableList.razor.css create mode 100644 COMETwebapp/Components/Shared/SortableList.razor.js delete mode 100644 COMETwebapp/Model/ITabHandler.cs create mode 100644 COMETwebapp/wwwroot/Scripts/sortable.min.js diff --git a/COMETwebapp.Tests/Components/Tabs/TabsPanelComponentTestFixture.cs b/COMETwebapp.Tests/Components/Tabs/TabsPanelComponentTestFixture.cs index 1e3b8680..c714c88b 100644 --- a/COMETwebapp.Tests/Components/Tabs/TabsPanelComponentTestFixture.cs +++ b/COMETwebapp.Tests/Components/Tabs/TabsPanelComponentTestFixture.cs @@ -35,6 +35,7 @@ namespace COMETwebapp.Tests.Components.Tabs using COMET.Web.Common.Test.Helpers; using COMETwebapp.Components.EngineeringModel; + using COMETwebapp.Components.Shared; using COMETwebapp.Components.Tabs; using COMETwebapp.Model; using COMETwebapp.Utilities; @@ -63,6 +64,8 @@ public class TabsPanelComponentTestFixture private Mock viewModel; private Mock engineeringModelBodyViewModel; private Iteration iteration; + private TabPanelInformation mainPanel; + private TabPanelInformation sidePanel; [SetUp] public void SetUp() @@ -98,10 +101,17 @@ public void SetUp() this.viewModel = new Mock(); var openTabs = new SourceList(); openTabs.Add(new TabbedApplicationInformation(this.engineeringModelBodyViewModel.Object, typeof(EngineeringModelBody), this.iteration)); - this.viewModel.Setup(x => x.OpenTabs).Returns(openTabs); - this.viewModel.Setup(x => x.CurrentTab).Returns(openTabs.Items.First()); + this.sidePanel = new TabPanelInformation(); + + this.mainPanel = new TabPanelInformation + { + OpenTabs = openTabs, + CurrentTab = openTabs.Items.First() + }; + + this.viewModel.Setup(x => x.MainPanel).Returns(this.mainPanel); + this.viewModel.Setup(x => x.SidePanel).Returns(this.sidePanel); this.viewModel.Setup(x => x.SelectedApplication).Returns(engineeringModelBodyApplication); - this.viewModel.Setup(x => x.SidePanels).Returns(new SourceList()); var sessionService = new Mock(); sessionService.Setup(x => x.GetDomainOfExpertise(It.IsAny())).Returns(new DomainOfExpertise()); @@ -114,10 +124,9 @@ public void SetUp() this.renderer = this.context.RenderComponent(parameters => { parameters.Add(p => p.ViewModel, this.viewModel.Object); - parameters.Add(p => p.Handler, this.viewModel.Object); + parameters.Add(p => p.Panel, this.mainPanel); parameters.Add(p => p.CssClass, "css-test-class"); parameters.Add(p => p.IsSidePanelAvailable, true); - parameters.Add(p => p.Tabs, this.viewModel.Object.OpenTabs.Items.ToList()); }); } @@ -134,7 +143,12 @@ public async Task VerifyAddSidePanel() var sidePanelButton = this.renderer.FindComponents().First(x => x.Instance.Id == "new-side-panel-button"); await this.renderer.InvokeAsync(sidePanelButton.Instance.Click.InvokeAsync); - this.viewModel.VerifySet(x => x.CurrentTab = null, Times.Once); + Assert.Multiple(() => + { + Assert.That(this.viewModel.Object.SidePanel.OpenTabs, Has.Count.GreaterThan(0)); + Assert.That(this.viewModel.Object.SidePanel.CurrentTab, Is.Not.Null); + Assert.That(this.viewModel.Object.MainPanel.OpenTabs, Has.Count.EqualTo(0)); + }); } [Test] @@ -147,5 +161,56 @@ public void VerifyComponent() Assert.That(this.renderer.Markup, Does.Contain("css-test-class")); }); } + + [Test] + public async Task VerifyTabsOrdering() + { + var newTab = new TabbedApplicationInformation(this.engineeringModelBodyViewModel.Object, typeof(EngineeringModelBody), this.iteration); + this.mainPanel.OpenTabs.Add(newTab); + + Assert.Multiple(() => + { + Assert.That(this.mainPanel.OpenTabs, Has.Count.EqualTo(2)); + Assert.That(this.sidePanel.OpenTabs, Has.Count.EqualTo(0)); + Assert.That(this.mainPanel.OpenTabs.Items.First(), Is.Not.EqualTo(newTab)); + Assert.That(this.mainPanel.OpenTabs.Items.ElementAt(1), Is.EqualTo(newTab)); + }); + + var sortableList = this.renderer.FindComponent>(); + await this.renderer.InvokeAsync(() => sortableList.Instance.OnUpdate.InvokeAsync((0, 1))); + + Assert.Multiple(() => + { + Assert.That(this.mainPanel.OpenTabs, Has.Count.EqualTo(2)); + Assert.That(this.mainPanel.OpenTabs.Items.First(), Is.EqualTo(newTab)); + Assert.That(this.mainPanel.OpenTabs.Items.ElementAt(1), Is.Not.EqualTo(newTab)); + }); + + await this.renderer.InvokeAsync(() => sortableList.Instance.OnRemove.InvokeAsync((0, 0))); + this.sidePanel.CurrentTab = this.sidePanel.OpenTabs.Items.First(); + + Assert.Multiple(() => + { + Assert.That(this.mainPanel.OpenTabs, Has.Count.EqualTo(1)); + Assert.That(this.sidePanel.OpenTabs, Has.Count.EqualTo(1)); + Assert.That(this.mainPanel.OpenTabs.Items.First(), Is.Not.EqualTo(newTab)); + Assert.That(this.sidePanel.OpenTabs.Items.First(), Is.EqualTo(newTab)); + }); + + this.renderer.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Panel, this.sidePanel); + }); + + sortableList = this.renderer.FindComponent>(); + await this.renderer.InvokeAsync(() => sortableList.Instance.OnRemove.InvokeAsync((0, 0))); + + Assert.Multiple(() => + { + Assert.That(this.mainPanel.OpenTabs, Has.Count.EqualTo(2)); + Assert.That(this.sidePanel.OpenTabs, Has.Count.EqualTo(0)); + Assert.That(this.mainPanel.OpenTabs.Items.First(), Is.EqualTo(newTab)); + }); + } } } diff --git a/COMETwebapp.Tests/Pages/TabsTestFixture.cs b/COMETwebapp.Tests/Pages/TabsTestFixture.cs index 424f4b3b..a9ce1fb1 100644 --- a/COMETwebapp.Tests/Pages/TabsTestFixture.cs +++ b/COMETwebapp.Tests/Pages/TabsTestFixture.cs @@ -67,6 +67,7 @@ public class TabsTestFixture private Mock engineeringModelBodyViewModel; private IRenderedComponent renderer; private Iteration iteration; + private TabPanelInformation mainPanel; [SetUp] public void Setup() @@ -74,20 +75,6 @@ public void Setup() this.context = new TestContext(); var engineeringModelBodyApplication = Applications.ExistingApplications.OfType().First(x => x.Url == WebAppConstantValues.EngineeringModelPage); - this.viewModel = new Mock(); - - var sidePanels = new SourceList(); - sidePanels.Add(new TabPanelInformation()); - this.viewModel.Setup(x => x.OpenTabs).Returns(new SourceList()); - this.viewModel.Setup(x => x.SelectedApplication).Returns(engineeringModelBodyApplication); - this.viewModel.Setup(x => x.SidePanels).Returns(sidePanels); - - var optionsTableViewModel = new Mock(); - optionsTableViewModel.Setup(x => x.Rows).Returns(new SourceList()); - optionsTableViewModel.Setup(x => x.CurrentThing).Returns(new Option()); - this.engineeringModelBodyViewModel = new Mock(); - this.engineeringModelBodyViewModel.Setup(x => x.OptionsTableViewModel).Returns(optionsTableViewModel.Object); - var engineeringSetupModel = new EngineeringModelSetup(); this.iteration = new Iteration @@ -102,6 +89,26 @@ public void Setup() } }; + var optionsTableViewModel = new Mock(); + optionsTableViewModel.Setup(x => x.Rows).Returns(new SourceList()); + optionsTableViewModel.Setup(x => x.CurrentThing).Returns(new Option()); + this.engineeringModelBodyViewModel = new Mock(); + this.engineeringModelBodyViewModel.Setup(x => x.OptionsTableViewModel).Returns(optionsTableViewModel.Object); + + var openTabs = new SourceList(); + openTabs.Add(new TabbedApplicationInformation(this.engineeringModelBodyViewModel.Object, typeof(EngineeringModelBody), this.iteration)); + + this.mainPanel = new TabPanelInformation + { + OpenTabs = openTabs, + CurrentTab = openTabs.Items.First() + }; + + this.viewModel = new Mock(); + this.viewModel.Setup(x => x.MainPanel).Returns(this.mainPanel); + this.viewModel.Setup(x => x.SidePanel).Returns(new TabPanelInformation()); + this.viewModel.Setup(x => x.SelectedApplication).Returns(engineeringModelBodyApplication); + var configuration = new Mock(); configuration.Setup(x => x.ServerConfiguration).Returns(new ServerConfiguration()); @@ -129,22 +136,16 @@ public void Teardown() [Test] public async Task VerifyTabComponents() { - var openTabs = new SourceList(); - openTabs.Add(new TabbedApplicationInformation(this.engineeringModelBodyViewModel.Object, typeof(EngineeringModelBody), this.iteration)); - this.viewModel.Setup(x => x.OpenTabs).Returns(openTabs); - this.viewModel.Setup(x => x.CurrentTab).Returns(openTabs.Items.First()); - this.renderer.Render(); - var tabComponents = this.renderer.FindComponents(); var firstTab = tabComponents[0]; await this.renderer.InvokeAsync(firstTab.Instance.OnClick.Invoke); - this.viewModel.VerifySet(x => x.CurrentTab = openTabs.Items.First(), Times.Once); + Assert.That(this.viewModel.Object.MainPanel.CurrentTab, Is.EqualTo(this.mainPanel.OpenTabs.Items.First())); await this.renderer.InvokeAsync(firstTab.Instance.OnIconClick.Invoke); Assert.Multiple(() => { - this.viewModel.Verify(x => x.OpenTabs, Times.AtLeastOnce); + Assert.That(this.viewModel.Object.MainPanel.OpenTabs, Has.Count.EqualTo(0)); Assert.That(this.renderer.Instance.IsOpenTabVisible, Is.False); }); @@ -156,13 +157,6 @@ public async Task VerifyTabComponents() [Test] public async Task VerifyTabCustomButton() { - var openTabs = new SourceList(); - var tabToOpen = new TabbedApplicationInformation(this.engineeringModelBodyViewModel.Object, typeof(EngineeringModelBody), this.iteration); - openTabs.Add(tabToOpen); - this.viewModel.Setup(x => x.OpenTabs).Returns(openTabs); - this.viewModel.Setup(x => x.CurrentTab).Returns(tabToOpen); - this.renderer.Render(); - var tabCustomButton = this.renderer.FindComponents().First(x => x.Instance.Id == "tab-custom-option-button"); await this.renderer.InvokeAsync(tabCustomButton.Instance.Click.InvokeAsync); Assert.That(this.renderer.Instance.IsOpenTabVisible, Is.True); @@ -171,8 +165,7 @@ public async Task VerifyTabCustomButton() await this.renderer.InvokeAsync(openTabComponent.Instance.OnCancel.Invoke); Assert.That(this.renderer.Instance.IsOpenTabVisible, Is.False); - tabToOpen = new TabbedApplicationInformation(this.engineeringModelBodyViewModel.Object, typeof(EngineeringModelBody), null); - openTabs.ReplaceAt(0, tabToOpen); + this.mainPanel.OpenTabs.ReplaceAt(0, new TabbedApplicationInformation(this.engineeringModelBodyViewModel.Object, typeof(EngineeringModelBody), null)); this.renderer.Render(); var hasCustomOption = this.renderer.FindComponents().Any(x => x.Instance.Id == "tab-custom-option-button"); Assert.That(hasCustomOption, Is.False); @@ -181,6 +174,9 @@ public async Task VerifyTabCustomButton() [Test] public void VerifyTabsPage() { + this.viewModel.Setup(x => x.MainPanel).Returns(new TabPanelInformation()); + this.renderer.Render(); + var tabComponents = this.renderer.FindComponents(); var openTab = this.renderer.FindComponents(); @@ -190,10 +186,7 @@ public void VerifyTabsPage() Assert.That(openTab, Has.Count.EqualTo(1)); }); - var openTabs = new SourceList(); - openTabs.Add(new TabbedApplicationInformation(this.engineeringModelBodyViewModel.Object, typeof(EngineeringModelBody), this.iteration)); - this.viewModel.Setup(x => x.OpenTabs).Returns(openTabs); - this.viewModel.Setup(x => x.CurrentTab).Returns(openTabs.Items.First()); + this.viewModel.Setup(x => x.MainPanel).Returns(this.mainPanel); this.renderer.Render(); tabComponents = this.renderer.FindComponents(); diff --git a/COMETwebapp.Tests/ViewModels/Pages/TabsViewModelTestFixture.cs b/COMETwebapp.Tests/ViewModels/Pages/TabsViewModelTestFixture.cs index 40473fb3..cdcbd973 100644 --- a/COMETwebapp.Tests/ViewModels/Pages/TabsViewModelTestFixture.cs +++ b/COMETwebapp.Tests/ViewModels/Pages/TabsViewModelTestFixture.cs @@ -68,35 +68,35 @@ public void Setup() [Test] public void VerifyOnSelectedApplication() { - Assert.That(this.viewModel.CurrentTab, Is.Null); + Assert.That(this.viewModel.MainPanel.CurrentTab, Is.Null); - this.viewModel.OpenTabs.Add(new TabbedApplicationInformation(new Mock().Object, typeof(EngineeringModelBody), new Iteration())); + this.viewModel.MainPanel.OpenTabs.Add(new TabbedApplicationInformation(new Mock().Object, typeof(EngineeringModelBody), new Iteration())); this.viewModel.SelectedApplication = this.viewModel.AvailableApplications.FirstOrDefault(x => x.Url == WebAppConstantValues.EngineeringModelPage); - Assert.That(this.viewModel.CurrentTab, Is.Not.Null); + Assert.That(this.viewModel.MainPanel.CurrentTab, Is.Not.Null); } [Test] public void VerifyTabCreation() { var engineeringModelApplication = this.viewModel.AvailableApplications.First(x => x.Url == WebAppConstantValues.EngineeringModelPage); - this.viewModel.CreateNewTab(engineeringModelApplication, Guid.Empty); + this.viewModel.CreateNewTab(engineeringModelApplication, Guid.Empty, this.viewModel.MainPanel); Assert.Multiple(() => { - Assert.That(this.viewModel.CurrentTab, Is.Not.Null); - Assert.That(this.viewModel.CurrentTab.ObjectOfInterest, Is.TypeOf()); + Assert.That(this.viewModel.MainPanel.CurrentTab, Is.Not.Null); + Assert.That(this.viewModel.MainPanel.CurrentTab.ObjectOfInterest, Is.TypeOf()); Assert.That(this.viewModel.SelectedApplication, Is.Not.Null); - Assert.That(this.viewModel.OpenTabs, Has.Count.EqualTo(1)); + Assert.That(this.viewModel.MainPanel.OpenTabs, Has.Count.EqualTo(1)); }); var bookEditorApplication = this.viewModel.AvailableApplications.First(x => x.Url == WebAppConstantValues.BookEditorPage); - this.viewModel.CreateNewTab(bookEditorApplication, Guid.Empty); + this.viewModel.CreateNewTab(bookEditorApplication, Guid.Empty, this.viewModel.MainPanel); Assert.Multiple(() => { - Assert.That(this.viewModel.CurrentTab.ObjectOfInterest, Is.TypeOf()); - Assert.That(this.viewModel.OpenTabs, Has.Count.EqualTo(2)); + Assert.That(this.viewModel.MainPanel.CurrentTab.ObjectOfInterest, Is.TypeOf()); + Assert.That(this.viewModel.MainPanel.OpenTabs, Has.Count.EqualTo(2)); }); } @@ -105,27 +105,26 @@ public void VerifyTabRemoval() { var engineeringModelApplication1 = this.viewModel.AvailableApplications.First(x => x.Url == WebAppConstantValues.EngineeringModelPage); var engineeringModelApplication2 = this.viewModel.AvailableApplications.First(x => x.Url == WebAppConstantValues.EngineeringModelPage); - this.viewModel.CreateNewTab(engineeringModelApplication1, Guid.Empty); - this.viewModel.CreateNewTab(engineeringModelApplication2, Guid.Empty); - this.viewModel.SidePanels.Add(new TabPanelInformation()); + this.viewModel.CreateNewTab(engineeringModelApplication1, Guid.Empty, this.viewModel.MainPanel); + this.viewModel.CreateNewTab(engineeringModelApplication2, Guid.Empty, this.viewModel.MainPanel); - var removedTab = this.viewModel.OpenTabs.Items.ElementAt(1); + var removedTab = this.viewModel.MainPanel.OpenTabs.Items.ElementAt(1); - Assert.That(this.viewModel.CurrentTab, Is.EqualTo(removedTab)); - this.viewModel.OpenTabs.Remove(removedTab); + Assert.That(this.viewModel.MainPanel.CurrentTab, Is.EqualTo(removedTab)); + this.viewModel.MainPanel.OpenTabs.Remove(removedTab); Assert.Multiple(() => { - Assert.That(this.viewModel.CurrentTab, Is.Not.EqualTo(removedTab)); - Assert.That(this.viewModel.CurrentTab, Is.Not.Null); + Assert.That(this.viewModel.MainPanel.CurrentTab, Is.Not.EqualTo(removedTab)); + Assert.That(this.viewModel.MainPanel.CurrentTab, Is.Not.Null); }); - this.viewModel.CreateNewTab(engineeringModelApplication2, Guid.Empty); - this.viewModel.CreateNewTab(engineeringModelApplication2, Guid.Empty); - Assert.That(this.viewModel.OpenTabs, Has.Count.EqualTo(3)); + this.viewModel.CreateNewTab(engineeringModelApplication2, Guid.Empty, this.viewModel.MainPanel); + this.viewModel.CreateNewTab(engineeringModelApplication2, Guid.Empty, this.viewModel.MainPanel); + Assert.That(this.viewModel.MainPanel.OpenTabs, Has.Count.EqualTo(3)); - this.viewModel.OpenTabs.RemoveRange(1, 2); - Assert.That(this.viewModel.OpenTabs, Has.Count.EqualTo(1)); + this.viewModel.MainPanel.OpenTabs.RemoveRange(1, 2); + Assert.That(this.viewModel.MainPanel.OpenTabs, Has.Count.EqualTo(1)); } [Test] @@ -134,17 +133,17 @@ public void VerifyTabsOnSessionChanges() var engineeringModelApplication = this.viewModel.AvailableApplications.First(x => x.Url == WebAppConstantValues.EngineeringModelPage); var iteration = new Iteration(); - this.viewModel.CreateNewTab(engineeringModelApplication, iteration.Iid); + this.viewModel.CreateNewTab(engineeringModelApplication, iteration.Iid, this.viewModel.MainPanel); this.openIterations.Add(iteration); Assert.Multiple(() => { - Assert.That(this.viewModel.OpenTabs, Has.Count.EqualTo(1)); - Assert.That(this.viewModel.CurrentTab, Is.Not.Null); + Assert.That(this.viewModel.MainPanel.OpenTabs, Has.Count.EqualTo(1)); + Assert.That(this.viewModel.MainPanel.CurrentTab, Is.Not.Null); }); this.openIterations.Clear(); - Assert.That(this.viewModel.CurrentTab, Is.Null); + Assert.That(this.viewModel.MainPanel.CurrentTab, Is.Null); } [Test] @@ -154,7 +153,7 @@ public void VerifyViewModelProperties() { Assert.That(this.viewModel.AvailableApplications, Is.Not.Empty); Assert.That(this.viewModel.SelectedApplication, Is.Null); - Assert.That(this.viewModel.OpenTabs, Has.Count.EqualTo(0)); + Assert.That(this.viewModel.MainPanel.OpenTabs, Has.Count.EqualTo(0)); }); } } diff --git a/COMETwebapp/Components/Shared/SortableList.razor b/COMETwebapp/Components/Shared/SortableList.razor new file mode 100644 index 00000000..d223c55c --- /dev/null +++ b/COMETwebapp/Components/Shared/SortableList.razor @@ -0,0 +1,35 @@ + +@typeparam T +@inherits DisposableComponent + +
+ @foreach (var item in this.Items.Where(item => this.SortableItemTemplate is not null)) + { + var sortableItemTemplate = this.SortableItemTemplate; + + if (sortableItemTemplate != null) + { + @sortableItemTemplate(item) + } + } +
diff --git a/COMETwebapp/Components/Shared/SortableList.razor.cs b/COMETwebapp/Components/Shared/SortableList.razor.cs new file mode 100644 index 00000000..0c234b5e --- /dev/null +++ b/COMETwebapp/Components/Shared/SortableList.razor.cs @@ -0,0 +1,186 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2024 Starion Group S.A. +// +// Authors: Sam Gerené, Alex Vorobiev, Alexander van Delft, Jaime Bernar, Théate Antoine, João Rua +// +// This file is part of COMET WEB Community Edition +// The COMET WEB Community Edition is the Starion Group Web Application implementation of ECSS-E-TM-10-25 Annex A and Annex C. +// +// The COMET WEB Community Edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU Affero General Public +// License as published by the Free Software Foundation; either +// version 3 of the License, or (at your option) any later version. +// +// The COMET WEB Community Edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace COMETwebapp.Components.Shared +{ + using System.Diagnostics.CodeAnalysis; + + using COMET.Web.Common.Components; + + using Microsoft.AspNetCore.Components; + using Microsoft.JSInterop; + + /// + /// Class used to support the sortable list razor component + /// + public partial class SortableList : DisposableComponent + { + /// + /// The self + /// + private DotNetObjectReference> selfReference; + + /// + /// The injected + /// + [Inject] + public IJSRuntime JsRuntime { get; set; } + + /// + /// The sortable item template to be displayed in the listing + /// + [Parameter] + public RenderFragment SortableItemTemplate { get; set; } + + /// + /// The list of items to be displayed + /// + [Parameter] + [AllowNull] + public List Items { get; set; } + + /// + /// Callback for when an item is updated in the list + /// + [Parameter] + public EventCallback<(int oldIndex, int newIndex)> OnUpdate { get; set; } + + /// + /// Callback for when an item is removed from the list + /// + [Parameter] + public EventCallback<(int oldIndex, int newIndex)> OnRemove { get; set; } + + /// + /// The unique identifier for the list + /// + [Parameter] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// The group where the list belongs + /// + [Parameter] + public string Group { get; set; } = Guid.NewGuid().ToString(); + + /// + /// The pull parameter data + /// + [Parameter] + public string Pull { get; set; } + + /// + /// The condition to check if the put option should be available + /// + [Parameter] + public bool Put { get; set; } = true; + + /// + /// The sort parameter for list sorting + /// + [Parameter] + public bool Sort { get; set; } = true; + + /// + /// The handle for list events + /// + [Parameter] + public string Handle { get; set; } = string.Empty; + + /// + /// The filter for the list data + /// + [Parameter] + public string Filter { get; set; } + + /// + /// Condition to check if a fallback should be forced + /// + [Parameter] + public bool ForceFallback { get; set; } = true; + + /// + /// Gets or sets the custom css class to be applied in the container component + /// + [Parameter] + public string CssClass { get; set; } + + /// + /// Method used to update an item from the list, invoking the OnUpdate event and passing the old and new indexes + /// + /// The old item index + /// The new item index + [JSInvokable] + public void OnUpdateJS(int oldIndex, int newIndex) + { + this.OnUpdate.InvokeAsync((oldIndex, newIndex)); + } + + /// + /// Method used to remove an item from the list + /// + /// The old item index + /// The new item index + [JSInvokable] + public void OnRemoveJS(int oldIndex, int newIndex) + { + this.OnRemove.InvokeAsync((oldIndex, newIndex)); + } + + /// + /// Method invoked after each time the component has been rendered interactively and the UI has finished + /// updating (for example, after elements have been added to the browser DOM). Any + /// + /// fields will be populated by the time this runs. + /// This method is not invoked during prerendering or server-side rendering, because those processes + /// are not attached to any live browser DOM and are already complete before the DOM is updated. + /// Note that the component does not automatically re-render after the completion of any returned + /// , + /// because that would cause an infinite render loop. + /// + /// + /// Set to true if this is the first time + /// has been invoked + /// on this component instance; otherwise false. + /// + /// A representing any asynchronous operation. + /// + /// The and + /// lifecycle methods + /// are useful for performing interop, or interacting with values received from @ref. + /// Use the parameter to ensure that initialization work is only performed + /// once. + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + this.selfReference = DotNetObjectReference.Create(this); + this.Disposables.Add(this.selfReference); + var module = await this.JsRuntime.InvokeAsync("import", "./Components/Shared/SortableList.razor.js"); + await module.InvokeAsync("init", this.Id, this.Group, this.Pull, this.Put, this.Sort, this.Handle, this.Filter, this.selfReference, this.ForceFallback); + } + } + } +} diff --git a/COMETwebapp/Components/Shared/SortableList.razor.css b/COMETwebapp/Components/Shared/SortableList.razor.css new file mode 100644 index 00000000..ff69fa3f --- /dev/null +++ b/COMETwebapp/Components/Shared/SortableList.razor.css @@ -0,0 +1,13 @@ +/* + you need the ::deep identifier if you are using scoped styles like this + because scoped styles are only applied to markup in the component, not + to the markup inside the render fragment. +*/ + +::deep .sortable-ghost { + visibility: hidden; +} + +::deep .sortable-fallback { + opacity: 1 !important +} \ No newline at end of file diff --git a/COMETwebapp/Components/Shared/SortableList.razor.js b/COMETwebapp/Components/Shared/SortableList.razor.js new file mode 100644 index 00000000..de0893cd --- /dev/null +++ b/COMETwebapp/Components/Shared/SortableList.razor.js @@ -0,0 +1,46 @@ +/** + * Method used to initialize the sortable list functionalities + * @param {any} id + * @param {any} group + * @param {any} pull + * @param {any} put + * @param {any} sort + * @param {any} handle + * @param {any} filter + * @param {any} component + * @param {any} forceFallback + */ +export function init(id, group, pull, put, sort, handle, filter, component, forceFallback) { + Sortable.create(document.getElementById(id), { + animation: 200, + group: { + name: group, + pull: pull || true, + put: put + }, + filter: filter || undefined, + sort: sort, + forceFallback: forceFallback, + handle: handle || undefined, + onUpdate: (event) => { + // Revert the DOM to match the .NET state + event.item.remove(); + event.to.insertBefore(event.item, event.to.childNodes[event.oldIndex]); + + // Notify .NET to update its model and re-render + component.invokeMethodAsync('OnUpdateJS', event.oldDraggableIndex, event.newDraggableIndex); + }, + onRemove: (event) => { + if (event.pullMode === 'clone') { + // Remove the clone + event.clone.remove(); + } + + event.item.remove(); + event.from.insertBefore(event.item, event.from.childNodes[event.oldIndex]); + + // Notify .NET to update its model and re-render + component.invokeMethodAsync('OnRemoveJS', event.oldDraggableIndex, event.newDraggableIndex); + } + }); +} diff --git a/COMETwebapp/Components/Tabs/TabsPanelComponent.razor b/COMETwebapp/Components/Tabs/TabsPanelComponent.razor index f774bf9b..f791ba6a 100644 --- a/COMETwebapp/Components/Tabs/TabsPanelComponent.razor +++ b/COMETwebapp/Components/Tabs/TabsPanelComponent.razor @@ -21,27 +21,34 @@ -------------------------------------------------------------------------------> @using COMETwebapp.Model @using CDP4Common.CommonData + @inherits DisposableComponent
- @if (this.Handler.CurrentTab is not null) + @if (this.Panel.CurrentTab is not null) { -
+
- @foreach (var tab in this.Tabs) - { - - } + + + + +
- +
}
diff --git a/COMETwebapp/Components/Tabs/TabsPanelComponent.razor.cs b/COMETwebapp/Components/Tabs/TabsPanelComponent.razor.cs index a62908dd..10d65230 100644 --- a/COMETwebapp/Components/Tabs/TabsPanelComponent.razor.cs +++ b/COMETwebapp/Components/Tabs/TabsPanelComponent.razor.cs @@ -53,7 +53,7 @@ public partial class TabsPanelComponent : DisposableComponent /// Gets or sets the tab handler to be used /// [Parameter] - public ITabHandler Handler { get; set; } + public TabPanelInformation Panel { get; set; } /// /// Gets or sets the @@ -61,12 +61,6 @@ public partial class TabsPanelComponent : DisposableComponent [Parameter] public ITabsViewModel ViewModel { get; set; } - /// - /// Gets or sets the tabs to be displayed - /// - [Parameter] - public List Tabs { get; set; } = []; - /// /// Gets or sets the method to be executed when the open tab button is clicked /// @@ -89,7 +83,7 @@ public partial class TabsPanelComponent : DisposableComponent /// Gets or sets the method to be executed when the tab is clicked /// [Parameter] - public EventCallback<(TabbedApplicationInformation, ITabHandler)> OnTabClick { get; set; } + public EventCallback<(TabbedApplicationInformation, TabPanelInformation)> OnTabClick { get; set; } /// /// Gets or sets the condition to check if the side panel should be available @@ -103,6 +97,38 @@ public partial class TabsPanelComponent : DisposableComponent [Inject] public ISessionService SessionService { get; set; } + /// + /// Sorts the tabs by the means of drag and drop + /// + /// The dragged tab old index + /// The dragged tab new index + /// A + private void SortTabs(int oldIndex, int newIndex) + { + this.Panel.OpenTabs.Move(oldIndex, newIndex); + } + + /// + /// Handles the logic to organize data when a tab is moved from one panel to another + /// + /// The dragged tab old panel index + /// The dragged tab new panel index + private void OnMovedTab(int oldIndex, int newIndex) + { + var tab = this.Panel.OpenTabs.Items.ElementAt(oldIndex); + + if (this.Panel == this.ViewModel.MainPanel) + { + this.ViewModel.SidePanel.OpenTabs.Insert(newIndex, tab); + } + else + { + this.ViewModel.MainPanel.OpenTabs.Insert(newIndex, tab); + } + + this.Panel.OpenTabs.Remove(tab); + } + /// /// Gets the tab text for the given object of interest /// @@ -158,16 +184,10 @@ private string GetCaptionText(object objectOfInterest) /// private void AddSidePanel() { - var currentTab = this.ViewModel.CurrentTab; - - var newPanel = new TabPanelInformation - { - CurrentTab = currentTab - }; - - currentTab.Panel = newPanel; - this.ViewModel.SidePanels.Add(newPanel); - this.ViewModel.CurrentTab = this.ViewModel.OpenTabs.Items.FirstOrDefault(x => x.Panel == null); + var currentTab = this.ViewModel.MainPanel.CurrentTab; + this.ViewModel.SidePanel.OpenTabs.Add(currentTab); + this.ViewModel.SidePanel.CurrentTab = currentTab; + this.ViewModel.MainPanel.OpenTabs.Remove(currentTab); } } } diff --git a/COMETwebapp/Model/ITabHandler.cs b/COMETwebapp/Model/ITabHandler.cs deleted file mode 100644 index 2ca83d55..00000000 --- a/COMETwebapp/Model/ITabHandler.cs +++ /dev/null @@ -1,37 +0,0 @@ -// -------------------------------------------------------------------------------------------------------------------- -// -// Copyright (c) 2024 Starion Group S.A. -// -// Authors: Sam Gerené, Alex Vorobiev, Alexander van Delft, Jaime Bernar, Théate Antoine, João Rua -// -// This file is part of COMET WEB Community Edition -// The COMET WEB Community Edition is the Starion Group Web Application implementation of ECSS-E-TM-10-25 Annex A and Annex C. -// -// The COMET WEB Community Edition is free software; you can redistribute it and/or -// modify it under the terms of the GNU Affero General Public -// License as published by the Free Software Foundation; either -// version 3 of the License, or (at your option) any later version. -// -// The COMET WEB Community Edition is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -// -// -------------------------------------------------------------------------------------------------------------------- - -namespace COMETwebapp.Model -{ - /// - /// The provides properties to be used by classes that handle tabs - /// - public interface ITabHandler - { - /// - /// Gets or sets the current tab - /// - TabbedApplicationInformation CurrentTab { get; set; } - } -} diff --git a/COMETwebapp/Model/TabPanelInformation.cs b/COMETwebapp/Model/TabPanelInformation.cs index 3ee8e30a..ed92bc9b 100644 --- a/COMETwebapp/Model/TabPanelInformation.cs +++ b/COMETwebapp/Model/TabPanelInformation.cs @@ -24,21 +24,73 @@ namespace COMETwebapp.Model { + using COMET.Web.Common.Utilities.DisposableObject; + + using DynamicData; + + using ReactiveUI; + /// /// The provides required information related to a panel /// - public class TabPanelInformation : ITabHandler + public class TabPanelInformation : DisposableObject { + /// + /// Backing field for the property + /// + private TabbedApplicationInformation currentTab; + /// /// Initializes a new instance of the class. /// public TabPanelInformation() { + this.Disposables.Add(this.OpenTabs.Connect().WhereReasonsAre(ListChangeReason.Remove, ListChangeReason.RemoveRange).Subscribe(this.OnOpenTabRemoved)); } /// /// Gets or sets the current tab /// - public TabbedApplicationInformation CurrentTab { get; set; } + public TabbedApplicationInformation CurrentTab + { + get => this.currentTab; + set => this.RaiseAndSetIfChanged(ref this.currentTab, value); + } + + /// + /// Gets the collection of all contained by the panel + /// + public SourceList OpenTabs { get; set; } = new(); + + /// + /// Method executed when one or more open tabs are removed + /// + /// The change set containing the removed + private void OnOpenTabRemoved(IChangeSet changeSet) + { + foreach (var result in changeSet.ToList()) + { + if (result.Range.Count > 0) + { + foreach (var tabToRemove in result.Range) + { + tabToRemove.ApplicationBaseViewModel.IsAllowedToDispose = true; + } + } + else + { + result.Item.Current.ApplicationBaseViewModel.IsAllowedToDispose = true; + } + } + + var wasCurrentTabRemoved = changeSet + .Select(x => x.Item.Current) + .Contains(this.CurrentTab); + + if (wasCurrentTabRemoved) + { + this.CurrentTab = this.OpenTabs.Items.FirstOrDefault(); + } + } } } diff --git a/COMETwebapp/Model/TabbedApplicationInformation.cs b/COMETwebapp/Model/TabbedApplicationInformation.cs index 276fab50..c80f172d 100644 --- a/COMETwebapp/Model/TabbedApplicationInformation.cs +++ b/COMETwebapp/Model/TabbedApplicationInformation.cs @@ -58,10 +58,5 @@ public TabbedApplicationInformation(IApplicationBaseViewModel applicationBaseVie /// Gets the object of interest /// public object ObjectOfInterest { get; } - - /// - /// Gets or sets the - /// - public TabPanelInformation Panel { get; set; } } } diff --git a/COMETwebapp/Pages/Tabs.razor b/COMETwebapp/Pages/Tabs.razor index 85e40a22..225395e6 100644 --- a/COMETwebapp/Pages/Tabs.razor +++ b/COMETwebapp/Pages/Tabs.razor @@ -25,33 +25,31 @@ @inherits DisposableComponent
- @if (this.ViewModel.CurrentTab is not null) + @if (this.ViewModel.MainPanel.CurrentTab is not null) { - + OnRemoveTabClick="@(tab => OnRemoveTabClick(tab, this.ViewModel.MainPanel))" + OnOpenTabClick="@(() => this.OnOpenTabClick(this.ViewModel.MainPanel))" + IsSidePanelAvailable="@(this.ViewModel.SidePanel.OpenTabs.Count == 0)"/> - @foreach (var panelsGrouping in this.OpenTabs.Where(x => x.Panel is not null).GroupBy(x => x.Panel)) + @if (this.ViewModel.SidePanel.CurrentTab is not null) { - + OnCreateTabForModel="@(tab => this.OnCreateTabForModel(tab))" + OnRemoveTabClick="@(tab => OnRemoveTabClick(tab, this.ViewModel.SidePanel))" + OnOpenTabClick="@(() => this.OnOpenTabClick(this.ViewModel.SidePanel))" /> } } else {
- +
} @@ -65,7 +63,7 @@ Closed="@(() => this.ResetOpenTabPopup())"> diff --git a/COMETwebapp/Pages/Tabs.razor.cs b/COMETwebapp/Pages/Tabs.razor.cs index 22eec333..096f3abd 100644 --- a/COMETwebapp/Pages/Tabs.razor.cs +++ b/COMETwebapp/Pages/Tabs.razor.cs @@ -44,14 +44,9 @@ namespace COMETwebapp.Pages public partial class Tabs { /// - /// Gets or sets the selected side panel + /// Gets or sets the selected panel /// - private TabPanelInformation SelectedSidePanel { get; set; } - - /// - /// Collection of open tabs that belong from view model sourcelist - /// - private IEnumerable OpenTabs => this.ViewModel.OpenTabs.Items; + private TabPanelInformation SelectedPanel { get; set; } /// /// The model id to fill the opentab form, if needed @@ -95,29 +90,32 @@ protected override void OnInitialized() this.Disposables.Add(this.WhenAnyValue( x => x.ViewModel.SelectedApplication, - x => x.ViewModel.CurrentTab) + x => x.ViewModel.MainPanel.CurrentTab, + x => x.ViewModel.SidePanel.CurrentTab) .SubscribeAsync(_ => this.InvokeAsync(this.StateHasChanged))); - this.Disposables.Add(this.ViewModel.OpenTabs.CountChanged.SubscribeAsync(_ => this.InvokeAsync(this.StateHasChanged))); + this.Disposables.Add(this.ViewModel.MainPanel.OpenTabs.Connect().SubscribeAsync(_ => this.InvokeAsync(this.StateHasChanged))); + this.Disposables.Add(this.ViewModel.SidePanel.OpenTabs.Connect().SubscribeAsync(_ => this.InvokeAsync(this.StateHasChanged))); } /// /// Method executed when a tab is clicked /// /// The tab to be set - /// The tab handler to handle the tab click - private static void OnTabClick(TabbedApplicationInformation tabbedApplicationInformation, ITabHandler tabHandler) + /// The tab panel to handle the tab click + private static void OnTabClick(TabbedApplicationInformation tabbedApplicationInformation, TabPanelInformation panel) { - tabHandler.CurrentTab = tabbedApplicationInformation; + panel.CurrentTab = tabbedApplicationInformation; } /// /// Method executed when the remove tab button is clicked /// /// The tab to be removed - private void OnRemoveTabClick(TabbedApplicationInformation tabbedApplicationInformation) + /// The tab panel to handle the tab click + private static void OnRemoveTabClick(TabbedApplicationInformation tabbedApplicationInformation, TabPanelInformation panel) { - this.ViewModel.OpenTabs.Remove(tabbedApplicationInformation); + panel.OpenTabs.Remove(tabbedApplicationInformation); } /// @@ -133,10 +131,10 @@ private void SetOpenTabVisibility(bool visibility) /// /// Method executed when the open tab button is clicked /// - /// The side panel to be set, if any - private void OnOpenTabClick(TabPanelInformation sidePanel = null) + /// The panel to be set + private void OnOpenTabClick(TabPanelInformation panel) { - this.SelectedSidePanel = sidePanel; + this.SelectedPanel = panel; this.SetOpenTabVisibility(true); } @@ -168,6 +166,9 @@ private void OnCreateTabForModel(TabbedApplicationInformation tabbedApplicationI return; } + var isTabFromMainPanel = this.ViewModel.MainPanel.OpenTabs.Items.Contains(tabbedApplicationInformation); + this.SelectedPanel = isTabFromMainPanel ? this.ViewModel.MainPanel : this.ViewModel.SidePanel; + this.IterationId = iterationOfInterest.Iid; this.ModelId = ((CDP4Common.EngineeringModelData.EngineeringModel)iterationOfInterest.Container).Iid; this.DomainId = this.SessionService.GetDomainOfExpertise(iterationOfInterest).Iid; diff --git a/COMETwebapp/Pages/_Host.cshtml b/COMETwebapp/Pages/_Host.cshtml index 4e9fdcad..c088a830 100644 --- a/COMETwebapp/Pages/_Host.cshtml +++ b/COMETwebapp/Pages/_Host.cshtml @@ -95,6 +95,7 @@
+ diff --git a/COMETwebapp/Shared/SideBarEntry/ApplicationsSideBar.razor.cs b/COMETwebapp/Shared/SideBarEntry/ApplicationsSideBar.razor.cs index 00716ff6..241c2bc5 100644 --- a/COMETwebapp/Shared/SideBarEntry/ApplicationsSideBar.razor.cs +++ b/COMETwebapp/Shared/SideBarEntry/ApplicationsSideBar.razor.cs @@ -81,9 +81,10 @@ protected override void OnInitialized() base.OnInitialized(); this.NavigationManager.LocationChanged += this.OnLocationChanged; - this.Disposables.Add(this.WhenAnyValue(x => - x.TabsViewModel.SelectedApplication, - x => x.TabsViewModel.CurrentTab + this.Disposables.Add(this.WhenAnyValue( + x => x.TabsViewModel.SelectedApplication, + x => x.TabsViewModel.MainPanel.CurrentTab, + x => x.TabsViewModel.SidePanel.CurrentTab ).SubscribeAsync(_ => this.InvokeAsync(this.StateHasChanged))); } diff --git a/COMETwebapp/ViewModels/Pages/ITabsViewModel.cs b/COMETwebapp/ViewModels/Pages/ITabsViewModel.cs index 0d46bffe..ce01cf4d 100644 --- a/COMETwebapp/ViewModels/Pages/ITabsViewModel.cs +++ b/COMETwebapp/ViewModels/Pages/ITabsViewModel.cs @@ -28,18 +28,11 @@ namespace COMETwebapp.ViewModels.Pages using COMETwebapp.Model; - using DynamicData; - /// /// The contains logic and behavior that are required to support multi-tabs application /// - public interface ITabsViewModel : ITabHandler + public interface ITabsViewModel { - /// - /// Gets the collection of all - /// - SourceList OpenTabs { get; } - /// /// Gets the collection of available /// @@ -51,9 +44,14 @@ public interface ITabsViewModel : ITabHandler TabbedApplication SelectedApplication { get; set; } /// - /// Gets the collection of all s + /// Gets the side tab panel information + /// + TabPanelInformation SidePanel { get; } + + /// + /// Gets the main tab panel information /// - SourceList SidePanels { get; } + TabPanelInformation MainPanel { get; } /// /// Creates a new tab and sets it to current @@ -63,7 +61,7 @@ public interface ITabsViewModel : ITabHandler /// The id of the object of interest, which can be an or an /// /// - /// The panel to open the new tab in - void CreateNewTab(TabbedApplication application, Guid objectOfInterestId, TabPanelInformation sidePanel = null); + /// The panel to open the new tab in + void CreateNewTab(TabbedApplication application, Guid objectOfInterestId, TabPanelInformation panel); } } diff --git a/COMETwebapp/ViewModels/Pages/TabsViewModel.cs b/COMETwebapp/ViewModels/Pages/TabsViewModel.cs index eb7c8175..5565d515 100644 --- a/COMETwebapp/ViewModels/Pages/TabsViewModel.cs +++ b/COMETwebapp/ViewModels/Pages/TabsViewModel.cs @@ -51,11 +51,6 @@ public class TabsViewModel : DisposableObject, ITabsViewModel /// private readonly ISessionService sessionService; - /// - /// Backing field for - /// - private TabbedApplicationInformation currentTab; - /// /// Backing field for /// @@ -71,29 +66,25 @@ public TabsViewModel(ISessionService sessionService, IServiceProvider servicePro this.sessionService = sessionService; this.serviceProvider = serviceProvider; this.Disposables.Add(this.WhenAnyValue(x => x.SelectedApplication).Subscribe(_ => this.OnSelectedApplicationChange())); - this.Disposables.Add(this.WhenAnyValue(x => x.CurrentTab).Subscribe(_ => this.OnCurrentTabChange())); + this.Disposables.Add(this.WhenAnyValue(x => x.MainPanel.CurrentTab).Subscribe(_ => this.OnCurrentTabChange(this.MainPanel))); + this.Disposables.Add(this.WhenAnyValue(x => x.SidePanel.CurrentTab).Subscribe(_ => this.OnCurrentTabChange(this.SidePanel))); this.Disposables.Add(this.sessionService.OpenIterations.CountChanged.Subscribe(this.CloseTabIfIterationClosed)); - this.Disposables.Add(this.OpenTabs.Connect().WhereReasonsAre(ListChangeReason.Remove, ListChangeReason.RemoveRange).Subscribe(this.OnOpenTabRemoved)); } /// /// Gets the collection of all /// - public SourceList OpenTabs { get; } = new(); + private IEnumerable OpenTabs => [.. this.MainPanel.OpenTabs.Items, .. this.SidePanel.OpenTabs.Items]; /// - /// Gets the collection of all s + /// Gets the side tab panel information /// - public SourceList SidePanels { get; } = new(); + public TabPanelInformation SidePanel { get; } = new(); /// - /// Gets or sets the current tab + /// Gets the main tab panel information /// - public TabbedApplicationInformation CurrentTab - { - get => this.currentTab; - set => this.RaiseAndSetIfChanged(ref this.currentTab, value); - } + public TabPanelInformation MainPanel { get; } = new(); /// /// Gets the collection of available @@ -117,8 +108,8 @@ public TabbedApplication SelectedApplication /// The id of the object of interest, which can be an or an /// /// - /// The panel to open the new tab in - public void CreateNewTab(TabbedApplication application, Guid objectOfInterestId, TabPanelInformation sidePanel = null) + /// The panel to open the new tab in + public void CreateNewTab(TabbedApplication application, Guid objectOfInterestId, TabPanelInformation panel) { if (this.serviceProvider.GetService(application.ViewModelType) is not IApplicationBaseViewModel viewModel) { @@ -139,18 +130,9 @@ public void CreateNewTab(TabbedApplication application, Guid objectOfInterestId, } var tabToCreate = new TabbedApplicationInformation(viewModel, application.ComponentType, thingOfInterest); - this.OpenTabs.Add(tabToCreate); + panel.OpenTabs.Add(tabToCreate); this.SelectedApplication = application; - - if (sidePanel == null) - { - this.CurrentTab = tabToCreate; - } - else - { - sidePanel.CurrentTab = tabToCreate; - tabToCreate.Panel = sidePanel; - } + panel.CurrentTab = tabToCreate; } /// @@ -158,25 +140,42 @@ public void CreateNewTab(TabbedApplication application, Guid objectOfInterestId, /// private void OnSelectedApplicationChange() { - if (this.SelectedApplication == null || this.CurrentTab?.ComponentType == this.SelectedApplication?.ComponentType) + if (this.SelectedApplication == null || this.MainPanel.CurrentTab?.ComponentType == this.SelectedApplication?.ComponentType) { return; } - this.CurrentTab = this.OpenTabs.Items.FirstOrDefault(x => x.ComponentType == this.SelectedApplication.ComponentType && x.Panel == null); + var mainPanelTabForCurrentApplication = this.MainPanel.OpenTabs.Items.FirstOrDefault(x => x.ComponentType == this.SelectedApplication.ComponentType); + var sidePanelTabForCurrentApplication = this.SidePanel.OpenTabs.Items.FirstOrDefault(x => x.ComponentType == this.SelectedApplication.ComponentType); + + if (mainPanelTabForCurrentApplication != null) + { + this.MainPanel.CurrentTab = mainPanelTabForCurrentApplication; + } + + if (sidePanelTabForCurrentApplication != null) + { + this.SidePanel.CurrentTab = sidePanelTabForCurrentApplication; + } + + if (sidePanelTabForCurrentApplication == null && mainPanelTabForCurrentApplication == null) + { + this.MainPanel.CurrentTab = null; + } } /// - /// Method executed everytime the changes + /// Method executed everytime the changes /// - private void OnCurrentTabChange() + /// The panel where the current tab changed + private void OnCurrentTabChange(TabPanelInformation panel) { - if (this.CurrentTab == null) + if (panel.CurrentTab == null) { return; } - this.SelectedApplication = Applications.ExistingApplications.OfType().FirstOrDefault(x => x.ComponentType == this.CurrentTab.ComponentType); + this.SelectedApplication = Applications.ExistingApplications.OfType().FirstOrDefault(x => x.ComponentType == panel.CurrentTab.ComponentType); } /// @@ -185,73 +184,17 @@ private void OnCurrentTabChange() /// The new number of open iterations private void CloseTabIfIterationClosed(int numberOfIterations) { - var iterationTabsToClose = this.OpenTabs.Items + var iterationTabsToClose = this.OpenTabs .Where(x => x.ObjectOfInterest is Iteration && !this.sessionService.OpenIterations.Items.Contains(x.ObjectOfInterest)) .ToList(); - var engineeringModelTabsToClose = this.OpenTabs.Items + var engineeringModelTabsToClose = this.OpenTabs .Where(x => x.ObjectOfInterest is EngineeringModel && !this.sessionService.OpenEngineeringModels.Contains(x.ObjectOfInterest)) .ToList(); - this.OpenTabs.RemoveMany([.. iterationTabsToClose, .. engineeringModelTabsToClose]); - - if (numberOfIterations == 0) - { - this.CurrentTab = null; - } - } - - /// - /// Method executed when one or more open tabs are removed - /// - /// The change set containing the removed - private void OnOpenTabRemoved(IChangeSet changeSet) - { - foreach (var result in changeSet.ToList()) - { - if (result.Range.Count > 0) - { - foreach (var tabToRemove in result.Range) - { - tabToRemove.ApplicationBaseViewModel.IsAllowedToDispose = true; - } - } - else - { - result.Item.Current.ApplicationBaseViewModel.IsAllowedToDispose = true; - } - } - - this.SetCurrentTabAfterTabRemoval(changeSet, this); - - foreach (var panel in this.SidePanels.Items) - { - this.SetCurrentTabAfterTabRemoval(changeSet, panel); - } - } - - /// - /// Sets the current tab in a after a tab removal, if needed - /// - /// The change set to be used to check deletions - /// The to set its current tab - private void SetCurrentTabAfterTabRemoval(IChangeSet changeSet, ITabHandler handler) - { - var wasCurrentTabRemoved = changeSet - .Select(x => x.Item.Current) - .Contains(handler.CurrentTab); - - var selectedSidePanel = handler is TabPanelInformation ? handler : null; - - if (wasCurrentTabRemoved) - { - handler.CurrentTab = this.OpenTabs.Items.FirstOrDefault(x => x.Panel == selectedSidePanel); - } - - if (selectedSidePanel != null && handler.CurrentTab == null) - { - this.SidePanels.Remove((TabPanelInformation)handler); - } + List thingTabsToClose = [.. iterationTabsToClose, .. engineeringModelTabsToClose]; + this.MainPanel.OpenTabs.RemoveMany(thingTabsToClose); + this.SidePanel.OpenTabs.RemoveMany(thingTabsToClose); } } } diff --git a/COMETwebapp/wwwroot/Scripts/sortable.min.js b/COMETwebapp/wwwroot/Scripts/sortable.min.js new file mode 100644 index 00000000..4fe7f0c3 --- /dev/null +++ b/COMETwebapp/wwwroot/Scripts/sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.13.0 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e"===e[0]&&(e=e.substring(1)),t)try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return!1}return!1}}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"===e[0]?t.parentNode===n&&h(t,e):h(t,e))||o&&t===n)return t;if(t===n)break}while(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode)}var i;return null}var f,p=/\s+/g;function k(t,e,n){if(t&&e)if(t.classList)t.classList[n?"add":"remove"](e);else{var o=(" "+t.className+" ").replace(p," ").replace(" "+e+" "," ");t.className=(o+(n?" "+e:"")).replace(p," ")}}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];e in o||-1!==e.indexOf("webkit")||(e="-webkit-"+e),o[e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform");o&&"none"!==o&&(n=o+" "+n)}while(!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function g(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=e.left-n&&r<=e.right+n,i=a>=e.top-n&&a<=e.bottom+n;return n&&o&&i?l=t:void 0}}),l}((t=t.touches?t.touches[0]:t).clientX,t.clientY);if(e){var n={};for(var o in t)t.hasOwnProperty(o)&&(n[o]=t[o]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[j]._onDragOver(n)}}}function kt(t){z&&z.parentNode[j]._isOutsideThisEl(t.target)}function Rt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[j]=this;var n={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Ot(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Rt.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(var o in O.initializePlugins(this,t,n),n)o in e||(e[o]=n[o]);for(var i in Nt(e),this)"_"===i.charAt(0)&&"function"==typeof this[i]&&(this[i]=this[i].bind(this));this.nativeDraggable=!e.forceFallback&&xt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?d(t,"pointerdown",this._onTapStart):(d(t,"mousedown",this._onTapStart),d(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(d(t,"dragover",this),d(t,"dragenter",this)),bt.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,T())}function Xt(t,e,n,o,i,r,a,l){var s,c,u=t[j],d=u.options.onMove;return!window.CustomEvent||w||E?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),d&&(c=d.call(u,s,a)),c}function Yt(t){t.draggable=!1}function Bt(){Dt=!1}function Ft(t){for(var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;n--;)o+=e.charCodeAt(n);return o.toString(36)}function Ht(t){return setTimeout(t,0)}function Lt(t){return clearTimeout(t)}Rt.prototype={constructor:Rt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(ht=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(function(t){St.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&St.push(o)}}(o),!z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||Z===l)){if(J=F(l),et=F(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return W({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),K("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c&&(c=c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return W({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),K("filter",n,{evt:e}),!0})))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;if(n&&!z&&n.parentNode===r){var s=X(n);if(q=r,G=(z=n).parentNode,V=z.nextSibling,Z=n,ot=a.group,rt={target:Rt.dragged=z,clientX:(e||t).clientX,clientY:(e||t).clientY},ct=rt.clientX-s.left,ut=rt.clientY-s.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,z.style["will-change"]="all",o=function(){K("delayEnded",i,{evt:t}),Rt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!c&&i.nativeDraggable&&(z.draggable=!0),i._triggerDragStart(t,e),W({sortable:i,name:"choose",originalEvent:t}),k(z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){g(z,t.trim(),Yt)}),d(l,"dragover",Pt),d(l,"mousemove",Pt),d(l,"touchmove",Pt),d(l,"mouseup",i._onDrop),d(l,"touchend",i._onDrop),d(l,"touchcancel",i._onDrop),c&&this.nativeDraggable&&(this.options.touchStartThreshold=4,z.draggable=!0),K("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(E||w))o();else{if(Rt.eventCanceled)return void this._onDrop();d(l,"mouseup",i._disableDelayedDrag),d(l,"touchend",i._disableDelayedDrag),d(l,"touchcancel",i._disableDelayedDrag),d(l,"mousemove",i._delayedDragTouchMoveHandler),d(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&d(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)}}},_delayedDragTouchMoveHandler:function(t){var e=t.touches?t.touches[0]:t;Math.max(Math.abs(e.clientX-this._lastX),Math.abs(e.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){z&&Yt(z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;s(t,"mouseup",this._disableDelayedDrag),s(t,"touchend",this._disableDelayedDrag),s(t,"touchcancel",this._disableDelayedDrag),s(t,"mousemove",this._delayedDragTouchMoveHandler),s(t,"touchmove",this._delayedDragTouchMoveHandler),s(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?d(document,"pointermove",this._onTouchMove):d(document,e?"touchmove":"mousemove",this._onTouchMove):(d(z,"dragend",this),d(q,"dragstart",this._onDragStart));try{document.selection?Ht(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){if(vt=!1,q&&z){K("dragStarted",this,{evt:e}),this.nativeDraggable&&d(document,"dragover",kt);var n=this.options;t||k(z,n.dragClass,!1),k(z,n.ghostClass,!0),Rt.active=this,t&&this._appendGhost(),W({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(at){this._lastX=at.clientX,this._lastY=at.clientY,At();for(var t=document.elementFromPoint(at.clientX,at.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(at.clientX,at.clientY))!==e;)e=t;if(z.parentNode[j]._isOutsideThisEl(t),e)do{if(e[j]){if(e[j]._onDragOver({clientX:at.clientX,clientY:at.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}t=e}while(e=e.parentNode);It()}},_onTouchMove:function(t){if(rt){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=U&&v(U,!0),a=U&&r&&r.a,l=U&&r&&r.d,s=Ct&>&&b(gt),c=(i.clientX-rt.clientX+o.x)/(a||1)+(s?s[0]-Et[0]:0)/(a||1),u=(i.clientY-rt.clientY+o.y)/(l||1)+(s?s[1]-Et[1]:0)/(l||1);if(!Rt.active&&!vt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))o.right+10||t.clientX<=o.right&&t.clientY>o.bottom&&t.clientX>=o.left:t.clientX>o.right&&t.clientY>o.top||t.clientX<=o.right&&t.clientY>o.bottom+10}(n,a,this)&&!g.animated){if(g===z)return N(!1);if(g&&l===n.target&&(s=g),s&&(i=X(s)),!1!==Xt(q,l,z,o,s,i,n,!!s))return O(),l.appendChild(z),G=l,A(),N(!0)}else if(s.parentNode===l){i=X(s);var v,m,b,y=z.parentNode!==l,w=!function(t,e,n){var o=n?t.left:t.top,i=n?t.right:t.bottom,r=n?t.width:t.height,a=n?e.left:e.top,l=n?e.right:e.bottom,s=n?e.width:e.height;return o===a||i===l||o+r/2===a+s/2}(z.animated&&z.toRect||o,s.animated&&s.toRect||i,a),E=a?"top":"left",D=Y(s,"top","top")||Y(z,"top","top"),S=D?D.scrollTop:void 0;if(ht!==s&&(m=i[E],yt=!1,wt=!w&&e.invertSwap||y),0!==(v=function(t,e,n,o,i,r,a,l){var s=o?t.clientY:t.clientX,c=o?n.height:n.width,u=o?n.top:n.left,d=o?n.bottom:n.right,h=!1;if(!a)if(l&&pt