TreeView and ListView: model merging

This page runs the actual Razor TagHelpers. Compare declarations alone with a supplied model plus tag-level traits.

Source: dom · DOM sample · JSON sample

Running checks…

TagHelper → new model

<bs-treeview selection-mode="single" checkable="false" …>
    <bs-treeview-node node-id="tag-node" label="Tag node" … />
</bs-treeview>
  • Tag node Tag subtitle Tag pillTag title
    • Tag child
    • Disabled by tag
  • Tag item Tag subtitle Tag pillTag title
  • Disabled by tag

Supplied model + tag-level traits

<bs-treeview model="@Model.TreeViewModel"
             selection-mode="single" checkable="false" …>
    <bs-treeview-node model="@Model.NodeModel" subtitle="Tag subtitle" … />
</bs-treeview>
  • Existing model node
    • Existing child
  • Model node label Tag subtitle Tag pillTag title
    • Model nested child
  • Existing model item
  • Model item label Tag subtitle Tag pillTag title

Expected and actual values

Existing model data, keywords, occupied slots and child collections retain precedence. Tag traits fill absent values and empty node child collections. Root tag options explicitly override model options; root rows compose with captured rows.

CheckExpectedActualResult
Supplied models after Razor processing
Selection and change submissions
Select sample rows to inspect their values.
Callback activity

Standalone examples: TreeView navigation and ListView tasks.

Complete sample Razor
aspnet
@model CopelandSyst.Docs.Models.ViewMergeSampleModel
@using System.Text.Json
@inject CopelandSyst.StandardTagsLibrary.Services.INonceService SampleNonce
<link rel="stylesheet" href="/_content/CopelandSyst.BootstrapComponents/lib/Bootstrap/dist/listview/bs-listview.css" />
<link rel="stylesheet" href="/css/view-model-merging-sample.css" />
<section class="view-merge-sample">
    <h1 class="h3">TreeView and ListView: model merging</h1>
    <p>This page runs the actual Razor TagHelpers. Compare declarations alone with a supplied model plus tag-level traits.</p>
    <p>Source: <strong>@Model.Source</strong> · <a href="/Bcl/ViewModelMerging?source=dom">DOM sample</a> · <a href="/Bcl/ViewModelMerging?source=json">JSON sample</a></p>
    <p id="verification-status" role="status">Running checks…</p>
    <form id="merge-sample-form">
        <div class="row g-4">
            <section class="col-lg-6">
                <h2 class="h5">TagHelper → new model</h2>
                <pre>&lt;bs-treeview selection-mode="single" checkable="false" …&gt;
    &lt;bs-treeview-node node-id="tag-node" label="Tag node" … /&gt;
&lt;/bs-treeview&gt;</pre>
                <bs-treeview id="tag-tree" source="@Model.Source" selection-mode="single" checkable="false"
                             track-changes="true" search="false" selection-bound-name="TagTreeSelection"
                             submission-bound-name="TagTreeSubmission" event-selected="sampleTagSelected">
                    <bs-treeview-node node-id="tag-node" label="Tag node" subtitle="Tag subtitle" expanded="true"
                                      item-data="@Model.TagData" keywords="@Model.TagKeywords" bound-value="tag-value">
                        <bs-start-icon class="sample-star" label="Tag icon" intent="informative" />
                        <bs-end-near-pill text="Tag pill" title="Tag title" variant="success" />
                        <bs-treeview-node node-id="tag-child" label="Tag child" />
                        <bs-treeview-node node-id="tag-disabled" label="Disabled by tag" disabled="true" />
                    </bs-treeview-node>
                </bs-treeview>
                <bs-listview id="tag-list" source="@Model.Source" selection-mode="single" checkable="false"
                             track-changes="true" search="false" selection-bound-name="TagListSelection"
                             submission-bound-name="TagListSubmission" event-selected="sampleTagSelected">
                    <bs-listview-item item-id="tag-item" label="Tag item" subtitle="Tag subtitle"
                                      item-data="@Model.TagData" keywords="@Model.TagKeywords" bound-value="tag-value">
                        <bs-start-icon class="sample-star" label="Tag icon" intent="informative" />
                        <bs-end-near-pill text="Tag pill" title="Tag title" variant="success" />
                    </bs-listview-item>
                    <bs-listview-item item-id="tag-disabled" label="Disabled by tag" disabled="true" />
                </bs-listview>
            </section>
            <section class="col-lg-6">
                <h2 class="h5">Supplied model + tag-level traits</h2>
                <pre>&lt;bs-treeview model="@@Model.TreeViewModel"
             selection-mode="single" checkable="false" …&gt;
    &lt;bs-treeview-node model="@@Model.NodeModel" subtitle="Tag subtitle" … /&gt;
&lt;/bs-treeview&gt;</pre>
                <bs-treeview model="@Model.TreeViewModel" source="@Model.Source" selection-mode="single" checkable="false"
                             track-changes="true" search="false" selection-bound-name="MergedTreeSelection"
                             submission-bound-name="MergedTreeSubmission" event-select="sampleTagSelecting">
                    <bs-treeview-node model="@Model.NodeModel" subtitle="Tag subtitle" keywords="@Model.TagKeywords"
                                      disabled="false" bound-value="merged-value" callback-item-activated="sampleActionCompleted">
                        <bs-end-near-pill text="Tag pill" title="Tag title" variant="success" />
                        <bs-end-far-pill text="Inspect" intent="functional" on-activate="sampleAction" action-name="inspect" />
                        <bs-treeview-node node-id="tag-nested" label="Tag nested child" />
                    </bs-treeview-node>
                </bs-treeview>
                <bs-listview model="@Model.ListViewModel" source="@Model.Source" selection-mode="single" checkable="false"
                             track-changes="true" search="false" selection-bound-name="MergedListSelection"
                             submission-bound-name="MergedListSubmission" event-select="sampleTagSelecting">
                    <bs-listview-item model="@Model.ItemModel" subtitle="Tag subtitle" keywords="@Model.TagKeywords"
                                      disabled="false" bound-value="merged-value" callback-item-activated="sampleActionCompleted">
                        <bs-end-near-pill text="Tag pill" title="Tag title" variant="success" />
                        <bs-end-far-pill text="Inspect" intent="functional" on-activate="sampleAction" action-name="inspect" />
                    </bs-listview-item>
                </bs-listview>
            </section>
        </div>
        <button id="inspect-selection" type="button" class="btn btn-primary mt-3">Select sample rows and inspect submission</button>
    </form>
    <h2 class="h5 mt-4">Expected and actual values</h2>
    <p>Existing model data, keywords, occupied slots and child collections retain precedence. Tag traits fill absent values and empty node child collections. Root tag options explicitly override model options; root rows compose with captured rows.</p>
    <div class="table-responsive"><table class="table table-sm"><thead><tr><th>Check</th><th>Expected</th><th>Actual</th><th>Result</th></tr></thead><tbody id="merge-checks"></tbody></table></div>
    <details><summary>Supplied models after Razor processing</summary><pre id="server-evidence-display"></pre></details>
    <details><summary>Selection and change submissions</summary><pre id="submission-display">Select sample rows to inspect their values.</pre></details>
    <details><summary>Callback activity</summary><pre id="callback-display"></pre></details>
</section>
<script nonce="@SampleNonce.Nonce" id="server-evidence" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model.GetServerEvidence(), new JsonSerializerOptions(JsonSerializerDefaults.Web)))</script>
<script type="module" src="/js/view-model-merging-sample.js"></script>
Complete supplied models
csharp
using CopelandSyst.BootstrapComponents.ViewModels;

namespace CopelandSyst.Docs.Models;

public sealed class ViewMergeSampleModel(string source)
{
    public string Source { get; } = source;
    public List<string> TagKeywords { get; } = ["tag-keyword", "shared-keyword"];
    public object TagData { get; } = new { origin = "tag", code = "0042" };

    public BootstrapTreeViewModel TreeViewModel { get; } = new()
    {
        Id = "merged-tree", Checkable = true, TrackChanges = false, SelectionMode = "multiple",
        ServerRevision = "model-v1", EventCallbacks = new() { ["selected"] = "sampleModelSelected" },
        NodesCapture = { new BootstrapTreeViewNodeModel
        {
            NodeId = "existing-node", Label = "Existing model node", Expanded = true,
            Data = new { origin = "model", code = "0007" },
            ChildrenCapture = { new BootstrapTreeViewNodeModel { NodeId = "existing-child", Label = "Existing child" } },
        } },
    };

    public BootstrapListViewModel ListViewModel { get; } = new()
    {
        Id = "merged-list", Checkable = true, TrackChanges = false, SelectionMode = "multiple",
        ServerRevision = "model-v1", EventCallbacks = new() { ["selected"] = "sampleModelSelected" },
        ItemsCapture = { new BootstrapListViewItemModel
        {
            ItemId = "existing-item", Label = "Existing model item", Data = new { origin = "model", code = "0007" },
        } },
    };

    public BootstrapTreeViewNodeModel NodeModel { get; } = new()
    {
        NodeId = "merged-node", Label = "Model node label", Expanded = true,
        Data = new { origin = "model", code = "0007" }, Keywords = ["model-keyword", "shared-keyword"],
        StartSlotCapture = new BootstrapViewItemIconModel { Class = "sample-star", Label = "Model icon" },
        EventCallbacks = new() { ["selected"] = "sampleItemSelected" },
        ChildrenCapture = { new BootstrapTreeViewNodeModel { NodeId = "model-nested", Label = "Model nested child" } },
    };

    public BootstrapListViewItemModel ItemModel { get; } = new()
    {
        ItemId = "merged-item", Label = "Model item label",
        Data = new { origin = "model", code = "0007" }, Keywords = ["model-keyword", "shared-keyword"],
        StartSlotCapture = new BootstrapViewItemIconModel { Class = "sample-star", Label = "Model icon" },
        EventCallbacks = new() { ["selected"] = "sampleItemSelected" },
    };

    // Evaluated after Razor has run the real TagHelpers, so this observes the supplied model instances.
    public object GetServerEvidence() => new
    {
        tree = new
        {
            TreeViewModel.Checkable, TreeViewModel.TrackChanges, TreeViewModel.SelectionMode,
            TreeViewModel.SelectionBoundName, TreeViewModel.ServerRevision,
            ids = TreeViewModel.NodesCapture.Select(c => c.Model?.NodeId),
            node = new { NodeModel.Label, NodeModel.Subtitle, NodeModel.Disabled, NodeModel.Data, NodeModel.Keywords,
                near = NodeModel.EndNearSlotCapture?.ToSlotPayload(),
                children = NodeModel.ChildrenCapture.Select(c => c.Model?.NodeId) },
        },
        list = new
        {
            ListViewModel.Checkable, ListViewModel.TrackChanges, ListViewModel.SelectionMode,
            ListViewModel.SelectionBoundName, ListViewModel.ServerRevision,
            ids = ListViewModel.ItemsCapture.Select(c => c.Model?.ItemId),
            item = new { ItemModel.Label, ItemModel.Subtitle, ItemModel.Disabled, ItemModel.Data, ItemModel.Keywords,
                near = ItemModel.EndNearSlotCapture?.ToSlotPayload() },
        },
    };
}
Complete checks and interaction code
javascript
import { TreeView } from '/_content/CopelandSyst.BootstrapComponents/lib/Bootstrap/dist/treeview/bs-treeview.js';
import { ListView } from '/_content/CopelandSyst.BootstrapComponents/lib/Bootstrap/dist/listview/bs-listview.js';

const calls = [];
const record = (handler, event) => {
    calls.push({ handler, event: event.type, action: event.detail?.actionName ?? null });
    document.getElementById('callback-display').textContent = JSON.stringify(calls, null, 2);
};
for (const name of ['sampleModelSelected', 'sampleItemSelected', 'sampleTagSelected', 'sampleTagSelecting', 'sampleActionCompleted'])
    window[name] = event => { record(name, event); };
window.sampleAction = event => { record('sampleAction', event); return Promise.resolve(); };

const checks = [];
const check = (name, expected, actual) => {
    const passed = JSON.stringify(expected) === JSON.stringify(actual);
    checks.push({ name, expected, actual, passed });
    const row = document.getElementById('merge-checks').insertRow();
    for (const value of [name, JSON.stringify(expected), JSON.stringify(actual) ?? 'undefined', passed ? 'PASS' : 'FAIL'])
        row.insertCell().textContent = value;
    row.className = passed ? 'table-success' : 'table-danger';
};
try {
    const controls = Object.fromEntries(['tag-tree', 'merged-tree', 'tag-list', 'merged-list'].map(id =>
        [id, new (id.endsWith('tree') ? TreeView : ListView)(document.getElementById(id))]));
    const server = JSON.parse(document.getElementById('server-evidence').textContent);
    document.getElementById('server-evidence-display').textContent = JSON.stringify(server, null, 2);
    for (const [id, view] of Object.entries(controls)) {
        const tree = id.endsWith('tree'), merged = id.startsWith('merged');
        const state = view.getSnapshot();
        const items = state.snapshot.items;
        const row = items.find(i => i.id === `${merged ? 'merged' : 'tag'}-${tree ? 'node' : 'item'}`);
        check(`${id}: no duplicate identities`, items.length, new Set(items.map(i => i.id)).size);
        check(`${id}: selection mode`, 'single', view._config.selectionMode);
        check(`${id}: explicit false`, false, view._config.checkable);
        check(`${id}: tracking`, true, view._integration.options.trackChanges);
        check(`${id}: label`, merged ? `Model ${tree ? 'node' : 'item'} label` : `Tag ${tree ? 'node' : 'item'}`, row?.data.label);
        check(`${id}: subtitle from tag`, 'Tag subtitle', row?.data.subtitle);
        check(`${id}: application data`, { origin: merged ? 'model' : 'tag', code: merged ? '0007' : '0042' }, row?.data.data);
        check(`${id}: keywords`, merged ? ['model-keyword', 'shared-keyword'] : ['tag-keyword', 'shared-keyword'], row?.data.keywords);
        check(`${id}: icon`, 'sample-star', row?.data.itemIcon?.start?.class);
        check(`${id}: pill text`, 'Tag pill', row?.data.itemPill?.endNear?.text);
        check(`${id}: pill title`, 'Tag title', row?.data.itemPill?.endNear?.title);
        if (merged) {
            check(`${id}: model revision`, 'model-v1', state.serverRevision);
            check(`${id}: existing model row`, true, items.some(i => i.id === (tree ? 'existing-node' : 'existing-item')));
            check(`${id}: functional slot`, 'inspect', row?.data.itemPill?.endFar?.actionName);
            const evidence = server[tree ? 'tree' : 'list'];
            check(`${id}: server model false override`, false, evidence.checkable);
            check(`${id}: server nested false`, false, evidence[tree ? 'node' : 'item'].disabled);
            check(`${id}: server keywords retain model precedence`, ['model-keyword', 'shared-keyword'], evidence[tree ? 'node' : 'item'].keywords);
            if (tree) {
                check(`${id}: nested model child`, 'merged-node', items.find(i => i.id === 'model-nested')?.parentId);
                check(`${id}: populated child collection retains model precedence`, ['model-nested'], evidence.node.children);
                check(`${id}: competing tag child is not appended`, false, items.some(i => i.id === 'tag-nested'));
            }
        } else {
            check(`${id}: disabled child/item`, true, items.find(i => i.id === 'tag-disabled')?.data.disabled);
            if (tree) check(`${id}: child hierarchy`, 'tag-node', items.find(i => i.id === 'tag-child')?.parentId);
        }
    }
    document.getElementById('inspect-selection').addEventListener('click', () => {
        const submissions = {};
        for (const [id, view] of Object.entries(controls)) {
            view.select(`${id.startsWith('merged') ? 'merged' : 'tag'}-${id.endsWith('tree') ? 'node' : 'item'}`);
            submissions[id] = view.createSubmission();
        }
        document.getElementById('submission-display').textContent = JSON.stringify(submissions, null, 2);
        window.mergeSample.submissions = submissions;
    });
    window.mergeSample = { checks, controls, server, calls };
} catch (error) {
    check('Sample initialization', 'success', error.message);
    window.mergeSample = { checks, calls };
}
const failures = checks.filter(c => !c.passed);
document.getElementById('verification-status').textContent = failures.length
    ? `${failures.length} checks failed. See the expected and actual values below.`
    : `All ${checks.length} checks passed. Select rows or activate Inspect to exercise callbacks.`;