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
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;
}