< Summary

Line coverage
0%
Covered lines: 0
Uncovered lines: 409
Coverable lines: 409
Total lines: 886
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 276
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Shared/ExternalLinkDetailPanel.razor

#LineLine coverage
 1@using System.Text.Json
 2@using Chronicis.Client.Models
 3@using Chronicis.Shared.DTOs
 4@inject IRenderDefinitionService RenderDefinitionService
 5@inject IMarkdownService MarkdownService
 6@inject ILogger<ExternalLinkDetailPanel> Logger
 7
 08@if (Content == null)
 9{
 010    return;
 11}
 12
 013@if (_isLoading)
 14{
 15    <div class="d-flex justify-center pa-4">
 16        <MudProgressCircular Size="Size.Small" Indeterminate="true" />
 17    </div>
 18}
 019else if (_useStructuredRendering && _parsedJson.HasValue)
 20{
 21    <div class="elp-structured">
 022        @RenderStructured(_parsedJson.Value, _definition!)
 23    </div>
 24}
 025else if (!string.IsNullOrWhiteSpace(Content.Markdown))
 26{
 27    <!-- Fallback: render markdown as before -->
 28    <div class="external-link-preview-body chronicis-scrollbar-light">
 029        @((MarkupString)MarkdownService.ToHtml(Content.Markdown))
 30    </div>
 31}

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Shared/ExternalLinkDetailPanel.razor.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text.Json;
 3using Chronicis.Client.Models;
 4using Chronicis.Shared.DTOs;
 5using Microsoft.AspNetCore.Components;
 6
 7namespace Chronicis.Client.Components.Shared;
 8
 9public partial class ExternalLinkDetailPanel : ComponentBase
 10{
 011    [Parameter] public ExternalLinkContentDto? Content { get; set; }
 12
 13    /// <summary>
 14    /// When provided, bypasses the render definition service and uses this definition directly.
 15    /// Used by the admin render definition generator for live preview.
 16    /// </summary>
 017    [Parameter] public RenderDefinition? DefinitionOverride { get; set; }
 18
 19    private bool _useStructuredRendering;
 20    private bool _isLoading;
 21    private JsonElement? _parsedJson;
 22    private RenderDefinition? _definition;
 23    private string? _lastContentId;
 24    private RenderDefinition? _lastDefinitionOverride;
 25
 26    protected override async Task OnParametersSetAsync()
 27    {
 028        var contentId = Content?.Id;
 029        var overrideChanged = DefinitionOverride != _lastDefinitionOverride;
 30
 031        if (contentId == _lastContentId && !overrideChanged)
 032            return;
 033        _lastContentId = contentId;
 034        _lastDefinitionOverride = DefinitionOverride;
 35
 036        if (Content == null || string.IsNullOrWhiteSpace(Content.JsonData))
 37        {
 038            _useStructuredRendering = false;
 039            _isLoading = false;
 040            _parsedJson = null;
 041            _definition = null;
 042            return;
 43        }
 44
 45        try
 46        {
 047            _isLoading = true;
 48
 049            var doc = JsonDocument.Parse(Content.JsonData);
 050            var parsedJson = doc.RootElement.Clone();
 51
 052            var lastSlash = Content.Id.LastIndexOf('/');
 053            var categoryPath = lastSlash > 0 ? Content.Id[..lastSlash] : null;
 54
 055            Logger.LogInformation(
 056                "ExternalLinkDetailPanel: Parsed JSON for {Id}. Category={Category}, RootKind={Kind}",
 057                Content.Id, categoryPath, parsedJson.ValueKind);
 58
 059            var definition = DefinitionOverride
 060                ?? await RenderDefinitionService.ResolveAsync(Content.Source, categoryPath);
 61
 062            _parsedJson = parsedJson;
 063            _definition = definition;
 064            _useStructuredRendering = true;
 065            _isLoading = false;
 66
 067            Logger.LogInformation(
 068                "ExternalLinkDetailPanel: Rendering with CatchAll={CatchAll}, Sections={Sections}",
 069                _definition.CatchAll, _definition.Sections.Count);
 70
 071            StateHasChanged();
 072        }
 073        catch (JsonException ex)
 74        {
 075            Logger.LogWarning(ex, "Failed to parse JsonData for {Id}, falling back to markdown", Content.Id);
 076            _useStructuredRendering = false;
 077            _isLoading = false;
 078            StateHasChanged();
 079        }
 080    }
 81
 82    // ==================================================================================
 83    // Rendering Methods
 84    // ==================================================================================
 85
 86    /// <summary>
 87    /// Gets the 'fields' object from the root, which is the primary data container.
 88    /// </summary>
 89    private static JsonElement? GetFieldsElement(JsonElement root)
 90    {
 091        if (root.ValueKind == JsonValueKind.Object &&
 092            root.TryGetProperty("fields", out var fields) &&
 093            fields.ValueKind == JsonValueKind.Object)
 94        {
 095            return fields;
 96        }
 097        return null;
 98    }
 99
 100    /// <summary>
 101    /// Main render method — dispatches to definition-driven or generic rendering.
 102    /// </summary>
 0103    private RenderFragment RenderStructured(JsonElement root, RenderDefinition definition) => builder =>
 0104    {
 0105        var fields = GetFieldsElement(root);
 0106        var dataSource = fields ?? root;
 0107
 0108        var renderedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 0109        // Always mark hidden fields as rendered so they don't appear in catch-all
 0110        foreach (var hidden in definition.Hidden)
 0111        {
 0112            renderedPaths.Add(hidden);
 0113        }
 0114        // Title field is rendered by the drawer header, not the panel
 0115        renderedPaths.Add(definition.TitleField);
 0116
 0117        var seq = 0;
 0118
 0119        // Render defined sections
 0120        foreach (var section in definition.Sections)
 0121        {
 0122            RenderSection(builder, ref seq, dataSource, section, renderedPaths);
 0123        }
 0124
 0125        // Catch-all: render any remaining fields not covered by sections
 0126        if (definition.CatchAll)
 0127        {
 0128            RenderCatchAll(builder, ref seq, dataSource, renderedPaths);
 0129        }
 0130    };
 131
 132    /// <summary>
 133    /// Renders a single defined section as an expansion panel.
 134    /// Skips sections where no fields would be visible (all null/empty with omitNull).
 135    /// </summary>
 136    private void RenderSection(
 137        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
 138        ref int seq,
 139        JsonElement dataSource,
 140        RenderSection section,
 141        HashSet<string> renderedPaths)
 142    {
 143        // If the section targets a specific path (e.g., an array), resolve it
 0144        JsonElement sectionData = dataSource;
 0145        if (!string.IsNullOrWhiteSpace(section.Path))
 146        {
 0147            if (!dataSource.TryGetProperty(section.Path, out var pathElement))
 0148                return; // Path doesn't exist in data — skip section
 149
 0150            sectionData = pathElement;
 0151            renderedPaths.Add(section.Path);
 152        }
 153
 154        // Pre-check: would any fields actually render?
 155        // If all fields are null/empty and omitNull, skip the entire section.
 0156        if (section.Fields != null && section.Fields.Count > 0 && section.Render != "stat-row")
 157        {
 0158            var hasVisibleField = false;
 0159            foreach (var field in section.Fields)
 160            {
 161                // Multi-path: check if any path has a value
 0162                if (field.Paths.Count > 1)
 163                {
 0164                    if (field.Paths.Any(p => sectionData.TryGetProperty(p, out var v) && !IsNullOrEmpty(v)))
 165                    {
 0166                        hasVisibleField = true;
 0167                        break;
 168                    }
 169                    continue;
 170                }
 171
 0172                if (!sectionData.TryGetProperty(field.Path, out var val))
 173                    continue;
 0174                if (field.OmitNull && IsNullOrEmpty(val))
 175                    continue;
 0176                hasVisibleField = true;
 0177                break;
 178            }
 0179            if (!hasVisibleField)
 180            {
 181                // Still track paths as rendered so they don't leak into catch-all
 0182                foreach (var field in section.Fields)
 0183                    foreach (var p in field.Paths)
 0184                        renderedPaths.Add(p);
 0185                return;
 186            }
 187        }
 188
 0189        builder.OpenElement(seq++, "details");
 0190        builder.AddAttribute(seq++, "class", "elp-section");
 0191        if (!section.Collapsed)
 192        {
 0193            builder.AddAttribute(seq++, "open", true);
 194        }
 195
 196        // Summary (section header)
 0197        builder.OpenElement(seq++, "summary");
 0198        builder.AddAttribute(seq++, "class", "elp-section-header");
 0199        builder.AddContent(seq++, section.Label);
 0200        builder.CloseElement(); // summary
 201
 202        // Section content
 0203        builder.OpenElement(seq++, "div");
 0204        builder.AddAttribute(seq++, "class", "elp-section-body");
 205
 0206        if (section.Render == "stat-row" && section.Fields != null)
 207        {
 0208            RenderStatRow(builder, ref seq, sectionData, section.Fields);
 0209            foreach (var field in section.Fields)
 0210                renderedPaths.Add(field.Path);
 211        }
 0212        else if (section.Render == "list" && sectionData.ValueKind == JsonValueKind.Array)
 213        {
 0214            RenderArrayAsList(builder, ref seq, sectionData, section.ItemFields);
 215        }
 0216        else if (section.Render == "table" && sectionData.ValueKind == JsonValueKind.Array)
 217        {
 0218            RenderArrayAsTable(builder, ref seq, sectionData, section.ItemFields);
 219        }
 0220        else if (section.Fields != null)
 221        {
 0222            foreach (var field in section.Fields)
 223            {
 0224                RenderDefinedField(builder, ref seq, sectionData, field);
 0225                foreach (var p in field.Paths)
 0226                    renderedPaths.Add(p);
 227            }
 228        }
 229
 0230        builder.CloseElement(); // div.elp-section-body
 0231        builder.CloseElement(); // details.elp-section
 0232    }
 233
 234    /// <summary>
 235    /// Renders all fields not already covered by defined sections.
 236    /// Auto-discovers structure: nested objects with 'fields' become subsections.
 237    /// </summary>
 238    private void RenderCatchAll(
 239        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
 240        ref int seq,
 241        JsonElement dataSource,
 242        HashSet<string> renderedPaths)
 243    {
 0244        if (dataSource.ValueKind != JsonValueKind.Object)
 0245            return;
 246
 0247        var unrenderedFields = new List<(string Name, JsonElement Value)>();
 0248        foreach (var prop in dataSource.EnumerateObject())
 249        {
 0250            if (renderedPaths.Contains(prop.Name))
 251                continue;
 0252            unrenderedFields.Add((prop.Name, prop.Value));
 253        }
 254
 0255        if (unrenderedFields.Count == 0)
 0256            return;
 257
 258        // Separate scalar fields from complex ones (objects/arrays)
 0259        var scalarFields = new List<(string Name, JsonElement Value)>();
 0260        var complexFields = new List<(string Name, JsonElement Value)>();
 261
 0262        foreach (var (name, value) in unrenderedFields)
 263        {
 0264            if (value.ValueKind == JsonValueKind.Object || value.ValueKind == JsonValueKind.Array)
 0265                complexFields.Add((name, value));
 266            else
 0267                scalarFields.Add((name, value));
 268        }
 269
 270        // Render scalars as a simple properties section
 0271        if (scalarFields.Count > 0)
 272        {
 0273            builder.OpenElement(seq++, "details");
 0274            builder.AddAttribute(seq++, "class", "elp-section");
 0275            builder.AddAttribute(seq++, "open", true);
 276
 0277            builder.OpenElement(seq++, "summary");
 0278            builder.AddAttribute(seq++, "class", "elp-section-header");
 0279            builder.AddContent(seq++, "Properties");
 0280            builder.CloseElement();
 281
 0282            builder.OpenElement(seq++, "div");
 0283            builder.AddAttribute(seq++, "class", "elp-section-body");
 284
 0285            foreach (var (name, value) in scalarFields)
 286            {
 287                // Skip null/empty values in catch-all to reduce noise
 0288                if (IsNullOrEmpty(value))
 289                    continue;
 0290                RenderKeyValue(builder, ref seq, FormatFieldName(name), FormatScalarValue(value));
 291            }
 292
 0293            builder.CloseElement(); // div
 0294            builder.CloseElement(); // details
 295        }
 296
 297        // Render complex fields as individual subsections
 0298        foreach (var (name, value) in complexFields)
 299        {
 0300            builder.OpenElement(seq++, "details");
 0301            builder.AddAttribute(seq++, "class", "elp-section");
 0302            builder.AddAttribute(seq++, "open", true);
 303
 0304            builder.OpenElement(seq++, "summary");
 0305            builder.AddAttribute(seq++, "class", "elp-section-header");
 0306            builder.AddContent(seq++, FormatFieldName(name));
 0307            builder.CloseElement();
 308
 0309            builder.OpenElement(seq++, "div");
 0310            builder.AddAttribute(seq++, "class", "elp-section-body");
 311
 0312            RenderGenericValue(builder, ref seq, value, 0);
 313
 0314            builder.CloseElement(); // div
 0315            builder.CloseElement(); // details
 316        }
 0317    }
 318
 319    // ==================================================================================
 320    // Field Rendering Helpers
 321    // ==================================================================================
 322
 323    /// <summary>
 324    /// Renders a field defined in a RenderField specification.
 325    /// </summary>
 326    private void RenderDefinedField(
 327        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
 328        ref int seq,
 329        JsonElement dataSource,
 330        RenderField field)
 331    {
 0332        if (field.Render == "hidden")
 0333            return;
 334
 335        // Multi-path support: resolve all paths and concatenate values
 0336        if (field.Paths.Count > 1)
 337        {
 0338            var parts = new List<string>();
 0339            foreach (var path in field.Paths)
 340            {
 0341                if (dataSource.TryGetProperty(path, out var partValue) && !IsNullOrEmpty(partValue))
 0342                    parts.Add(FormatScalarValue(partValue));
 343            }
 344
 0345            if (parts.Count == 0 && field.OmitNull)
 0346                return;
 347
 0348            var label = field.Label ?? FormatFieldName(field.Paths[0]);
 0349            var combined = parts.Count > 0 ? string.Join(" ", parts) : "—";
 0350            RenderKeyValue(builder, ref seq, label, combined);
 0351            return;
 352        }
 353
 354        // Single-path rendering (original behavior)
 0355        if (!dataSource.TryGetProperty(field.Path, out var value))
 0356            return;
 357
 358        // OmitNull: skip fields with null/empty/dash values
 0359        if (field.OmitNull && IsNullOrEmpty(value))
 0360            return;
 361
 0362        var fieldLabel = field.Label ?? FormatFieldName(field.Path);
 363
 0364        switch (field.Render)
 365        {
 366            case "heading":
 0367                builder.OpenElement(seq++, "h4");
 0368                builder.AddAttribute(seq++, "class", "elp-subheading");
 0369                builder.AddContent(seq++, FormatScalarValue(value));
 0370                builder.CloseElement();
 0371                break;
 372
 373            case "richtext":
 0374                var text = value.GetString() ?? string.Empty;
 0375                if (!string.IsNullOrWhiteSpace(text))
 376                {
 0377                    builder.OpenElement(seq++, "div");
 0378                    builder.AddAttribute(seq++, "class", "elp-richtext");
 0379                    builder.AddMarkupContent(seq++, MarkdownService.ToHtml(text));
 0380                    builder.CloseElement();
 381                }
 0382                break;
 383
 0384            case "chips" when value.ValueKind == JsonValueKind.Array:
 0385                builder.OpenElement(seq++, "div");
 0386                builder.AddAttribute(seq++, "class", "elp-field");
 387
 0388                builder.OpenElement(seq++, "span");
 0389                builder.AddAttribute(seq++, "class", "elp-field-label");
 0390                builder.AddContent(seq++, fieldLabel);
 0391                builder.CloseElement();
 392
 0393                builder.OpenElement(seq++, "div");
 0394                builder.AddAttribute(seq++, "class", "elp-chips");
 0395                foreach (var item in value.EnumerateArray())
 396                {
 0397                    builder.OpenElement(seq++, "span");
 0398                    builder.AddAttribute(seq++, "class", "elp-chip");
 0399                    builder.AddContent(seq++, FormatScalarValue(item));
 0400                    builder.CloseElement();
 401                }
 0402                builder.CloseElement(); // div.elp-chips
 0403                builder.CloseElement(); // div.elp-field
 0404                break;
 405
 406            default:
 407                // Default: key-value pair
 0408                if (value.ValueKind == JsonValueKind.Object || value.ValueKind == JsonValueKind.Array)
 409                {
 0410                    RenderGenericValue(builder, ref seq, value, 0);
 411                }
 412                else
 413                {
 0414                    RenderKeyValue(builder, ref seq, fieldLabel, FormatScalarValue(value));
 415                }
 416                break;
 417        }
 0418    }
 419
 420    /// <summary>
 421    /// Renders an array as a list of items, each rendered with itemFields or generically.
 422    /// </summary>
 423    private void RenderArrayAsList(
 424        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
 425        ref int seq,
 426        JsonElement array,
 427        List<RenderField>? itemFields)
 428    {
 0429        foreach (var item in array.EnumerateArray())
 430        {
 0431            builder.OpenElement(seq++, "div");
 0432            builder.AddAttribute(seq++, "class", "elp-list-item");
 433
 0434            if (item.ValueKind == JsonValueKind.Object)
 435            {
 436                // Check for nested fields pattern
 0437                var innerData = GetFieldsElement(item) ?? item;
 438
 0439                if (itemFields != null && itemFields.Count > 0)
 440                {
 0441                    foreach (var field in itemFields)
 442                    {
 0443                        RenderDefinedField(builder, ref seq, innerData, field);
 444                    }
 445                }
 446                else
 447                {
 0448                    RenderGenericObject(builder, ref seq, innerData, 0);
 449                }
 450            }
 451            else
 452            {
 0453                builder.OpenElement(seq++, "span");
 0454                builder.AddContent(seq++, FormatScalarValue(item));
 0455                builder.CloseElement();
 456            }
 457
 0458            builder.CloseElement(); // div.elp-list-item
 459        }
 0460    }
 461
 462    /// <summary>
 463    /// Renders an array as a table (for flat, uniform arrays).
 464    /// </summary>
 465    private void RenderArrayAsTable(
 466        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
 467        ref int seq,
 468        JsonElement array,
 469        List<RenderField>? itemFields)
 470    {
 471        // Determine columns from itemFields or first array item
 0472        var columns = new List<(string Path, string Label)>();
 0473        if (itemFields != null && itemFields.Count > 0)
 474        {
 0475            columns = itemFields
 0476                .Where(f => f.Render != "hidden")
 0477                .Select(f => (f.Path, Label: f.Label ?? FormatFieldName(f.Path)))
 0478                .ToList();
 479        }
 480        else
 481        {
 482            // Auto-detect from first item
 0483            foreach (var item in array.EnumerateArray())
 484            {
 0485                if (item.ValueKind == JsonValueKind.Object)
 486                {
 0487                    var source = GetFieldsElement(item) ?? item;
 0488                    foreach (var prop in source.EnumerateObject())
 489                    {
 0490                        if (prop.Value.ValueKind != JsonValueKind.Object &&
 0491                            prop.Value.ValueKind != JsonValueKind.Array)
 492                        {
 0493                            columns.Add((prop.Name, FormatFieldName(prop.Name)));
 494                        }
 495                    }
 496                }
 497                break; // Only inspect first item
 498            }
 499        }
 500
 0501        if (columns.Count == 0)
 0502            return;
 503
 0504        builder.OpenElement(seq++, "table");
 0505        builder.AddAttribute(seq++, "class", "elp-table");
 506
 507        // Header row
 0508        builder.OpenElement(seq++, "thead");
 0509        builder.OpenElement(seq++, "tr");
 0510        foreach (var (_, label) in columns)
 511        {
 0512            builder.OpenElement(seq++, "th");
 0513            builder.AddContent(seq++, label);
 0514            builder.CloseElement();
 515        }
 0516        builder.CloseElement(); // tr
 0517        builder.CloseElement(); // thead
 518
 519        // Data rows
 0520        builder.OpenElement(seq++, "tbody");
 0521        foreach (var item in array.EnumerateArray())
 522        {
 0523            if (item.ValueKind != JsonValueKind.Object)
 524                continue;
 0525            var source = GetFieldsElement(item) ?? item;
 526
 0527            builder.OpenElement(seq++, "tr");
 0528            foreach (var (path, _) in columns)
 529            {
 0530                builder.OpenElement(seq++, "td");
 0531                if (source.TryGetProperty(path, out var cellValue))
 532                {
 0533                    builder.AddContent(seq++, FormatScalarValue(cellValue));
 534                }
 0535                builder.CloseElement();
 536            }
 0537            builder.CloseElement(); // tr
 538        }
 0539        builder.CloseElement(); // tbody
 0540        builder.CloseElement(); // table
 0541    }
 542
 543    // ==================================================================================
 544    // Generic Rendering (no definition)
 545    // ==================================================================================
 546
 547    /// <summary>
 548    /// Renders any JSON value generically. Handles recursive structure.
 549    /// </summary>
 550    private void RenderGenericValue(
 551        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
 552        ref int seq,
 553        JsonElement value,
 554        int depth)
 555    {
 0556        if (depth > 10)
 0557            return; // Safety limit
 558
 0559        switch (value.ValueKind)
 560        {
 561            case JsonValueKind.Object:
 562                // Check for the { fields: {...} } pattern — unwrap it
 0563                var inner = GetFieldsElement(value) ?? value;
 0564                RenderGenericObject(builder, ref seq, inner, depth);
 0565                break;
 566
 567            case JsonValueKind.Array:
 0568                RenderGenericArray(builder, ref seq, value, depth);
 0569                break;
 570
 571            default:
 0572                builder.OpenElement(seq++, "span");
 0573                builder.AddContent(seq++, FormatScalarValue(value));
 0574                builder.CloseElement();
 575                break;
 576        }
 0577    }
 578
 579    /// <summary>
 580    /// Renders an object's properties as key-value pairs or nested subsections.
 581    /// </summary>
 582    private void RenderGenericObject(
 583        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
 584        ref int seq,
 585        JsonElement obj,
 586        int depth)
 587    {
 0588        foreach (var prop in obj.EnumerateObject())
 589        {
 0590            if (prop.Value.ValueKind == JsonValueKind.Null)
 591                continue;
 592
 0593            var label = FormatFieldName(prop.Name);
 594
 0595            if (prop.Value.ValueKind == JsonValueKind.Object ||
 0596                prop.Value.ValueKind == JsonValueKind.Array)
 597            {
 598                // Nested complex value — render as a collapsible subsection
 0599                builder.OpenElement(seq++, "details");
 0600                builder.AddAttribute(seq++, "class", "elp-subsection");
 0601                builder.AddAttribute(seq++, "open", true);
 602
 0603                builder.OpenElement(seq++, "summary");
 0604                builder.AddAttribute(seq++, "class", "elp-subsection-header");
 0605                builder.AddContent(seq++, label);
 0606                builder.CloseElement();
 607
 0608                builder.OpenElement(seq++, "div");
 0609                builder.AddAttribute(seq++, "class", "elp-subsection-body");
 0610                RenderGenericValue(builder, ref seq, prop.Value, depth + 1);
 0611                builder.CloseElement();
 612
 0613                builder.CloseElement(); // details
 614            }
 615            else
 616            {
 0617                RenderKeyValue(builder, ref seq, label, FormatScalarValue(prop.Value));
 618            }
 619        }
 0620    }
 621
 622    /// <summary>
 623    /// Renders an array generically — simple values as a comma list,
 624    /// objects as individual items with dividers.
 625    /// </summary>
 626    private void RenderGenericArray(
 627        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
 628        ref int seq,
 629        JsonElement array,
 630        int depth)
 631    {
 632        // Check if all items are scalars
 0633        var allScalar = true;
 0634        foreach (var item in array.EnumerateArray())
 635        {
 0636            if (item.ValueKind == JsonValueKind.Object || item.ValueKind == JsonValueKind.Array)
 637            {
 0638                allScalar = false;
 0639                break;
 640            }
 641        }
 642
 0643        if (allScalar)
 644        {
 645            // Render as chips/tags
 0646            builder.OpenElement(seq++, "div");
 0647            builder.AddAttribute(seq++, "class", "elp-chips");
 0648            foreach (var item in array.EnumerateArray())
 649            {
 0650                builder.OpenElement(seq++, "span");
 0651                builder.AddAttribute(seq++, "class", "elp-chip");
 0652                builder.AddContent(seq++, FormatScalarValue(item));
 0653                builder.CloseElement();
 654            }
 0655            builder.CloseElement();
 656        }
 657        else
 658        {
 659            // Render each item as a block
 0660            foreach (var item in array.EnumerateArray())
 661            {
 0662                builder.OpenElement(seq++, "div");
 0663                builder.AddAttribute(seq++, "class", "elp-list-item");
 0664                RenderGenericValue(builder, ref seq, item, depth + 1);
 0665                builder.CloseElement();
 666            }
 667        }
 0668    }
 669
 670    // ==================================================================================
 671    // Primitives
 672    // ==================================================================================
 673
 674    /// <summary>
 675    /// Renders a simple label: value pair.
 676    /// Values longer than 50 characters stack below the label for readability.
 677    /// </summary>
 678    private static void RenderKeyValue(
 679        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
 680        ref int seq,
 681        string label,
 682        string value)
 683    {
 0684        var isBlock = value.Length > 50;
 0685        builder.OpenElement(seq++, "div");
 0686        builder.AddAttribute(seq++, "class", isBlock ? "elp-field elp-field--block" : "elp-field");
 687
 0688        builder.OpenElement(seq++, "span");
 0689        builder.AddAttribute(seq++, "class", "elp-field-label");
 0690        builder.AddContent(seq++, label);
 0691        builder.CloseElement();
 692
 0693        builder.OpenElement(seq++, "span");
 0694        builder.AddAttribute(seq++, "class", "elp-field-value");
 0695        builder.AddContent(seq++, value);
 0696        builder.CloseElement();
 697
 0698        builder.CloseElement(); // div.elp-field
 0699    }
 700
 701    // ==================================================================================
 702    // Formatting Utilities
 703    // ==================================================================================
 704
 705    /// <summary>
 706    /// Resolves the display value for a field, handling both single-path and multi-path fields.
 707    /// For multi-path, concatenates non-null values with a space separator.
 708    /// Returns (resolvedValue, found). When not found, resolvedValue is default.
 709    /// </summary>
 710    private static (JsonElement value, bool found) ResolveFieldValue(JsonElement dataSource, RenderField field)
 711    {
 0712        if (field.Paths.Count <= 1)
 713        {
 714            // Single path — standard lookup
 0715            if (dataSource.TryGetProperty(field.Path, out var val))
 0716                return (val, true);
 0717            return (default, false);
 718        }
 719
 720        // Multi-path: concatenate non-null text values
 0721        var parts = new List<string>();
 0722        foreach (var path in field.Paths)
 723        {
 0724            if (dataSource.TryGetProperty(path, out var val) && !IsNullOrEmpty(val))
 0725                parts.Add(FormatScalarValue(val));
 726        }
 727
 0728        if (parts.Count == 0)
 0729            return (default, false);
 730
 731        // Return as a synthetic string JsonElement via round-trip
 0732        var combined = string.Join(" ", parts);
 0733        using var doc = JsonDocument.Parse($"\"{combined.Replace("\"", "\\\"")}\"");
 0734        return (doc.RootElement.Clone(), true);
 0735    }
 736
 737    /// <summary>
 738    /// Returns all JSON field paths referenced by a RenderField (for rendered-path tracking).
 739    /// </summary>
 0740    private static IEnumerable<string> GetAllPaths(RenderField field) => field.Paths;
 741
 742    /// <summary>
 743    /// Formats a JSON field name for display: snake_case/camelCase → Title Case.
 744    /// </summary>
 745    private static string FormatFieldName(string fieldName)
 746    {
 0747        if (string.IsNullOrWhiteSpace(fieldName))
 0748            return fieldName;
 749
 750        // Replace underscores with spaces
 0751        var withSpaces = fieldName.Replace('_', ' ');
 752
 753        // Insert spaces before capitals (camelCase)
 0754        var chars = new List<char>();
 0755        for (int i = 0; i < withSpaces.Length; i++)
 756        {
 0757            if (i > 0 && char.IsUpper(withSpaces[i]) && char.IsLower(withSpaces[i - 1]))
 0758                chars.Add(' ');
 0759            chars.Add(withSpaces[i]);
 760        }
 761
 762        // Title case
 0763        var words = new string(chars.ToArray())
 0764            .Split(' ', StringSplitOptions.RemoveEmptyEntries);
 0765        var titleCased = words.Select(w =>
 0766            w.Length > 0
 0767                ? char.ToUpper(w[0], CultureInfo.InvariantCulture) + w[1..].ToLower(CultureInfo.InvariantCulture)
 0768                : w);
 769
 0770        return string.Join(" ", titleCased);
 771    }
 772
 773    /// <summary>
 774    /// Formats a scalar JSON value for display.
 775    /// </summary>
 776    private static string FormatScalarValue(JsonElement value)
 777    {
 0778        return value.ValueKind switch
 0779        {
 0780            JsonValueKind.String => value.GetString() ?? string.Empty,
 0781            JsonValueKind.Number => value.GetRawText(),
 0782            JsonValueKind.True => "Yes",
 0783            JsonValueKind.False => "No",
 0784            JsonValueKind.Null => "—",
 0785            _ => value.GetRawText()
 0786        };
 787    }
 788
 789    /// <summary>
 790    /// Returns true if the JSON value is null, empty string, or the em-dash placeholder.
 791    /// </summary>
 792    private static bool IsNullOrEmpty(JsonElement value)
 793    {
 0794        if (value.ValueKind == JsonValueKind.Null || value.ValueKind == JsonValueKind.Undefined)
 0795            return true;
 796
 0797        if (value.ValueKind == JsonValueKind.String)
 798        {
 0799            var str = value.GetString();
 0800            return string.IsNullOrWhiteSpace(str) || str == "—" || str == "-";
 801        }
 802
 0803        if (value.ValueKind == JsonValueKind.Array && value.GetArrayLength() == 0)
 0804            return true;
 805
 0806        return false;
 807    }
 808
 809    /// <summary>
 810    /// Renders fields as a compact horizontal stat row (labels on top, values below).
 811    /// Used for D&amp;D ability scores and similar compact stat groups.
 812    /// </summary>
 813    private static void RenderStatRow(
 814        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
 815        ref int seq,
 816        JsonElement dataSource,
 817        List<RenderField> fields)
 818    {
 0819        builder.OpenElement(seq++, "table");
 0820        builder.AddAttribute(seq++, "class", "elp-stat-row");
 821
 822        // Header row: labels
 0823        builder.OpenElement(seq++, "thead");
 0824        builder.OpenElement(seq++, "tr");
 0825        foreach (var field in fields)
 826        {
 0827            builder.OpenElement(seq++, "th");
 0828            builder.AddContent(seq++, field.Label ?? FormatFieldName(field.Path));
 0829            builder.CloseElement();
 830        }
 0831        builder.CloseElement(); // tr
 0832        builder.CloseElement(); // thead
 833
 834        // Value row
 0835        builder.OpenElement(seq++, "tbody");
 0836        builder.OpenElement(seq++, "tr");
 0837        foreach (var field in fields)
 838        {
 0839            builder.OpenElement(seq++, "td");
 0840            if (dataSource.TryGetProperty(field.Path, out var value))
 841            {
 0842                builder.AddContent(seq++, FormatScalarValue(value));
 843            }
 844            else
 845            {
 0846                builder.AddContent(seq++, "—");
 847            }
 0848            builder.CloseElement();
 849        }
 0850        builder.CloseElement(); // tr
 0851        builder.CloseElement(); // tbody
 852
 0853        builder.CloseElement(); // table
 0854    }
 855}

Methods/Properties

BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder)
get_Content()
get_DefinitionOverride()
OnParametersSetAsync()
GetFieldsElement(System.Text.Json.JsonElement)
RenderStructured(System.Text.Json.JsonElement,Chronicis.Client.Models.RenderDefinition)
RenderSection(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder,System.Int32&,System.Text.Json.JsonElement,Chronicis.Client.Models.RenderSection,System.Collections.Generic.HashSet`1<System.String>)
RenderCatchAll(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder,System.Int32&,System.Text.Json.JsonElement,System.Collections.Generic.HashSet`1<System.String>)
RenderDefinedField(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder,System.Int32&,System.Text.Json.JsonElement,Chronicis.Client.Models.RenderField)
RenderArrayAsList(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder,System.Int32&,System.Text.Json.JsonElement,System.Collections.Generic.List`1<Chronicis.Client.Models.RenderField>)
RenderArrayAsTable(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder,System.Int32&,System.Text.Json.JsonElement,System.Collections.Generic.List`1<Chronicis.Client.Models.RenderField>)
RenderGenericValue(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder,System.Int32&,System.Text.Json.JsonElement,System.Int32)
RenderGenericObject(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder,System.Int32&,System.Text.Json.JsonElement,System.Int32)
RenderGenericArray(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder,System.Int32&,System.Text.Json.JsonElement,System.Int32)
RenderKeyValue(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder,System.Int32&,System.String,System.String)
ResolveFieldValue(System.Text.Json.JsonElement,Chronicis.Client.Models.RenderField)
GetAllPaths(Chronicis.Client.Models.RenderField)
FormatFieldName(System.String)
FormatScalarValue(System.Text.Json.JsonElement)
IsNullOrEmpty(System.Text.Json.JsonElement)
RenderStatRow(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder,System.Int32&,System.Text.Json.JsonElement,System.Collections.Generic.List`1<Chronicis.Client.Models.RenderField>)