Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@
<PackageVersion Include="FluentValidation" Version="12.2.0" />
<PackageVersion Include="Humanizer.Core" Version="3.0.10" />
<PackageVersion Include="Markdig" Version="1.6.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="10.0.15" />
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="10.0.15" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.15" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.15" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.15" />
<PackageVersion Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.15" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.10.94" />
<PackageVersion Include="PanoramicData.DeepCloner" Version="1.0.8" />
<PackageVersion Include="PanoramicData.NCalcExtensions" Version="6.1.45" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.14" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.15" />
<PackageVersion Include="System.Linq.Dynamic.Core" Version="1.7.4" />
<PackageVersion Include="xunit.v3" Version="4.0.3" />
<PackageVersion Include="bunit" Version="2.14.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
<tbody>
<tr><td><code>NavigateToAsync(path)</code></td><td>Opens a folder by opening each folder above it in turn</td></tr>
<tr><td><code>NavigateToItemAsync(itemPath)</code></td><td>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</td></tr>
<tr><td><code>NavigateToFolderAsync(folderPath)</code></td><td>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</td></tr>
</tbody>
</table>
</section>
Expand Down
7 changes: 7 additions & 0 deletions PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@
<span class="small text-muted ms-2">@_showItemResult</span>
</div>

<div class="mt-2">
<strong>Open a folder:</strong>
<input class="form-control form-control-sm d-inline-block w-auto ms-2" aria-label="Path of the folder to open" @bind="FolderPathToOpen" />
<button type="button" class="btn btn-sm btn-secondary ms-1" @onclick="OpenFolderAsync">Open</button>
<span class="small text-muted ms-2">@_openFolderResult</span>
</div>

<p class="small text-muted mt-2 mb-0">
Navigate to <code>/Library/Templates/</code> or <code>/Sharepoint/Public/</code> to see read-only files.
Try different combinations to compare: position (before/after), style (text/icon), and badge overlay.
Expand Down
11 changes: 11 additions & 0 deletions PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using AwesomeAssertions;
using Bunit;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;

namespace PanoramicData.Blazor.Test;

/// <summary>
/// How <see cref="PDContextMenu"/> 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.
/// </summary>
public partial class PDContextMenuTests
{
private static Task RightClickAsync(IRenderedComponent<PDContextMenu> component)
=> component.InvokeAsync(() => component.Find(".pdcontextmenuhost").MouseDownAsync(new MouseEventArgs { Button = 2 }));

/// <summary>A failure in showing the menu, such as its script failing, is passed to ExceptionHandler.</summary>
[Fact]
public async Task AFailureShowingTheMenu_IsPassedToTheExceptionHandler()
{
_module.SetupVoid("showMenu", _ => true).SetException(new JSException("No menu"));
var failures = new List<Exception>();
var component = Render<PDContextMenu>(parameters => parameters
.Add(p => p.Items, CreateItems())
.Add(p => p.ExceptionHandler, failures.Add)
.AddChildContent("<span class=\"target\">Right click me</span>"));

await RightClickAsync(component);

failures.Should().ContainSingle().Which.Message.Should().Be("No menu");
}

/// <summary>Without an ExceptionHandler the failure is thrown, as it always was.</summary>
[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<JSException>().WithMessage("No menu");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,17 @@ public async Task NavigateToItemAsync_ALaterCall_SupersedesOneStillLoading()
cut.Instance.SelectedFilesAndFolders.Should().Equal("/Media/pic.png");
cut.Instance.IsNavigating.Should().BeFalse();
}

/// <summary>The tree's and table's right-click menus pass their failures on as the explorer does.</summary>
[Fact]
public void TheContextMenus_PassFailuresOn_OnlyWhenTheApplicationGivesAHandler()
{
var withHandler = RenderExplorer();
var withoutHandler = Render<PDFileExplorer>(p => p.Add(x => x.DataProvider, _provider));

withHandler.FindComponents<PDContextMenu>().Should().HaveCount(2)
.And.AllSatisfy(menu => menu.Instance.ExceptionHandler.HasDelegate.Should().BeTrue());
withoutHandler.FindComponents<PDContextMenu>().Should().HaveCount(2)
.And.AllSatisfy(menu => menu.Instance.ExceptionHandler.HasDelegate.Should().BeFalse());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,74 @@ public async Task NavigateToItemAsync_NoItem_DoesNothing(string path)
cut.Instance.FolderPath.Should().Be("/Docs");
}

/// <summary>Navigating to a folder opens it once, without opening the folders above it or first showing its parent.</summary>
[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<string>();
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();
}

/// <summary>A missing folder on the way stops at the deepest folder found.</summary>
[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();
}

/// <summary>The root is a folder, so it can be opened.</summary>
[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("/");
}

/// <summary>A blank path is ignored.</summary>
[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");
}

/// <summary>SelectedFilesAndFolders lists the selected paths, and SelectionChanged carries the selected items.</summary>
[Fact]
public async Task Selection_IsReportedToTheApplication()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,33 @@ public async Task PreviewKey_WhenNotOptional_DoesNothing()
cut.Instance.PreviewPanelVisible.Should().BeTrue();
_splitter.Invocations["setSizes"].Should().BeEmpty();
}

/// <summary>A hidden preview fetches nothing; shown again, it fetches the row chosen while it was hidden.</summary>
[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<string?> PreviewedPaths { get; } = [];

public override Task<PreviewInfo> GetPreviewInfoAsync(FileExplorerItem? item)
{
PreviewedPaths.Add(item?.Path);
return base.GetPreviewInfoAsync(item);
}
}
}
17 changes: 17 additions & 0 deletions PanoramicData.Blazor.Test/Components/PDFilePreviewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
/// <summary>Verifies that a hidden preview fetches nothing, and fetches the item chosen meanwhile once it shows.</summary>
[Fact]
public void A_hidden_preview_fetches_nothing_until_it_shows()
{
var provider = new FakePreviewProvider(new PreviewInfo { CssClass = "txt" });

var component = Render<PDFilePreview>(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<PreviewInfo> preview) : IPreviewProvider
{
public FakePreviewProvider(PreviewInfo info)
Expand Down
22 changes: 20 additions & 2 deletions PanoramicData.Blazor/PDContextMenu.razor.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
namespace PanoramicData.Blazor;
using PanoramicData.Blazor.Helpers;

namespace PanoramicData.Blazor;

/// <summary>
/// A Blazor component that displays a context menu when the user right-clicks on the wrapped content.
/// </summary>
public partial class PDContextMenu
/// <remarks>
/// When the application gives an <see cref="ExceptionHandler"/>, 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.
/// </remarks>
public partial class PDContextMenu : IHandleEvent
{
private static int _idSequence;

Expand Down Expand Up @@ -43,6 +49,18 @@ public partial class PDContextMenu
/// </summary>
[Parameter] public bool ShowOnMouseUp { get; set; }

/// <summary>
/// Gets or sets where a failure in one of the menu's events is passed. Without one, a failure is thrown as before.
/// </summary>
[Parameter] public EventCallback<Exception> ExceptionHandler { get; set; }

/// <summary>
/// Handles an event as <see cref="ComponentBase"/> does, passing a failure to <see cref="ExceptionHandler"/> when
/// the application gives one.
/// </summary>
Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
=> ComponentExceptionGuard.HandleEventAsync(callback, arg, ExceptionHandler, StateHasChanged);

/// <summary>
/// Gets the unique identifier of this panel.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions PanoramicData.Blazor/PDContextMenu.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
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 */
-ms-user-select: none; /* IE10+ */
width: fit-content;
flex-direction: column;
display: none;
z-index: var(--pd-contextmenu-z-index, 1000);
}

.pdcontextmenu.show {
Expand Down
4 changes: 3 additions & 1 deletion PanoramicData.Blazor/PDContextMenu.razor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions PanoramicData.Blazor/PDFileExplorer.Operations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand Down
3 changes: 3 additions & 0 deletions PanoramicData.Blazor/PDFileExplorer.razor
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@

<PDContextMenu Items="TreeContextItems"
Enabled="ShowContextMenu"
ExceptionHandler="ChildExceptionHandler"
UpdateState="OnTreeContextMenuUpdateStateAsync"
ItemClick="OnTreeContextMenuItemClickAsync">

Expand Down Expand Up @@ -224,6 +225,7 @@

<PDContextMenu Items="TableContextItems"
Enabled="ShowContextMenu"
ExceptionHandler="ChildExceptionHandler"
ShowOnMouseUp="true"
UpdateState="OnTableContextMenuUpdateStateAsync"
ItemClick="OnTableContextMenuItemClickAsync">
Expand Down Expand Up @@ -424,6 +426,7 @@
<PDSplitPanel Size="InitialPreviewSize" MinSize="0">

<PDFilePreview Item="_previewItem"
IsVisible="PreviewPanelVisible"
ExceptionHandler="ExceptionHandler"
PreviewProvider="PreviewProvider" />

Expand Down
Loading
Loading