From c1cac17c6b3624f2e7ff51f3553d3115a34b6642 Mon Sep 17 00:00:00 2001 From: Alexander Shabarshov Date: Tue, 8 Apr 2025 11:14:45 +0100 Subject: [PATCH] Add Table of Contents feature for improved navigation --- .../Controls/ComponentsControl.razor | 28 ++-- BlazorOpenApi/Controls/Expander.razor | 29 ++-- BlazorOpenApi/Controls/HeaderControl.razor | 62 ++++---- BlazorOpenApi/Controls/OperationControl.razor | 133 ++++++++++-------- BlazorOpenApi/Controls/PathControl.razor | 58 ++++---- BlazorOpenApi/Controls/ServersControl.razor | 108 +++++++------- BlazorOpenApi/Controls/TableOfContents.razor | 48 +++++++ BlazorOpenApi/Controls/TocMember.razor | 45 ++++++ ...ExpandoTree.cs => ITableOfContentsTree.cs} | 10 +- BlazorOpenApi/OpenAPIUIControl.razor | 39 +++-- ...{ExpandoTree.cs => TableOfContentsTree.cs} | 22 ++- BlazorOpenApi/wwwroot/css/openapi-ui.css | 7 +- BlazorOpenApi/wwwroot/js/BlazorOpenAPI.js | 10 ++ Demo/Shared/MainLayout.razor | 10 +- 14 files changed, 389 insertions(+), 220 deletions(-) create mode 100644 BlazorOpenApi/Controls/TableOfContents.razor create mode 100644 BlazorOpenApi/Controls/TocMember.razor rename BlazorOpenApi/{IExpandoTree.cs => ITableOfContentsTree.cs} (67%) rename BlazorOpenApi/{ExpandoTree.cs => TableOfContentsTree.cs} (66%) create mode 100644 BlazorOpenApi/wwwroot/js/BlazorOpenAPI.js diff --git a/BlazorOpenApi/Controls/ComponentsControl.razor b/BlazorOpenApi/Controls/ComponentsControl.razor index 175e54a..d5d53b0 100644 --- a/BlazorOpenApi/Controls/ComponentsControl.razor +++ b/BlazorOpenApi/Controls/ComponentsControl.razor @@ -2,33 +2,39 @@ { @if (Value.Schemas?.Count > 0) { -

Schemas

- @foreach (var (name, val) in Value.Schemas) - { -

@name

- - } + +

Schemas

+ @foreach (var (name, val) in Value.Schemas) + { +

@name

+ + } +
} @if (Value.Parameters?.Count > 0) { +

Parameters

@foreach (var (name, val) in Value.Parameters) {

@name

} +
} @if (Value.Examples?.Count > 0) { -

Examples

- @foreach (var (name, val) in Value.Examples) - { - - } + +

Examples

+ @foreach (var (name, val) in Value.Examples) + { + + } +
} } diff --git a/BlazorOpenApi/Controls/Expander.razor b/BlazorOpenApi/Controls/Expander.razor index 7372dd2..f0afb74 100644 --- a/BlazorOpenApi/Controls/Expander.razor +++ b/BlazorOpenApi/Controls/Expander.razor @@ -1,5 +1,5 @@ - -
+ +
@Title
@@ -29,16 +29,18 @@ [Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] - public bool Collapsed { get; set; } = true; + public bool Collapsed { get; set; } = true; [Parameter] - public string Title { get; set; } = ""; + public string Title { get; set; } = ""; [Parameter] - public string HeaderClass { get; set; } = ""; + public string HeaderClass { get; set; } = ""; [Parameter] - public string Class { get; set; } = ""; + public string Class { get; set; } = ""; + [Parameter] + public bool AddToTOC { get; set; } = false; - [CascadingParameter(Name = "OpenAPIUI_ExpandoTree")] public IExpandoTree Tree { get; set; } = null!; - [CascadingParameter(Name = "OpenAPIUI_ExpandoTree_Parent")] public string Parent { get; set; } = ""; + [CascadingParameter(Name = "OpenAPIUI_TOC")] public ITableOfContentsTree Tree { get; set; } = null!; + [CascadingParameter(Name = "OpenAPIUI_TOC_Parent")] public string Parent { get; set; } = ""; private string _anchor = $"anc{Random.Shared.Next():X8}"; @@ -47,14 +49,19 @@ if (!firstRender) return; - Tree.Add(Title, _anchor, Parent, Collapsed); - Collapsed = Tree.IsCollapsed(Title); + if (AddToTOC) + { + Tree.Add(Title, _anchor, Parent, Collapsed); + Collapsed = Tree.IsCollapsed(Title); + } } void Toggle() { Collapsed = !Collapsed; - Tree.Collapse(_anchor, Collapsed); + if ( AddToTOC ) + Tree.Collapse(_anchor, Collapsed); + StateHasChanged(); } } \ No newline at end of file diff --git a/BlazorOpenApi/Controls/HeaderControl.razor b/BlazorOpenApi/Controls/HeaderControl.razor index 2f3348d..a0f23a6 100644 --- a/BlazorOpenApi/Controls/HeaderControl.razor +++ b/BlazorOpenApi/Controls/HeaderControl.razor @@ -1,38 +1,42 @@ @if (Value != null) { -
-

@Value.Title @Value.Version

-
- @if (!string.IsNullOrWhiteSpace(Value.Contact?.Name)) + + +
+

@Value.Title @Value.Version

+
+ @if (!string.IsNullOrWhiteSpace(Value.Contact?.Name)) + { +
+ @Value.Contact.Name +
+ } + @if (Value.Contact?.Url != null) + { +
+ @Value.Contact.Url +
+ } + @if (Value.Contact?.Email != null) + { +
+ @Value.Contact.Email +
+ } +
+ @if (!string.IsNullOrWhiteSpace(DownloadUrl)) { -
- @Value.Contact.Name -
- } - @if (Value.Contact?.Url != null) - { -
- @Value.Contact.Url -
- } - @if (Value.Contact?.Email != null) - { -
- @Value.Contact.Email -
+ @DownloadUrl }
- @if (!string.IsNullOrWhiteSpace(DownloadUrl)) - { - @DownloadUrl - } -
- @if (!string.IsNullOrWhiteSpace(Value.Description)) - { -

Description

- - } + @if (!string.IsNullOrWhiteSpace(Value.Description)) + { +

Description

+ + } + + } @code { diff --git a/BlazorOpenApi/Controls/OperationControl.razor b/BlazorOpenApi/Controls/OperationControl.razor index 10b6247..add8cec 100644 --- a/BlazorOpenApi/Controls/OperationControl.razor +++ b/BlazorOpenApi/Controls/OperationControl.razor @@ -1,69 +1,71 @@ -@if (Value != null) -{ -
-
@Operation
-
-
-
@Endpoint
-
-
-
@Value.Summary
- @if (Value.Tags.Count > 0) + + @if (Value != null) + { +
+
@Operation
+
-
+
@Endpoint
+
-
+
@Value.Summary
+ @if (Value.Tags.Count > 0) + { +
+ @foreach (var tag in Value.Tags) + { + + + + } +
+ } + @if (!Collapsed) + { + + + + } + else + { + + + + } + +
+ @if (!Collapsed) { -
- @foreach (var tag in Value.Tags) +
+ + @if (Value != null) { - - - + + + if (Value.Servers?.Count > 0) + { + + } + + + + + + @if (true) + { + var example = GenerateExampleData(); + if (!string.IsNullOrWhiteSpace(example)) + { +
+

Example Data

+
@GenerateExampleData()
+
+ } + + } }
} - @if (_expanded) - { - - - - } - else - { - - - - } - -
- @if ( _expanded ) - { -
- - @if (Value != null) - { - - - if (Value.Servers?.Count > 0) - { - - } - - - - - - @if (true) - { - var example = GenerateExampleData(); - if ( !string.IsNullOrWhiteSpace(example) ) - { -
-

Example Data

-
@GenerateExampleData()
-
- } - - } - } -
} -} +
@code { [Parameter] @@ -73,13 +75,20 @@ [Parameter] public string Endpoint { get; set; } = ""; + [CascadingParameter(Name = "OpenAPIUI_TOC")] public ITableOfContentsTree Tree { get; set; } = null!; + [CascadingParameter(Name = "OpenAPIUI_TOC_Parent")] public string Parent { get; set; } = ""; + + private string _anchor = $"op_anc{Random.Shared.Next():X8}"; + private string TocTitle => $"{Endpoint} - [{Operation.ToString().ToUpper()}] {Value?.Summary}"; + private string OperationClass => $"op-{Operation.ToString().ToLower()}"; - private bool _expanded = true; + private bool Collapsed => Tree.IsCollapsed(_anchor); private void Expand() { - _expanded = !_expanded; + var collapsed = !Tree.IsCollapsed(_anchor); + Tree.Collapse(_anchor, collapsed); StateHasChanged(); } diff --git a/BlazorOpenApi/Controls/PathControl.razor b/BlazorOpenApi/Controls/PathControl.razor index 696a324..223f8da 100644 --- a/BlazorOpenApi/Controls/PathControl.razor +++ b/BlazorOpenApi/Controls/PathControl.razor @@ -1,35 +1,37 @@ @if (!string.IsNullOrWhiteSpace(Key) || Value != null) { - @if (!string.IsNullOrWhiteSpace(@Value?.Summary) || !string.IsNullOrWhiteSpace(Value?.Description)) - { -
-

@Key

-
-
-
@Value?.Summary
-
- } - -
- @if (Value != null) + + @if (!string.IsNullOrWhiteSpace(@Value?.Summary) || !string.IsNullOrWhiteSpace(Value?.Description)) { - - - if (Value.Servers?.Count > 0) - { - - } - - @if (Value.Operations?.Count > 0) - { -
- @foreach (var (key, op) in @Value.Operations.Where(x => x.Value != null)) - { - - } -
- } +
+

@Key

+
-
+
@Value?.Summary
+
} -
+ +
+ @if (Value != null) + { + + + if (Value.Servers?.Count > 0) + { + + } + + @if (Value.Operations?.Count > 0) + { +
+ @foreach (var (key, op) in @Value.Operations.Where(x => x.Value != null)) + { + + } +
+ } + } +
+ } @code { diff --git a/BlazorOpenApi/Controls/ServersControl.razor b/BlazorOpenApi/Controls/ServersControl.razor index 259abbf..1d8a01b 100644 --- a/BlazorOpenApi/Controls/ServersControl.razor +++ b/BlazorOpenApi/Controls/ServersControl.razor @@ -1,62 +1,64 @@ @if (Value != null && Value.Count > 0) { -

Servers

- - @foreach (var server in Value) - { - - - - - @if (server.Variables?.Count > 0) + +

Servers

+
@server.Url
+ @foreach (var server in Value) { - + + + @if (server.Variables?.Count > 0) + { + + + + } } - } -
-
- - - - - - - - - - @foreach (var (key, val) in server.Variables.OrderBy(x => x.Key)) - { - - - - - - - } - -
NameDescriptionValue
@key - - @if (val.Enum?.Count > 0) - { -
    - @foreach (var v in val.Enum) - { - if (string.IsNullOrWhiteSpace(v)) - { -
  • < empty string >
  • - } - else - { -
  • @v
  • - } - } -
- } -
-
-
@server.Url
+
+ + + + + + + + + + @foreach (var (key, val) in server.Variables.OrderBy(x => x.Key)) + { + + + + + + + } + +
NameDescriptionValue
@key + + @if (val.Enum?.Count > 0) + { +
    + @foreach (var v in val.Enum) + { + if (string.IsNullOrWhiteSpace(v)) + { +
  • < empty string >
  • + } + else + { +
  • @v
  • + } + } +
+ } +
+
+
+ + } @code { diff --git a/BlazorOpenApi/Controls/TableOfContents.razor b/BlazorOpenApi/Controls/TableOfContents.razor new file mode 100644 index 0000000..044ddd7 --- /dev/null +++ b/BlazorOpenApi/Controls/TableOfContents.razor @@ -0,0 +1,48 @@ +@inject IJSRuntime _js; +@implements IDisposable + +
+ @Title + @foreach (var child in Tree.GetChildren(Anchor)) + { + + } +
+ +@code{ + [Parameter] public string Class { get; set; } = "toc-level"; + [Parameter] public string Anchor { get; set; } = ""; + [Parameter] public string Title { get; set; } = ""; + [Parameter] public ITableOfContentsTree Tree { get; set; } = null!; + + private string AdaptedTitle => string.IsNullOrWhiteSpace(Title) ? "Root" : Title; + private bool _initialized; + + protected override void OnParametersSet() + { + base.OnParametersSet(); + if (Tree != null && !_initialized) + { + Tree.Changed += OnTocChanged; + _initialized = true; + } + } + + public void Dispose() + { + if (Tree != null) + { + Tree.Changed -= OnTocChanged; + } + } + + private void OnTocChanged(object? sender, EventArgs e) + { + StateHasChanged(); + } + + private async Task ScrolltoAnchor() + { + await _js.InvokeVoidAsync("ScrollTo", Anchor); + } +} \ No newline at end of file diff --git a/BlazorOpenApi/Controls/TocMember.razor b/BlazorOpenApi/Controls/TocMember.razor new file mode 100644 index 0000000..27f57a9 --- /dev/null +++ b/BlazorOpenApi/Controls/TocMember.razor @@ -0,0 +1,45 @@ +@if (string.IsNullOrWhiteSpace(Anchor)) +{ + +
+ @if (ChildContent != null) + { + @ChildContent + } +
+
+} +else +{ + +
+ @if (ChildContent != null) + { + @ChildContent + } +
+
+} + + +@code{ + [Parameter] public string Title { get; set; } = ""; + [Parameter] public string Anchor { get; set; } = ""; + [Parameter] public bool Collapsed { get; set; } + + [Parameter] public RenderFragment? ChildContent { get; set; } + + [CascadingParameter(Name = "OpenAPIUI_TOC")] public ITableOfContentsTree Tree { get; set; } = null!; + [CascadingParameter(Name = "OpenAPIUI_TOC_Parent")] public string Parent { get; set; } = ""; + + private string _anchor = $"anc{Random.Shared.Next():X8}"; + + protected override void OnAfterRender(bool firstRender) + { + if (!firstRender) + return; + + Tree.Add(Title, string.IsNullOrWhiteSpace(Anchor) ? _anchor : Anchor, Parent, Collapsed); + } + +} \ No newline at end of file diff --git a/BlazorOpenApi/IExpandoTree.cs b/BlazorOpenApi/ITableOfContentsTree.cs similarity index 67% rename from BlazorOpenApi/IExpandoTree.cs rename to BlazorOpenApi/ITableOfContentsTree.cs index 54d45e8..6de751c 100644 --- a/BlazorOpenApi/IExpandoTree.cs +++ b/BlazorOpenApi/ITableOfContentsTree.cs @@ -6,21 +6,25 @@ using System.Threading.Tasks; namespace BlazorOpenApi; -public class ExpandoTreeNode +public class TocTreeNode { public string Name { get; set; } = string.Empty; public string Anchor { get; set; } = string.Empty; public string ParentAnchor { get; set; } = string.Empty; public bool Collapsed { get; set; } + + public override string ToString() => $"Name: {Name}, Anchor: {Anchor}, ParentAnchor: {ParentAnchor}, Collapsed: {Collapsed}"; } -public interface IExpandoTree +public interface ITableOfContentsTree { + public event EventHandler Changed; + void Add(string name, string anchor, string parentAnchor, bool collapsed); void Collapse(string anchor, bool collapsed); bool IsCollapsed(string anchor); bool Exists(string anchor); - ExpandoTreeNode[] GetChildren(string anchor); + TocTreeNode[] GetChildren(string anchor); } //implement IExpandoTree \ No newline at end of file diff --git a/BlazorOpenApi/OpenAPIUIControl.razor b/BlazorOpenApi/OpenAPIUIControl.razor index 34f9567..029f6e1 100644 --- a/BlazorOpenApi/OpenAPIUIControl.razor +++ b/BlazorOpenApi/OpenAPIUIControl.razor @@ -2,6 +2,7 @@ @using Microsoft.OpenApi.Models @using Microsoft.OpenApi.Readers + @if (Palette != null ) { @@ -10,44 +11,58 @@
- + + + @if (_api.Paths?.Count > 0) { -

Endpoints

- foreach (var path in _api.Paths) - { - if (!string.IsNullOrWhiteSpace(path.Key) || path.Value != null) + +

Endpoints

+ @foreach (var path in _api.Paths) { - + if (!string.IsNullOrWhiteSpace(path.Key) || path.Value != null) + { + + } } - } +
} @if (_api.Components != null) { -

Components

- + +

Components

+ +
}
+
@code { - [Parameter] public string Url { get; set; } = ""; - [Parameter] public string Json { get; set; } = ""; + [Parameter] public string Url { get; set; } = ""; + [Parameter] public string Json { get; set; } = ""; [Parameter] public OpenApiUiPalette? Palette { get; set; } private string _loadedFor = ""; private OpenApiDocument _api = new(); - private IExpandoTree _tree = new ExpandoTree(); + private ITableOfContentsTree _tree = new TableOfContentsTree(); private MarkupString PaletteStr => Palette?.AsMarkupString ?? new(); + protected override void OnAfterRender(bool firstRender) + { + if (!firstRender) + return; + StateHasChanged(); + } + protected override async Task OnParametersSetAsync() { var loadedFromUrl = _loadedFor == Url && !string.IsNullOrWhiteSpace(Url); diff --git a/BlazorOpenApi/ExpandoTree.cs b/BlazorOpenApi/TableOfContentsTree.cs similarity index 66% rename from BlazorOpenApi/ExpandoTree.cs rename to BlazorOpenApi/TableOfContentsTree.cs index 2c81807..b4dd618 100644 --- a/BlazorOpenApi/ExpandoTree.cs +++ b/BlazorOpenApi/TableOfContentsTree.cs @@ -4,19 +4,26 @@ using System.Linq; namespace BlazorOpenApi; -internal class ExpandoTree : IExpandoTree +internal class TableOfContentsTree : ITableOfContentsTree { - private readonly Dictionary _nodes = new(); + private readonly Dictionary _nodes = new(); private readonly List _order = new(); + public event EventHandler Changed; + public void Add(string name, string anchor, string parentAnchor, bool collapsed) { if (_nodes.ContainsKey(anchor)) { - throw new InvalidOperationException($"A node with anchor '{anchor}' already exists."); + throw new InvalidOperationException($"A node with anchor '{anchor}' already exists for {name}."); } - var node = new ExpandoTreeNode + if ( parentAnchor != "" && !_nodes.ContainsKey(parentAnchor)) + { + throw new InvalidOperationException($"A parent anchor '{parentAnchor}' is not registered for {name}."); + } + + var node = new TocTreeNode { Name = name, Anchor = anchor, @@ -26,6 +33,11 @@ internal class ExpandoTree : IExpandoTree _nodes[anchor] = node; _order.Add(anchor); + + if ( Changed != null ) + { + Changed(this, EventArgs.Empty); + } } public void Collapse(string anchor, bool collapsed) @@ -47,7 +59,7 @@ internal class ExpandoTree : IExpandoTree public bool Exists(string anchor) => _nodes.ContainsKey(anchor); - public ExpandoTreeNode[] GetChildren(string anchor) => _order + public TocTreeNode[] GetChildren(string anchor) => _order .Where(x => _nodes[x].ParentAnchor == anchor) .Select(x => _nodes[x]) .ToArray(); diff --git a/BlazorOpenApi/wwwroot/css/openapi-ui.css b/BlazorOpenApi/wwwroot/css/openapi-ui.css index 6ef7161..4480795 100644 --- a/BlazorOpenApi/wwwroot/css/openapi-ui.css +++ b/BlazorOpenApi/wwwroot/css/openapi-ui.css @@ -1,4 +1,9 @@ -.descriminator {} +.toc-level { + margin-left: 20px; +} + +.descriminator { +} .example {} .e-item {} diff --git a/BlazorOpenApi/wwwroot/js/BlazorOpenAPI.js b/BlazorOpenApi/wwwroot/js/BlazorOpenAPI.js new file mode 100644 index 0000000..cd809df --- /dev/null +++ b/BlazorOpenApi/wwwroot/js/BlazorOpenAPI.js @@ -0,0 +1,10 @@ +function ScrollTo(elementId) { + var element = document.getElementById(elementId); + if (element != null) { + element.scrollIntoView({ + block: "start" +// behavior: 'smooth' + }); + window.scrollBy(0, -64); // Adjust scrolling with a negative value here + } +} \ No newline at end of file diff --git a/Demo/Shared/MainLayout.razor b/Demo/Shared/MainLayout.razor index a85d758..32b8fd5 100644 --- a/Demo/Shared/MainLayout.razor +++ b/Demo/Shared/MainLayout.razor @@ -20,13 +20,13 @@
- - - + + + @Body - - + + @code {