diff --git a/Directory.Packages.props b/Directory.Packages.props index 18f982ee0..609579e8b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,17 +10,17 @@ - - - - - - + + + + + + - + diff --git a/PanoramicData.Blazor.Demo/Pages/PDFileExplorerDocumentation.razor b/PanoramicData.Blazor.Demo/Pages/PDFileExplorerDocumentation.razor index bb674132c..7d0f9bbe6 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDFileExplorerDocumentation.razor +++ b/PanoramicData.Blazor.Demo/Pages/PDFileExplorerDocumentation.razor @@ -64,6 +64,7 @@ NavigateToAsync(path)Opens a folder by opening each folder above it in turn NavigateToItemAsync(itemPath)Opens the folder holding a file or folder and selects it, loading the folder's contents once. Returns false, leaving the deepest folder found open, when it is not there + NavigateToFolderAsync(folderPath)Opens the folder itself, loading its contents once, without first showing the folder holding it. Returns false, leaving the deepest folder found open, when it is not there diff --git a/PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor b/PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor index 9855c9592..5328d6023 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor +++ b/PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor @@ -88,6 +88,13 @@ @_showItemResult +
+ Open a folder: + + + @_openFolderResult +
+

Navigate to /Library/Templates/ or /Sharepoint/Public/ to see read-only files. Try different combinations to compare: position (before/after), style (text/icon), and badge overlay. diff --git a/PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor.cs b/PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor.cs index e88ab9076..4d2fa4906 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor.cs +++ b/PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor.cs @@ -95,6 +95,17 @@ private async Task ShowItemAsync() _showItemResult = isFound ? "Selected" : "Not found: the deepest folder found is open"; } + private string FolderPathToOpen { get; set; } = "/Library/Templates"; + + private string? _openFolderResult; + + // Opens the folder itself once, without first showing the folder holding it + private async Task OpenFolderAsync() + { + var isFound = await FileExplorer!.NavigateToFolderAsync(FolderPathToOpen).ConfigureAwait(true); + _openFolderResult = isFound ? "Opened" : "Not found: the deepest folder found is open"; + } + private async Task OnReady() { if (!string.IsNullOrWhiteSpace(_deepLinkPath)) diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Interrupt.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Interrupt.cs index 9a094af21..f0c61cf60 100644 --- a/PanoramicData.Blazor.Test/Components/PDChatTests.Interrupt.cs +++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Interrupt.cs @@ -131,7 +131,7 @@ public async Task Interrupting_leaves_the_text_box_unchanged() { var service = InterruptService(); var component = await RenderSpeakingAsync(service); - await component.Find("textarea").InputAsync(new ChangeEventArgs { Value = "A draft" }); + await component.InvokeAsync(() => component.Find("textarea").InputAsync(new ChangeEventArgs { Value = "A draft" })); await SayAsync(component, Merlin); await Task.Delay(_settle, Xunit.TestContext.Current.CancellationToken); diff --git a/PanoramicData.Blazor.Test/Components/PDContextMenuTests.Failures.cs b/PanoramicData.Blazor.Test/Components/PDContextMenuTests.Failures.cs new file mode 100644 index 000000000..538d9e1d0 --- /dev/null +++ b/PanoramicData.Blazor.Test/Components/PDContextMenuTests.Failures.cs @@ -0,0 +1,44 @@ +using AwesomeAssertions; +using Bunit; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; + +namespace PanoramicData.Blazor.Test; + +///

+/// How contains failures: given an ExceptionHandler, a failure in showing the menu is passed +/// to it rather than ending the Blazor circuit, and without one it is thrown as before. +/// +public partial class PDContextMenuTests +{ + private static Task RightClickAsync(IRenderedComponent component) + => component.InvokeAsync(() => component.Find(".pdcontextmenuhost").MouseDownAsync(new MouseEventArgs { Button = 2 })); + + /// A failure in showing the menu, such as its script failing, is passed to ExceptionHandler. + [Fact] + public async Task AFailureShowingTheMenu_IsPassedToTheExceptionHandler() + { + _module.SetupVoid("showMenu", _ => true).SetException(new JSException("No menu")); + var failures = new List(); + var component = Render(parameters => parameters + .Add(p => p.Items, CreateItems()) + .Add(p => p.ExceptionHandler, failures.Add) + .AddChildContent("Right click me")); + + await RightClickAsync(component); + + failures.Should().ContainSingle().Which.Message.Should().Be("No menu"); + } + + /// Without an ExceptionHandler the failure is thrown, as it always was. + [Fact] + public async Task WithoutAnExceptionHandler_AFailureShowingTheMenu_IsThrownAsBefore() + { + _module.SetupVoid("showMenu", _ => true).SetException(new JSException("No menu")); + var component = RenderMenu(null); + + var act = () => RightClickAsync(component); + + await act.Should().ThrowAsync().WithMessage("No menu"); + } +} diff --git a/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Failures.cs b/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Failures.cs index f6ba084f1..1553c1f65 100644 --- a/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Failures.cs +++ b/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Failures.cs @@ -92,4 +92,17 @@ public async Task NavigateToItemAsync_ALaterCall_SupersedesOneStillLoading() cut.Instance.SelectedFilesAndFolders.Should().Equal("/Media/pic.png"); cut.Instance.IsNavigating.Should().BeFalse(); } + + /// The tree's and table's right-click menus pass their failures on as the explorer does. + [Fact] + public void TheContextMenus_PassFailuresOn_OnlyWhenTheApplicationGivesAHandler() + { + var withHandler = RenderExplorer(); + var withoutHandler = Render(p => p.Add(x => x.DataProvider, _provider)); + + withHandler.FindComponents().Should().HaveCount(2) + .And.AllSatisfy(menu => menu.Instance.ExceptionHandler.HasDelegate.Should().BeTrue()); + withoutHandler.FindComponents().Should().HaveCount(2) + .And.AllSatisfy(menu => menu.Instance.ExceptionHandler.HasDelegate.Should().BeFalse()); + } } diff --git a/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Navigation.cs b/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Navigation.cs index 23d6aa4e7..9bb5f71d7 100644 --- a/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Navigation.cs +++ b/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Navigation.cs @@ -162,6 +162,74 @@ public async Task NavigateToItemAsync_NoItem_DoesNothing(string path) cut.Instance.FolderPath.Should().Be("/Docs"); } + /// Navigating to a folder opens it once, without opening the folders above it or first showing its parent. + [Fact] + public async Task NavigateToFolderAsync_OpensOnlyTheFolder() + { + _provider.Items.Remove(_provider.Get("/Docs/Sub")); + _provider.Items.AddRange( + [ + FileProvider.Dir("/Docs/Sub", hasSubFolders: true), + FileProvider.Dir("/Docs/Sub/Deep"), + FileProvider.File("/Docs/Sub/Deep/report.pdf", 100) + ]); + var folders = new List(); + var cut = RenderExplorer(p => p.Add(x => x.FolderChanged, (FileExplorerItem f) => folders.Add(f.Path))); + await NavigateAsync(cut, "/Media"); + folders.Clear(); + _provider.Requests.Clear(); + + var found = await cut.InvokeAsync(() => cut.Instance.NavigateToFolderAsync("/Docs/Sub/Deep")); + + found.Should().BeTrue(); + cut.Instance.FolderPath.Should().Be("/Docs/Sub/Deep"); + folders.Should().Equal("/Docs/Sub/Deep"); + _provider.Requests.Should().Equal("/Docs", "/Docs/Sub", "/Docs/Sub/Deep"); + Tree(cut).Instance.SelectedNode!.Key.Should().Be("/Docs/Sub/Deep"); + cut.Instance.SelectedFilesAndFolders.Should().BeEmpty(); + RowNames(cut).Should().Equal("..", "report.pdf"); + cut.Instance.IsNavigating.Should().BeFalse(); + } + + /// A missing folder on the way stops at the deepest folder found. + [Fact] + public async Task NavigateToFolderAsync_AMissingFolder_OpensTheDeepestFolderFound_AndReturnsFalse() + { + var cut = RenderExplorer(); + + var found = await cut.InvokeAsync(() => cut.Instance.NavigateToFolderAsync("/Docs/Gone")); + + found.Should().BeFalse(); + cut.Instance.FolderPath.Should().Be("/Docs"); + cut.Instance.IsNavigating.Should().BeFalse(); + } + + /// The root is a folder, so it can be opened. + [Fact] + public async Task NavigateToFolderAsync_TheRoot_OpensIt() + { + var cut = RenderExplorer(); + await NavigateAsync(cut, "/Docs"); + + var found = await cut.InvokeAsync(() => cut.Instance.NavigateToFolderAsync("/")); + + found.Should().BeTrue(); + cut.Instance.FolderPath.Should().Be("/"); + } + + /// A blank path is ignored. + [Fact] + public async Task NavigateToFolderAsync_ABlankPath_DoesNothing() + { + var cut = RenderExplorer(); + await NavigateAsync(cut, "/Docs"); + + var found = await cut.InvokeAsync(() => cut.Instance.NavigateToFolderAsync(" ")); + + found.Should().BeFalse(); + cut.Instance.FolderPath.Should().Be("/Docs"); + } + /// SelectedFilesAndFolders lists the selected paths, and SelectionChanged carries the selected items. [Fact] public async Task Selection_IsReportedToTheApplication() diff --git a/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Preview.cs b/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Preview.cs index a77cf6f88..aeb0ae963 100644 --- a/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Preview.cs +++ b/PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Preview.cs @@ -120,4 +120,33 @@ public async Task PreviewKey_WhenNotOptional_DoesNothing() cut.Instance.PreviewPanelVisible.Should().BeTrue(); _splitter.Invocations["setSizes"].Should().BeEmpty(); } + + /// A hidden preview fetches nothing; shown again, it fetches the row chosen while it was hidden. + [Fact] + public async Task OptionalOn_AHiddenPreview_FetchesOnlyOnceShown() + { + var provider = new RecordingPreviewProvider(); + var cut = RenderExplorer(p => p + .Add(x => x.PreviewPanel, FilePreviewModes.OptionalOn) + .Add(x => x.PreviewProvider, provider)); + await ClickToolbarAsync(cut, "preview"); + provider.PreviewedPaths.Clear(); + + await SelectRowsAsync(cut, "/Docs"); + provider.PreviewedPaths.Should().BeEmpty(); + + await ClickToolbarAsync(cut, "preview"); + cut.WaitForAssertion(() => provider.PreviewedPaths.Should().Equal("/Docs")); + } + + private sealed class RecordingPreviewProvider : DefaultPreviewProvider + { + public List PreviewedPaths { get; } = []; + + public override Task GetPreviewInfoAsync(FileExplorerItem? item) + { + PreviewedPaths.Add(item?.Path); + return base.GetPreviewInfoAsync(item); + } + } } diff --git a/PanoramicData.Blazor.Test/Components/PDFilePreviewTests.cs b/PanoramicData.Blazor.Test/Components/PDFilePreviewTests.cs index 00090c27e..80f6b910f 100644 --- a/PanoramicData.Blazor.Test/Components/PDFilePreviewTests.cs +++ b/PanoramicData.Blazor.Test/Components/PDFilePreviewTests.cs @@ -153,6 +153,23 @@ public void A_failing_preview_falls_back_and_reports_the_exception() /// A preview provider returning a fixed result, recording which items are previewed and how often a spinner is asked for. /// Both timings are zero so the spinner path is decided by whether the preview task has completed. /// + /// Verifies that a hidden preview fetches nothing, and fetches the item chosen meanwhile once it shows. + [Fact] + public void A_hidden_preview_fetches_nothing_until_it_shows() + { + var provider = new FakePreviewProvider(new PreviewInfo { CssClass = "txt" }); + + var component = Render(parameters => parameters + .Add(p => p.PreviewProvider, provider) + .Add(p => p.IsVisible, false) + .Add(p => p.Item, _item)); + provider.PreviewedPaths.Should().BeEmpty(); + + component.Render(parameters => parameters.Add(p => p.IsVisible, true)); + + provider.PreviewedPaths.Should().Equal(_item.Path); + } + private sealed class FakePreviewProvider(Task preview) : IPreviewProvider { public FakePreviewProvider(PreviewInfo info) diff --git a/PanoramicData.Blazor/PDContextMenu.razor.cs b/PanoramicData.Blazor/PDContextMenu.razor.cs index 7c20f5306..344a92c6b 100644 --- a/PanoramicData.Blazor/PDContextMenu.razor.cs +++ b/PanoramicData.Blazor/PDContextMenu.razor.cs @@ -1,9 +1,15 @@ -namespace PanoramicData.Blazor; +using PanoramicData.Blazor.Helpers; + +namespace PanoramicData.Blazor; /// /// A Blazor component that displays a context menu when the user right-clicks on the wrapped content. /// -public partial class PDContextMenu +/// +/// When the application gives an , a failure in showing the menu or in an item's click, +/// such as its script failing, is passed to it instead of ending the Blazor circuit. +/// +public partial class PDContextMenu : IHandleEvent { private static int _idSequence; @@ -43,6 +49,18 @@ public partial class PDContextMenu /// [Parameter] public bool ShowOnMouseUp { get; set; } + /// + /// Gets or sets where a failure in one of the menu's events is passed. Without one, a failure is thrown as before. + /// + [Parameter] public EventCallback ExceptionHandler { get; set; } + + /// + /// Handles an event as does, passing a failure to when + /// the application gives one. + /// + Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg) + => ComponentExceptionGuard.HandleEventAsync(callback, arg, ExceptionHandler, StateHasChanged); + /// /// Gets the unique identifier of this panel. /// diff --git a/PanoramicData.Blazor/PDContextMenu.razor.css b/PanoramicData.Blazor/PDContextMenu.razor.css index 1ffdabd4a..c5a51590d 100644 --- a/PanoramicData.Blazor/PDContextMenu.razor.css +++ b/PanoramicData.Blazor/PDContextMenu.razor.css @@ -9,6 +9,7 @@ overflow: auto !important; } +/* Above what it opens over, such as a sticky table header: Bootstrap's drop-down level, unless the application sets its own */ .pdcontextmenu { -webkit-user-select: none; /* Chrome/Safari */ -moz-user-select: none; /* Firefox */ @@ -16,6 +17,7 @@ width: fit-content; flex-direction: column; display: none; + z-index: var(--pd-contextmenu-z-index, 1000); } .pdcontextmenu.show { diff --git a/PanoramicData.Blazor/PDContextMenu.razor.js b/PanoramicData.Blazor/PDContextMenu.razor.js index 7bb70d0f2..8cc48c34b 100644 --- a/PanoramicData.Blazor/PDContextMenu.razor.js +++ b/PanoramicData.Blazor/PDContextMenu.razor.js @@ -20,9 +20,11 @@ export function showMenu(menuId, x, y) { }; }, }; + // Fixed, so the menu opens at the pointer wherever it sits: under CSS zoom or a scaled parent, and in a panel that + // scrolls or clips. "positionFixed" was popper 1's option, which popper 2 ignores var options = { placement: "bottom-start", - positionFixed: true, + strategy: "fixed", }; menuEl.classList.add("show"); popper = window.Popper.createPopper(reference, menuEl, options); // this is popper v2.4.4 syntax diff --git a/PanoramicData.Blazor/PDFileExplorer.Operations.cs b/PanoramicData.Blazor/PDFileExplorer.Operations.cs index bcd50a8ae..08cd63a07 100644 --- a/PanoramicData.Blazor/PDFileExplorer.Operations.cs +++ b/PanoramicData.Blazor/PDFileExplorer.Operations.cs @@ -30,6 +30,9 @@ private async Task OnTogglePreviewPanelAsync() PreviewPanelVisible = !PreviewPanelVisible; await RefreshToolbarAsync(); + + // Shown again, the preview fetches the item chosen while it was hidden, also when a host toggles it + StateHasChanged(); } } diff --git a/PanoramicData.Blazor/PDFileExplorer.razor b/PanoramicData.Blazor/PDFileExplorer.razor index 296f5bb32..61f267a6f 100644 --- a/PanoramicData.Blazor/PDFileExplorer.razor +++ b/PanoramicData.Blazor/PDFileExplorer.razor @@ -130,6 +130,7 @@ @@ -224,6 +225,7 @@ @@ -424,6 +426,7 @@ diff --git a/PanoramicData.Blazor/PDFileExplorer.razor.cs b/PanoramicData.Blazor/PDFileExplorer.razor.cs index d4ad71375..ec7cc00d5 100644 --- a/PanoramicData.Blazor/PDFileExplorer.razor.cs +++ b/PanoramicData.Blazor/PDFileExplorer.razor.cs @@ -727,21 +727,42 @@ public async Task NavigateToItemAsync(string itemPath) IsNavigating = true; try { - var (folderNode, isFolderFound) = await LoadFolderNodesAsync(rootNode, segments[..^1], navigation).ConfigureAwait(true); - if (navigation != _itemNavigation) + return await OpenFolderAsync(rootNode, segments[..^1], navigation).ConfigureAwait(true) + && navigation == _itemNavigation + && await SelectTableItemAsync("/" + string.Join('/', segments)).ConfigureAwait(true); + } + finally + { + if (navigation == _itemNavigation) { - return false; + IsNavigating = false; } + } + } - if (Tree.SelectedNode != folderNode) - { - await Tree.SelectNode(folderNode, false).ConfigureAwait(true); - } + /// + /// Opens the given folder, as choosing it in the tree does. As with , the + /// folders above it are only loaded into the tree, so the table loads once and is raised + /// once, for the folder itself. + /// + /// The path of the folder to open, such as "/Docs/Sub"; "/" opens the root. + /// + /// True when the folder was found and opened. False when it was not, in which case the deepest folder found on the + /// way to it is open. + /// + public async Task NavigateToFolderAsync(string folderPath) + { + if (string.IsNullOrWhiteSpace(folderPath) || Tree?.RootNode.Find("/") is not { } rootNode) + { + return false; + } - await ScrollIntoViewAsync(() => Tree.ScrollNodeIntoViewAsync(folderNode)).ConfigureAwait(true); - return isFolderFound - && navigation == _itemNavigation - && await SelectTableItemAsync("/" + string.Join('/', segments)).ConfigureAwait(true); + var navigation = ++_itemNavigation; + IsNavigating = true; + try + { + return await OpenFolderAsync(rootNode, folderPath.Split('/', StringSplitOptions.RemoveEmptyEntries), navigation).ConfigureAwait(true) + && navigation == _itemNavigation; } finally { @@ -752,6 +773,27 @@ public async Task NavigateToItemAsync(string itemPath) } } + /// + /// Loads each folder on the way into the tree, then opens the deepest one found, and returns whether every folder + /// was found. Returns false without opening anything once a later navigation has started. + /// + private async Task OpenFolderAsync(TreeNode rootNode, string[] folderNames, int navigation) + { + var (folderNode, isFound) = await LoadFolderNodesAsync(rootNode, folderNames, navigation).ConfigureAwait(true); + if (navigation != _itemNavigation) + { + return false; + } + + if (Tree!.SelectedNode != folderNode) + { + await Tree.SelectNode(folderNode, false).ConfigureAwait(true); + } + + await ScrollIntoViewAsync(() => Tree.ScrollNodeIntoViewAsync(folderNode)).ConfigureAwait(true); + return isFound; + } + /// /// Scrolls something into view. Scrolling only helps the eye, so a browser that cannot do it, or a connection that /// has closed, is no reason to fail. diff --git a/PanoramicData.Blazor/PDFilePreview.razor.cs b/PanoramicData.Blazor/PDFilePreview.razor.cs index c1527f919..b099309ca 100644 --- a/PanoramicData.Blazor/PDFilePreview.razor.cs +++ b/PanoramicData.Blazor/PDFilePreview.razor.cs @@ -28,11 +28,18 @@ public partial class PDFilePreview [Parameter] public IPreviewProvider PreviewProvider { get; set; } = new DefaultPreviewProvider(); + /// + /// Gets or sets whether the preview is showing. While it is not, nothing is fetched; the item chosen meanwhile is + /// fetched once it shows. + /// + [Parameter] + public bool IsVisible { get; set; } = true; + /// protected override async Task OnParametersSetAsync() { // cache last preview by path - if (Item?.Path == _lastPreviewPath) + if (!IsVisible || Item?.Path == _lastPreviewPath) { return; }