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: 14 additions & 0 deletions PanoramicData.Blazor.Demo/Pages/PDFileExplorerDocumentation.razor
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@
<tr><td><code>AllowDelete</code></td><td><code>bool</code></td><td>Enable file deletion</td></tr>
<tr><td><code>AllowRename</code></td><td><code>bool</code></td><td>Enable renaming</td></tr>
<tr><td><code>AllowNewFolder</code></td><td><code>bool</code></td><td>Enable folder creation</td></tr>
<tr><td><code>ShowToolbar</code></td><td><code>bool</code></td><td>Show the toolbar. Without it the explorer has the <code>pdfe-no-toolbar</code> class and its panes take the full height</td></tr>
</tbody>
</table>
</section>

<section id="methods">
<h2 class="doc-section">Methods</h2>
<table class="table table-sm doc-props-table">
<thead>
<tr><th>Method</th><th>Description</th></tr>
</thead>
<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>
</tbody>
</table>
</section>
Expand Down
9 changes: 8 additions & 1 deletion PanoramicData.Blazor.Demo/Pages/PDFileExplorerPage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,15 @@
</div>
</div>

<div class="mt-2">
<strong>Show a file:</strong>
<input class="form-control form-control-sm d-inline-block w-auto ms-2" aria-label="Path of the file to show" @bind="ItemPathToShow" />
<button type="button" class="btn btn-sm btn-secondary ms-1" @onclick="ShowItemAsync">Show</button>
<span class="small text-muted ms-2">@_showItemResult</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.
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.
</p>
</div>
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 @@ -84,6 +84,17 @@ protected override void OnAfterRender(bool firstRender)

private void OnFolderChanged(FileExplorerItem folder) => NavigationManager.SetUri(new Dictionary<string, object> { { "path", $"{folder.Path}" } });

private string ItemPathToShow { get; set; } = "/Library/Templates/word_template.docx";

private string? _showItemResult;

// Opens the file's folder once and selects the file, without opening each folder on the way
private async Task ShowItemAsync()
{
var isFound = await FileExplorer!.NavigateToItemAsync(ItemPathToShow).ConfigureAwait(true);
_showItemResult = isFound ? "Selected" : "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
@@ -0,0 +1,95 @@
using AwesomeAssertions;
using Bunit;
using Microsoft.JSInterop;
using PanoramicData.Blazor.Models;

namespace PanoramicData.Blazor.Test.Components;

/// <summary>
/// How <see cref="PDFileExplorer"/> contains failures: given an ExceptionHandler, a failure in a handler is passed to it
/// rather than ending the Blazor circuit, and without one it is thrown as before.
/// </summary>
public partial class PDFileExplorerTests
{
private static void FailOnFolderChange(FileExplorerItem folder) => throw new InvalidOperationException($"Cannot open {folder.Path}");

private static Task SelectTreeNodeAsync(IRenderedComponent<PDFileExplorer> cut, string path)
=> Tree(cut).InvokeAsync(() => Tree(cut).Instance.SelectNode(cut.Instance.TreeRootNode!.Find(path)!, false));

/// <summary>A failing application handler is passed to ExceptionHandler, and the folder still opens.</summary>
[Fact]
public async Task AFailingHandler_IsPassedToTheExceptionHandler()
{
var cut = RenderExplorer(p => p.Add(x => x.FolderChanged, folder => FailOnFolderChange(folder)));
_exceptions.Should().ContainSingle().Which.Message.Should().Be("Cannot open /", "opening the root on the first render fails too");
_exceptions.Clear();

await SelectTreeNodeAsync(cut, "/Docs");

_exceptions.Should().ContainSingle().Which.Message.Should().Be("Cannot open /Docs");
cut.Instance.FolderPath.Should().Be("/Docs");
RowNames(cut).Should().Equal("..", "Sub", "a.docx", "b.xlsx");
}

/// <summary>Without an ExceptionHandler a failing handler is thrown, as it always was.</summary>
[Fact]
public async Task WithoutAnExceptionHandler_AFailingHandler_IsThrownAsBefore()
{
var cut = Render<PDFileExplorer>(p => p
.Add(x => x.DataProvider, _provider)
.Add(x => x.AutoExpand, true)
.Add(x => x.FolderChanged, folder => FailOnFolderChange(folder)));
cut.WaitForAssertion(() => Tree(cut).Instance.SelectedNode.Should().NotBeNull());

var act = () => SelectTreeNodeAsync(cut, "/Docs");

await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("Cannot open /Docs");
}

/// <summary>A toolbar state handler that fails on the first render is passed on, and the explorer still loads.</summary>
[Fact]
public void AFailingToolbarStateHandler_OnTheFirstRender_IsPassedOn()
{
static void FailToUpdateToolbar(List<ToolbarItem> items) => throw new InvalidOperationException("No toolbar state");

Check warning on line 53 in PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Failures.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

PanoramicData.Blazor.Test/Components/PDFileExplorerTests.Failures.cs#L53

Remove this unused method parameter 'items'.

var cut = RenderExplorer(p => p.Add(x => x.UpdateToolbarState, items => FailToUpdateToolbar(items)));

cut.WaitForAssertion(() => _exceptions.Should().NotBeEmpty());
_exceptions.Should().AllSatisfy(ex => ex.Message.Should().Be("No toolbar state"));
RowNames(cut).Should().Equal("Docs", "Empty", "Media");
}

/// <summary>An item is still selected when the browser cannot scroll it into view.</summary>
[Fact]
public async Task NavigateToItemAsync_WhenScrollingFails_StillSelectsTheItem()
{
var cut = RenderExplorer();
_common.SetupVoid("scrollIntoView", _ => true).SetException(new JSException("No scrolling"));
_common.SetupVoid("scrollIntoViewEx", _ => true).SetException(new JSException("No scrolling"));

var found = await cut.InvokeAsync(() => cut.Instance.NavigateToItemAsync("/Docs/a.docx"));

found.Should().BeTrue();
cut.Instance.SelectedFilesAndFolders.Should().Equal("/Docs/a.docx");
_exceptions.Should().BeEmpty();
}

/// <summary>A navigation started while another is still loading the tree supersedes it.</summary>
[Fact]
public async Task NavigateToItemAsync_ALaterCall_SupersedesOneStillLoading()
{
var docsAnswer = new TaskCompletionSource();
var cut = RenderExplorer();
_provider.BeforeAnswer = folder => folder == "/Docs" ? docsAnswer.Task : Task.CompletedTask;

var first = cut.InvokeAsync(() => cut.Instance.NavigateToItemAsync("/Docs/Sub/notes.txt"));
var second = await cut.InvokeAsync(() => cut.Instance.NavigateToItemAsync("/Media/pic.png"));
docsAnswer.SetResult();

(await first).Should().BeFalse();
second.Should().BeTrue();
cut.Instance.FolderPath.Should().Be("/Media");
cut.Instance.SelectedFilesAndFolders.Should().Equal("/Media/pic.png");
cut.Instance.IsNavigating.Should().BeFalse();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,18 @@

public FileExplorerItem Get(string path) => Items.Single(x => x.Path == path);

public override Task<DataResponse<FileExplorerItem>> GetDataAsync(DataRequest<FileExplorerItem> request, CancellationToken cancellationToken)
/// <summary>Awaited before a request is answered, given the request's folder, to hold an answer back.</summary>
public Func<string?, Task>? BeforeAnswer { get; set; }

Check warning on line 57 in PanoramicData.Blazor.Test/Components/PDFileExplorerTests.FileProvider.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

PanoramicData.Blazor.Test/Components/PDFileExplorerTests.FileProvider.cs#L57

Remove unassigned auto-property 'BeforeAnswer', or set its value.

public override async Task<DataResponse<FileExplorerItem>> GetDataAsync(DataRequest<FileExplorerItem> request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Requests.Add(request.SearchText);
if (BeforeAnswer is not null)
{
await BeforeAnswer(request.SearchText).ConfigureAwait(true);
}

List<FileExplorerItem> items = request.SearchText switch
{
null => [.. Items.Select(Copy)],
Expand All @@ -69,7 +77,7 @@
items = request.SortDirection == SortDirection.Descending ? [.. items.OrderByDescending(key)] : [.. items.OrderBy(key)];
}

return Task.FromResult(new DataResponse<FileExplorerItem>(items, items.Count));
return new DataResponse<FileExplorerItem>(items, items.Count);
}

private static FileExplorerItem Copy(FileExplorerItem x) => new()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,94 @@ public async Task NavigateToAsync_BlankPath_DoesNothing()
cut.Instance.FolderPath.Should().Be("/");
}

/// <summary>Navigating to an item opens its folder once, without opening the folders above it, and selects it.</summary>
[Fact]
public async Task NavigateToItemAsync_OpensOnlyTheItemsFolder_AndSelectsTheItem()
{
_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>();
FileExplorerItem[]? reported = null;
var cut = RenderExplorer(p => p
.Add(x => x.FolderChanged, (FileExplorerItem f) => folders.Add(f.Path))
.Add(x => x.SelectionChanged, (FileExplorerItem[] s) => reported = s));
await NavigateAsync(cut, "/Media");
folders.Clear();
_provider.Requests.Clear();

var found = await cut.InvokeAsync(() => cut.Instance.NavigateToItemAsync("/Docs/Sub/Deep/report.pdf"));

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");
cut.Instance.SelectedFilesAndFolders.Should().Equal("/Docs/Sub/Deep/report.pdf");
reported!.Select(x => x.Path).Should().Equal("/Docs/Sub/Deep/report.pdf");
Tree(cut).Instance.SelectedNode!.Key.Should().Be("/Docs/Sub/Deep");
cut.Instance.IsNavigating.Should().BeFalse();
_common.Invocations["scrollIntoViewEx"].Should().ContainSingle()
.Which.Arguments[0].Should().Be($"#{Table(cut).Instance.Id} tr[id=\"/Docs/Sub/Deep/report.pdf\"]");
}

/// <summary>A folder can be the item: its parent folder opens with the folder selected.</summary>
[Fact]
public async Task NavigateToItemAsync_AFolder_OpensItsParent_AndSelectsIt()
{
var cut = RenderExplorer();

var found = await cut.InvokeAsync(() => cut.Instance.NavigateToItemAsync("/Docs/Sub"));

found.Should().BeTrue();
cut.Instance.FolderPath.Should().Be("/Docs");
cut.Instance.SelectedFilesAndFolders.Should().Equal("/Docs/Sub");
}

/// <summary>An item that is no longer in its folder leaves the folder open, with nothing selected.</summary>
[Fact]
public async Task NavigateToItemAsync_AMissingItem_OpensItsFolder_AndReturnsFalse()
{
var cut = RenderExplorer();

var found = await cut.InvokeAsync(() => cut.Instance.NavigateToItemAsync("/Docs/gone.docx"));

found.Should().BeFalse();
cut.Instance.FolderPath.Should().Be("/Docs");
cut.Instance.SelectedFilesAndFolders.Should().BeEmpty();
}

/// <summary>A missing folder on the way stops at the deepest folder found.</summary>
[Fact]
public async Task NavigateToItemAsync_AMissingFolder_OpensTheDeepestFolderFound_AndReturnsFalse()
{
var cut = RenderExplorer();

var found = await cut.InvokeAsync(() => cut.Instance.NavigateToItemAsync("/Docs/Gone/a.docx"));

found.Should().BeFalse();
cut.Instance.FolderPath.Should().Be("/Docs");
cut.Instance.IsNavigating.Should().BeFalse();
}

/// <summary>A blank path, or the root itself, which is in no folder, is ignored.</summary>
[Theory]
[InlineData(" ")]
[InlineData("/")]
public async Task NavigateToItemAsync_NoItem_DoesNothing(string path)
{
var cut = RenderExplorer();
await NavigateAsync(cut, "/Docs");

var found = await cut.InvokeAsync(() => cut.Instance.NavigateToItemAsync(path));

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
12 changes: 12 additions & 0 deletions PanoramicData.Blazor.Test/Components/PDFileExplorerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ public void FirstRender_LoadsRootFolder_IntoTreeAndTable()
cut.Instance.FileItems.Should().HaveCount(3);
}

/// <summary>Without its toolbar the explorer says so, so that its panes can take the toolbar's height.</summary>
[Fact]
public void WithoutTheToolbar_TheExplorerHasTheNoToolbarClass()
{
var withToolbar = RenderExplorer();
var withoutToolbar = RenderExplorer(p => p.Add(x => x.ShowToolbar, false));

withToolbar.Find("div.pdfileexplorer").ClassList.Should().NotContain("pdfe-no-toolbar");
withoutToolbar.Find("div.pdfileexplorer").ClassList.Should().Contain("pdfe-no-toolbar");
withoutToolbar.Find("div.pdfe-toolbar").ClassList.Should().Contain("d-none");
}

/// <summary>A whitespace new-folder name is rejected as a parameter.</summary>
[Fact]
public void BlankNewFolderName_Throws()
Expand Down
17 changes: 17 additions & 0 deletions PanoramicData.Blazor.Test/Components/PDTableTests.DataLoading.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,23 @@
pages.Should().Equal(2u);
}

/// <summary>A PageChanged handler that fails is passed to ExceptionHandler, rather than ending the circuit.</summary>
[Fact]
public async Task A_failing_PageChanged_handler_is_passed_to_ExceptionHandler()
{
static void FailToChangePage(PageCriteria criteria) => throw new InvalidOperationException("Paging failed");

Check warning on line 155 in PanoramicData.Blazor.Test/Components/PDTableTests.DataLoading.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

PanoramicData.Blazor.Test/Components/PDTableTests.DataLoading.cs#L155

Remove this unused method parameter 'criteria'.
var criteria = new PageCriteria(1, 2);
var failures = new List<Exception>();
var table = RenderTable(p => p
.Add(x => x.PageCriteria, criteria)
.Add(x => x.PageChanged, c => FailToChangePage(c))
.Add(x => x.ExceptionHandler, failures.Add));

await table.InvokeAsync(() => criteria.Page = 2);

table.WaitForAssertion(() => failures.Should().ContainSingle().Which.Message.Should().Be("Paging failed"));
}

/// <summary>Changing the page size refetches and raises PageSizeChanged.</summary>
[Fact]
public async Task Changing_the_page_size_refetches_and_raises_PageSizeChanged()
Expand Down
13 changes: 13 additions & 0 deletions PanoramicData.Blazor.Test/Components/PDTableTests.Selection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,19 @@ public async Task SelectItemAsync_with_shift_selects_a_range()
table.Instance.Selection.Should().Equal("1", "2", "3");
}

/// <summary>ScrollItemIntoViewAsync scrolls the row with the key into view, matching a key that is no CSS identifier.</summary>
[Fact]
public async Task ScrollItemIntoViewAsync_scrolls_the_row_by_its_id_attribute()
{
var table = RenderTable();

await table.InvokeAsync(() => table.Instance.ScrollItemIntoViewAsync("/a \"b\".txt"));
await table.InvokeAsync(() => table.Instance.ScrollItemIntoViewAsync(" "));

var arguments = _common.Invocations["scrollIntoViewEx"].Should().ContainSingle().Which.Arguments;
arguments.Should().Equal($"#{table.Instance.Id} tr[id=\"/a \\\"b\\\".txt\"]", "auto", "nearest", "nearest");
}

/// <summary>Without a key field no items are reported as selected.</summary>
[Fact]
public void GetSelectedItems_without_a_key_field_is_empty()
Expand Down
Loading
Loading