ListViewDropdown — Ajax (Drag and drop)

Open the picker to edit notices. Apply commits the chosen value and queues a background save; Cancel restores the previous value. Reordering and renaming remain independent edits. Delay, failure, retry and revision conflicts exercise the same typed acknowledgment contract as the standalone controls.

Loading saved state…

Latest request and response
Saved server snapshot

What this demonstrates

Changes appear locally first. A committed event creates a typed submission, the server validates the operation and revision, and the response acknowledges that particular snapshot. A failed request keeps local edits pending. Retry reuses an uncertain submission's identity so a lost response cannot duplicate a save; later edits follow after acknowledgment. Validation failures can be corrected and retried. Reload saved state discards local edits. A revision conflict requires reloading the saved state.

Each example has separate synthetic server state associated with this browser's cookie. State expires after 30 minutes of inactivity, may be evicted for capacity, and is cleared when the demo server restarts. Tabs in the same browser share the saved revision. Reset affects this example only.

Razor and declared handlers

Draft selection/checking stays out of submissions until Apply. Acknowledgment confirms a server save; Apply alone does not. Mobile uses a native dialog that preserves an enclosing Bootstrap modal.

aspnet
@model ViewAjaxExampleModel
@if (Model.Kind == "treeview")
{
    <bs-treeview-dropdown id="ajax-demo" model="@Model.DropdownTree" source="json" dropdown-label="Notice destinations"
        dropdown-placeholder="Choose notices…" commit-mode="explicit" value-mode="@(Model.Operation == "checking" ? "checked" : "selection")"
        selection-mode="multiple" checkable="@(Model.Operation == "checking")" search="true" popup-height="400"
        track-changes="@Model.SavesChanges" drag-and-drop="@(Model.Operation == "reordering")" reordering="@(Model.Operation == "reordering")"
        inline-rename="@(Model.Operation == "renaming")" rename-commit-on-enter="true"
        event-committed="viewAjax.save" event-moved="viewAjax.save" event-renamed="viewAjax.save" event-load-children="viewAjax.children" />
}
else
{
    <bs-listview-dropdown id="ajax-demo" model="@Model.DropdownList" source="json" dropdown-label="Notices"
        dropdown-placeholder="Choose notices…" commit-mode="explicit" value-mode="@(Model.Operation == "checking" ? "checked" : "selection")"
        selection-mode="multiple" checkable="@(Model.Operation == "checking")" search="true" popup-height="400"
        track-changes="@Model.SavesChanges" drag-and-drop="@(Model.Operation == "reordering")" reordering="@(Model.Operation == "reordering")"
        inline-rename="@(Model.Operation == "renaming")" rename-commit-on-enter="true"
        event-committed="viewAjax.save" event-moved="viewAjax.save" event-renamed="viewAjax.save" />
}
aspnet
@model ViewAjaxExampleModel
<section id="ajax-example" data-kind="@Model.Kind" data-scenario="@Model.Scenario">
    <form id="ajax-example-form">
        @Html.AntiForgeryToken()
        @if (Model.IsDropdown)
        {
            @await Html.PartialAsync("~/Views/Docs/_ViewDropdownAjaxControl.cshtml", Model)
        }
        else if (Model.Kind == "treeview")
        {
            <bs-treeview id="ajax-demo" model="@Model.Tree" source="json" search="false" selection-mode="single"
                         checkable="@(Model.Scenario == "checking")" track-changes="@Model.SavesChanges"
                         drag-and-drop="@(Model.Scenario == "reordering")" reordering="@(Model.Scenario == "reordering")"
                         inline-rename="@(Model.Scenario == "renaming")" rename-commit-on-enter="true"
                         event-checked="viewAjax.save" event-moved="viewAjax.save" event-renamed="viewAjax.save"
                         event-selected="viewAjax.selected" event-load-children="viewAjax.children" />
        }
        else
        {
            <bs-listview id="ajax-demo" model="@Model.List" source="json" search="false" selection-mode="single"
                         checkable="@(Model.Scenario == "checking")" track-changes="@Model.SavesChanges"
                         drag-and-drop="@(Model.Scenario == "reordering")" reordering="@(Model.Scenario == "reordering")"
                         inline-rename="@(Model.Scenario == "renaming")" rename-commit-on-enter="true"
                         event-checked="viewAjax.save" event-moved="viewAjax.save" event-renamed="viewAjax.save"
                         event-selected="viewAjax.selected" />
        }
    </form>
    <div class="d-flex flex-wrap gap-2 align-items-center my-3">
        <label for="ajax-delay">Response delay</label>
        <select id="ajax-delay" class="form-select w-auto">
            <option value="0">None</option><option value="1000">1 second</option><option value="2500">2.5 seconds</option>
        </select>
        <label><input id="ajax-fail" type="checkbox" class="form-check-input" /> Fail next request</label>
    </div>
    <div class="d-flex flex-wrap gap-2 mb-3">
        @if (Model.Operation == "reordering") { <button id="ajax-move" type="button" class="btn btn-primary">Move selected to end</button> }
        @if (Model.SavesChanges)
        {
            <button id="ajax-retry" type="button" class="btn btn-outline-primary">Retry save</button>
            <button id="ajax-conflict" type="button" class="btn btn-outline-secondary">Simulate another editor</button>
        }
        @if (Model.Scenario == "selection") { <button id="ajax-retry-details" type="button" class="btn btn-outline-primary">Retry details</button> }
        <button id="ajax-reload" type="button" class="btn btn-outline-secondary">Reload saved state</button>
        <button id="ajax-reset" type="button" class="btn btn-outline-danger">Reset this example</button>
    </div>
    <p id="ajax-status" role="status" aria-live="polite">Loading saved state…</p>
    <p id="ajax-pending" class="text-body-secondary"></p>
    @if (Model.Scenario == "selection") { <h2 class="h5">Selected notice details</h2><pre id="ajax-details">Select a notice.</pre> }
    <details open><summary>Latest request and response</summary><pre id="ajax-wire"></pre></details>
    <details><summary>Saved server snapshot</summary><pre id="ajax-server"></pre></details>
</section>
<script type="module" src="/js/view-ajax-example.js"></script>
JavaScript: requests, acknowledgment and recovery
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';
import { TreeViewDropdown } from '/_content/CopelandSyst.BootstrapComponents/lib/Bootstrap/dist/treeview/bs-treeview-dropdown.js';
import { ListViewDropdown } from '/_content/CopelandSyst.BootstrapComponents/lib/Bootstrap/dist/listview/bs-listview-dropdown.js';

const example = document.getElementById('ajax-example');
const { kind } = example.dataset;
const isDropdown = example.dataset.scenario.startsWith('dropdown-');
const scenario = example.dataset.scenario.replace(/^dropdown-/, '');
const View = isDropdown ? (kind === 'treeview' ? TreeViewDropdown : ListViewDropdown) : (kind === 'treeview' ? TreeView : ListView);
const savesChanges = ['checking', 'reordering', 'renaming'].includes(scenario) || isDropdown && scenario === 'selection';
const endpoint = `/Bcl/ViewExamples/${kind}/${example.dataset.scenario}`;
const token = example.querySelector('[name="__RequestVerificationToken"]').value;
const status = document.getElementById('ajax-status');
const pending = document.getElementById('ajax-pending');
const wire = document.getElementById('ajax-wire');
const server = document.getElementById('ajax-server');
const details = document.getElementById('ajax-details');
const initialElement = document.getElementById('ajax-demo').cloneNode(true);
const controllers = new Set();
let view, seed, ready = false, saving = false, queued = false, paused = false;
let generation = 0, selectionSequence = 0, detailController;
let pendingSubmission;

function report(state, message) {
    example.dataset.state = state;
    status.textContent = message;
    pending.textContent = !savesChanges ? 'This example retrieves data; it does not save local edits.'
        : ready && view.getChangeSet().changes.hasChanges ? 'Local changes are not yet acknowledged.' : 'No unacknowledged changes.';
}

async function request(action, { method = 'GET', body, simulate = true, controller = new AbortController() } = {}) {
    const epoch = generation;
    const url = new URL(`${endpoint}/${action}`, location.origin);
    if (simulate) {
        url.searchParams.set('delay', document.getElementById('ajax-delay').value);
        const fail = document.getElementById('ajax-fail');
        if (fail.checked) { url.searchParams.set('fail', 'true'); fail.checked = false; }
    }
    const sent = { method, url: url.pathname + url.search, body: body ?? null };
    wire.textContent = JSON.stringify({ request: sent, response: 'Pending…' }, null, 2);
    controllers.add(controller);
    try {
        const response = await fetch(url, {
            method, credentials: 'same-origin', signal: controller.signal,
            headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': token },
            body: body === undefined ? undefined : JSON.stringify(body)
        });
        const payload = await response.json();
        if (epoch !== generation) throw new DOMException('Obsolete request', 'AbortError');
        wire.textContent = JSON.stringify({ request: sent, status: response.status, response: payload }, null, 2);
        if (!response.ok) throw Object.assign(new Error(payload.message || `Request failed (${response.status}).`), { status: response.status });
        return payload;
    } finally { controllers.delete(controller); }
}

function dataFromSnapshot(snapshot) {
    const records = new Map(snapshot.items.map(item => [item.id, { ...item.data, id: item.id, ...(kind === 'treeview' ? { children: [] } : {}) }]));
    const roots = [];
    for (const item of [...snapshot.items].sort((a, b) => a.index - b.index)) {
        if (kind === 'treeview' && item.parentId) records.get(item.parentId).children.push(records.get(item.id));
        else roots.push(records.get(item.id));
    }
    return roots;
}

async function restore(reset = false) {
    const epoch = ++generation;
    ++selectionSequence;
    for (const controller of controllers) controller.abort();
    ready = false; saving = false; queued = false; paused = false;
    pendingSubmission = undefined;
    if (isDropdown) { view.setDisabled(true); }
    document.getElementById('ajax-demo').inert = true;
    // Disposing the old instance invalidates deferred lazy-load actions before replacement.
    if (scenario === 'lazy-loading') {
        view.dispose();
        document.getElementById('ajax-demo').replaceWith(initialElement.cloneNode(true));
        document.getElementById('ajax-demo').inert = true;
        view = new View(document.getElementById('ajax-demo'));
    }
    report('loading', reset ? 'Resetting this example…' : 'Loading saved state…');
    try {
        const result = await request(reset ? 'reset' : 'state', { method: reset ? 'POST' : 'GET', simulate: false });
        if (epoch !== generation) return;
        view.replaceData(result.snapshot ? dataFromSnapshot(result.snapshot) : structuredClone(seed));
        if (result.snapshot) view.setState(result.snapshot.state);
        else view.setState({ selection: { ids: [], primaryId: null }, checkedIds: [], focusedId: null });
        view.acceptCurrentState(result.serverRevision);
        document.getElementById('ajax-demo').inert = false; ready = true;
        if (isDropdown) view.setDisabled(false);
        server.textContent = JSON.stringify(result, null, 2);
        if (details) details.textContent = 'Select a notice.';
        report('ready', reset ? 'Example reset.' : 'Ready. Changes are isolated to this example.');
    } catch (error) {
        if (epoch === generation && error.name !== 'AbortError') report('error', error.message);
    }
}

async function save() {
    if (!ready || paused || !savesChanges) return;
    if (saving) { queued = true; return; }
    if (!pendingSubmission && !view.getChangeSet().changes.hasChanges) return;
    const epoch = generation;
    // An uncertain response must retry the same identity, even if later edits exist.
    const submission = pendingSubmission ??= view.createSubmission();
    saving = true; queued = false;
    report('saving', 'Saving this snapshot. Further edits remain available.');
    try {
        const result = await request('save', { method: 'POST', body: submission });
        if (epoch !== generation) return;
        view.acknowledgeChanges(result.acknowledgment);
        pendingSubmission = undefined;
        server.textContent = JSON.stringify(result.snapshot, null, 2);
        report('saved', 'Server acknowledged the submitted snapshot.');
    } catch (error) {
        if (epoch !== generation || error.name === 'AbortError') return;
        // These endpoints reject validation/conflict requests before committing them.
        if ([400, 409, 413, 422].includes(error.status)) pendingSubmission = undefined;
        paused = true;
        report('error', `${error.message} Local edits are retained.`);
    } finally {
        if (epoch === generation) {
            saving = false;
            if (!paused && (queued || view.getChangeSet().changes.hasChanges)) queueMicrotask(save);
        }
    }
}

window.viewAjax = {
    save() { queueMicrotask(save); },
    async selected() {
        if (!ready || scenario !== 'selection' || isDropdown) return;
        const id = view.getSelectedId();
        const sequence = ++selectionSequence;
        const epoch = generation;
        detailController?.abort();
        if (!id) { details.textContent = 'Select a notice.'; report('ready', 'Select a notice.'); return; }
        detailController = new AbortController();
        details.textContent = `Loading details for ${id}…`;
        report('loading', `Loading details for ${id}…`);
        try {
            const result = await request(`details/${encodeURIComponent(id)}`, { controller: detailController });
            if (sequence !== selectionSequence || epoch !== generation) return;
            details.textContent = JSON.stringify(result, null, 2);
            view.acceptCurrentState(view.getSnapshot().serverRevision);
            report('ready', `Details loaded for ${id}.`);
        } catch (error) {
            if (sequence === selectionSequence && epoch === generation && error.name !== 'AbortError') {
                details.textContent = `Details unavailable for ${id}. Retry the request or select another notice.`;
                report('error', error.message);
            }
        }
    },
    async children(event) {
        if (scenario !== 'lazy-loading') return;
        report('loading', `Loading children of ${event.detail.id}…`);
        try {
            const result = await request(`children/${encodeURIComponent(event.detail.id)}`);
            report('ready', `Loaded ${result.length} children. Collapse and expand to use the loaded data.`);
            return result;
        } catch (error) {
            if (error.name !== 'AbortError') report('error', `${error.message} Expand the group again to retry.`);
            throw error;
        }
    }
};

view = View.getOrCreateInstance(document.getElementById('ajax-demo'));
seed = dataFromSnapshot(view.getSnapshot().snapshot);
document.getElementById('ajax-reset').addEventListener('click', () => restore(true));
document.getElementById('ajax-reload').addEventListener('click', () => restore());
document.getElementById('ajax-retry')?.addEventListener('click', () => { paused = false; save(); });
document.getElementById('ajax-retry-details')?.addEventListener('click', () => window.viewAjax.selected());
document.getElementById('ajax-conflict')?.addEventListener('click', async () => {
    try { const result = await request('conflict', { method: 'POST', simulate: false }); report('ready', result.message); }
    catch (error) { if (error.name !== 'AbortError') report('error', error.message); }
});
document.getElementById('ajax-move')?.addEventListener('click', () => {
    const id = view.getSelectedId();
    if (!id) { report('ready', 'Select a notice first.'); return; }
    if (kind === 'treeview') view.moveNode(id, id.startsWith('notice-') ? 'planned' : null, { index: 99 });
    else view.moveItem(id, { index: 99 });
});
restore();
Server endpoints and operation validation
csharp
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using CopelandSyst.BootstrapComponents.ViewModels;
using CopelandSyst.Docs.DocsNavigation;
using CopelandSyst.Docs.Models;
using CopelandSyst.Docs.Services;
using Microsoft.AspNetCore.Mvc;

namespace CopelandSyst.Docs.Controllers;

[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class ViewExamplesController(ViewDemoStore store) : Controller
{
    [DocsNode("bcl:components:treeview:checking"), HttpGet("/Bcl/TreeView/Ajax/Checking")]
    public IActionResult TreeChecking() => Example("treeview", "checking");
    [DocsNode("bcl:components:treeview:reordering"), HttpGet("/Bcl/TreeView/Ajax/Reordering")]
    public IActionResult TreeReordering() => Example("treeview", "reordering");
    [DocsNode("bcl:components:treeview:renaming"), HttpGet("/Bcl/TreeView/Ajax/Renaming")]
    public IActionResult TreeRenaming() => Example("treeview", "renaming");
    [DocsNode("bcl:components:treeview:selection"), HttpGet("/Bcl/TreeView/Ajax/Selection")]
    public IActionResult TreeSelection() => Example("treeview", "selection");
    [DocsNode("bcl:components:treeview:lazy-loading"), HttpGet("/Bcl/TreeView/Ajax/LazyLoading")]
    public IActionResult TreeLazyLoading() => Example("treeview", "lazy-loading");
    [DocsNode("bcl:components:listview:checking"), HttpGet("/Bcl/ListView/Ajax/Checking")]
    public IActionResult ListChecking() => Example("listview", "checking");
    [DocsNode("bcl:components:listview:reordering"), HttpGet("/Bcl/ListView/Ajax/Reordering")]
    public IActionResult ListReordering() => Example("listview", "reordering");
    [DocsNode("bcl:components:listview:renaming"), HttpGet("/Bcl/ListView/Ajax/Renaming")]
    public IActionResult ListRenaming() => Example("listview", "renaming");
    [DocsNode("bcl:components:listview:selection"), HttpGet("/Bcl/ListView/Ajax/Selection")]
    public IActionResult ListSelection() => Example("listview", "selection");

    [DocsNode("bcl:components:treeview-dropdown:checking"), HttpGet("/Bcl/TreeViewDropdown/Ajax/Checking")]
    public IActionResult TreeDropdownChecking() => Example("treeview", "dropdown-checking");
    [DocsNode("bcl:components:treeview-dropdown:selection"), HttpGet("/Bcl/TreeViewDropdown/Ajax/Selection")]
    public IActionResult TreeDropdownSelection() => Example("treeview", "dropdown-selection");
    [DocsNode("bcl:components:treeview-dropdown:reordering"), HttpGet("/Bcl/TreeViewDropdown/Ajax/Reordering")]
    public IActionResult TreeDropdownReordering() => Example("treeview", "dropdown-reordering");
    [DocsNode("bcl:components:treeview-dropdown:renaming"), HttpGet("/Bcl/TreeViewDropdown/Ajax/Renaming")]
    public IActionResult TreeDropdownRenaming() => Example("treeview", "dropdown-renaming");
    [DocsNode("bcl:components:treeview-dropdown:lazy-loading"), HttpGet("/Bcl/TreeViewDropdown/Ajax/LazyLoading")]
    public IActionResult TreeDropdownLazyLoading() => Example("treeview", "dropdown-lazy-loading");
    [DocsNode("bcl:components:listview-dropdown:checking"), HttpGet("/Bcl/ListViewDropdown/Ajax/Checking")]
    public IActionResult ListDropdownChecking() => Example("listview", "dropdown-checking");
    [DocsNode("bcl:components:listview-dropdown:selection"), HttpGet("/Bcl/ListViewDropdown/Ajax/Selection")]
    public IActionResult ListDropdownSelection() => Example("listview", "dropdown-selection");
    [DocsNode("bcl:components:listview-dropdown:reordering"), HttpGet("/Bcl/ListViewDropdown/Ajax/Reordering")]
    public IActionResult ListDropdownReordering() => Example("listview", "dropdown-reordering");
    [DocsNode("bcl:components:listview-dropdown:renaming"), HttpGet("/Bcl/ListViewDropdown/Ajax/Renaming")]
    public IActionResult ListDropdownRenaming() => Example("listview", "dropdown-renaming");

    private IActionResult Example(string kind, string scenario)
    {
        store.Get(HttpContext); // Issue the browser-isolation cookie before concurrent Ajax requests.
        return View("~/Views/Docs/ViewAjax.cshtml", new ViewAjaxExampleModel(kind, scenario));
    }

    [HttpGet("/Bcl/ViewExamples/{kind}/{scenario}/state")]
    public IActionResult State(string kind, string scenario)
    {
        if (!ViewAjaxExampleModel.IsValid(kind, scenario)) return NotFound();
        var workspace = store.Get(HttpContext);
        lock (workspace.SyncRoot)
        {
            var state = workspace.Get(kind, scenario);
            return Json(new { serverRevision = state.Revision, snapshot = state.Snapshot });
        }
    }

    [HttpPost("/Bcl/ViewExamples/{kind}/{scenario}/save"), ValidateAntiForgeryToken, RequestSizeLimit(131072)]
    public async Task<IActionResult> Save(string kind, string scenario, [FromBody] JsonElement body, int delay = 0, bool fail = false)
    {
        if (!ViewAjaxExampleModel.IsValid(kind, scenario) || !new ViewAjaxExampleModel(kind, scenario).SavesChanges) return NotFound();
        if (await SimulateAsync(delay, fail) is { } failure) return failure;
        if (!ModelState.IsValid || body.ValueKind != JsonValueKind.Object) return BadRequest(new { message = "A JSON submission object is required." });
        BootstrapViewSubmissionModel submission;
        try { submission = BootstrapViewSubmissionModel.Parse(body.GetRawText()); }
        catch (Exception error) when (error is JsonException or ValidationException)
        { return BadRequest(new { message = error.Message }); }
        if (submission.Kind != kind || submission.ControlId != "ajax-demo") return BadRequest(new { message = "Wrong control identity." });
        if (ValidateDomain(submission, scenario) is { } invalid) return UnprocessableEntity(new { message = invalid });
        var workspace = store.Get(HttpContext);
        lock (workspace.SyncRoot)
        {
            var state = workspace.Get(kind, scenario);
            var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body.GetRawText())));
            var receiptKey = submission.InstanceId + ":" + submission.SubmissionId;
            if (state.Receipts.TryGetValue(receiptKey, out var receipt))
                return receipt.Hash == hash ? Json(new { acknowledgment = receipt.Ack, snapshot = receipt.Snapshot })
                    : Conflict(new { message = "A submission identity was reused with different data." });
            if (submission.ServerRevision != state.Revision)
                return Conflict(new { message = "The saved revision changed. Reload saved state before applying new edits.", serverRevision = state.Revision });
            state.Snapshot = submission.Snapshot;
            state.Revision = Guid.NewGuid().ToString("N");
            var acknowledgment = new BootstrapViewAcknowledgmentModel
            {
                InstanceId = submission.InstanceId, SubmissionId = submission.SubmissionId,
                Accepted = true, ServerRevision = state.Revision,
            };
            if (state.Receipts.Count >= 32) state.Receipts.Remove(state.Receipts.Keys.First());
            state.Receipts[receiptKey] = (hash, acknowledgment, submission.Snapshot);
            return Json(new { acknowledgment, snapshot = state.Snapshot });
        }
    }

    [HttpPost("/Bcl/ViewExamples/{kind}/{scenario}/reset"), ValidateAntiForgeryToken]
    public IActionResult Reset(string kind, string scenario)
    {
        if (!ViewAjaxExampleModel.IsValid(kind, scenario)) return NotFound();
        var workspace = store.Get(HttpContext);
        lock (workspace.SyncRoot)
        {
            var state = workspace.Get(kind, scenario);
            state.Reset();
            return Json(new { serverRevision = state.Revision, snapshot = state.Snapshot });
        }
    }

    [HttpPost("/Bcl/ViewExamples/{kind}/{scenario}/conflict"), ValidateAntiForgeryToken]
    public IActionResult ConflictExample(string kind, string scenario)
    {
        if (!ViewAjaxExampleModel.IsValid(kind, scenario) || !new ViewAjaxExampleModel(kind, scenario).SavesChanges) return NotFound();
        var workspace = store.Get(HttpContext);
        lock (workspace.SyncRoot)
        {
            var state = workspace.Get(kind, scenario);
            state.Revision = Guid.NewGuid().ToString("N");
            return Json(new { message = "Another editor advanced the server revision. The next local save will conflict." });
        }
    }

    [HttpGet("/Bcl/ViewExamples/{kind}/selection/details/{id}")]
    public async Task<IActionResult> Details(string kind, string id, int delay = 0, bool fail = false)
    {
        if (!ViewAjaxExampleModel.IsValid(kind, "selection")) return NotFound();
        if (await SimulateAsync(delay, fail) is { } failure) return failure;
        var row = ViewAjaxExampleModel.Rows(kind).SingleOrDefault(row => row.Id == id);
        return row is null ? NotFound() : Json(new { id = row.Id, title = row.Label, reference = row.Value,
            owner = "Documentation", summary = "Synthetic notice details retrieved from the server.", loadedAt = DateTimeOffset.UtcNow });
    }

    [HttpGet("/Bcl/ViewExamples/treeview/lazy-loading/children/{id}")]
    [HttpGet("/Bcl/ViewExamples/treeview/dropdown-lazy-loading/children/{id}")]
    public async Task<IActionResult> Children(string id, int delay = 0, bool fail = false)
    {
        if (await SimulateAsync(delay, fail) is { } failure) return failure;
        if (id is not ("operations" or "planned")) return NotFound();
        return Json(ViewAjaxExampleModel.Rows("treeview").Where(row => row.ParentId == id).Select(ViewAjaxExampleModel.Payload));
    }

    private async Task<IActionResult?> SimulateAsync(int delay, bool fail)
    {
        if (delay is < 0 or > 2500) return BadRequest(new { message = "Delay must be between 0 and 2500 milliseconds." });
        if (delay > 0) await Task.Delay(delay, HttpContext.RequestAborted);
        return fail ? StatusCode(503, new { message = "Simulated service failure. The request was not saved; retry when ready." }) : null;
    }

    private static string? ValidateDomain(BootstrapViewSubmissionModel submission, string scenario)
    {
        if (scenario.StartsWith("dropdown-", StringComparison.Ordinal)) scenario = scenario[9..];
        var expected = ViewAjaxExampleModel.Rows(submission.Kind).ToDictionary(row => row.Id);
        if (submission.Snapshot.Items.Count != expected.Count || submission.Snapshot.Items.Any(item => !expected.ContainsKey(item.Id)))
            return "This example permits only its original notice identities.";
        foreach (var item in submission.Snapshot.Items)
        {
            var seed = expected[item.Id];
            if (!item.Data.TryGetValue("data", out var data) || data.ValueKind != JsonValueKind.Object
                || !data.TryGetProperty("code", out var code) || code.ValueKind != JsonValueKind.String || code.GetString() != item.Id
                || !data.TryGetProperty("owner", out var owner) || owner.ValueKind != JsonValueKind.String || owner.GetString() != "Documentation")
                return "Application notice data cannot be changed by this operation.";
            if (!item.Data.TryGetValue("boundValue", out var value) || value.ValueKind != JsonValueKind.String || value.GetString() != seed.Value)
                return "Notice reference values cannot be changed.";
            if (!item.Data.TryGetValue("label", out var label) || label.ValueKind != JsonValueKind.String
                || label.GetString()!.Trim().Length is < 3 or > 80) return "Labels must contain between 3 and 80 characters.";
            if (scenario != "renaming" && label.GetString() != seed.Label) return "This example does not permit renaming.";
            if (scenario != "reordering" && (item.ParentId != seed.ParentId || item.Index != seed.Index)) return "This example does not permit reordering.";
            if (item.ParentId is not null && item.ParentId is not ("operations" or "planned")) return "Only notice groups can receive children.";
        }
        if (scenario != "checking" && (submission.Snapshot.State.CheckedIds.Count > 0 || submission.Snapshot.State.IndeterminateIds.Count > 0))
            return "This example does not permit checking.";
        return null;
    }
}
Models and synthetic notice data
csharp
using CopelandSyst.BootstrapComponents.ViewModels;

namespace CopelandSyst.Docs.Models;

public sealed record ViewDemoRow(string Id, string Label, string? ParentId, int Index, string Value);

public sealed class ViewAjaxExampleModel(string kind, string scenario)
{
    public string Kind { get; } = kind;
    public string Scenario { get; } = scenario;
    public bool IsDropdown => Scenario.StartsWith("dropdown-", StringComparison.Ordinal);
    public string Operation => IsDropdown ? Scenario[9..] : Scenario;
    public string ControlName => (Kind == "treeview" ? "TreeView" : "ListView") + (IsDropdown ? "Dropdown" : "");
    public string Title => Operation switch
    {
        "checking" => "Ajax (Checking)", "reordering" => "Ajax (Drag and drop)",
        "renaming" => "Ajax (Renaming)", "selection" => "Ajax (Selection)",
        "lazy-loading" => "Ajax (Lazy loading)", _ => throw new ArgumentOutOfRangeException(nameof(Scenario)),
    };
    public bool SavesChanges => Operation is "checking" or "reordering" or "renaming" || IsDropdown && Operation == "selection";
    public string Instructions => IsDropdown
        ? Operation == "lazy-loading" ? "Open the picker and expand a group to load its options. Failed loads can be retried; closing the picker preserves loaded data. Reset invalidates pending work."
        : "Open the picker to edit notices. Apply commits the chosen value and queues a background save; Cancel restores the previous value. Reordering and renaming remain independent edits. Delay, failure, retry and revision conflicts exercise the same typed acknowledgment contract as the standalone controls."
        : Operation switch
    {
        "checking" => "Check notices to save their checked state in the background. During a slow save, check another notice: the later edit remains pending until its own acknowledgment.",
        "reordering" => "Drag notices to reorder them. In TreeView, drop a notice inside another group to change its parent. The Move selected button provides the same operation without a pointer. The request records previous and new positions.",
        "renaming" => "Select a notice and press F2, or double-click its label. Commit with Enter or cancel with Escape. The server requires labels between 3 and 80 characters; rejected edits remain local until corrected or discarded.",
        "selection" => "Select notices to fetch their details. With a delay enabled, select another notice before the first request returns: only the latest selection may update the details panel.",
        _ => "Expand a group to request its children. Enable Fail next request to exercise the error/retry path. Reset restores the unloaded groups and invalidates pending responses.",
    };

    public static bool IsValid(string kind, string scenario)
    {
        var operation = scenario.StartsWith("dropdown-", StringComparison.Ordinal) ? scenario[9..] : scenario;
        return kind is "treeview" or "listview"
            && (operation is "checking" or "reordering" or "renaming" or "selection" || kind == "treeview" && operation == "lazy-loading");
    }

    public static IReadOnlyList<ViewDemoRow> Rows(string kind) => kind == "treeview"
        ? [new("operations", "Operations", null, 0, "operations"),
           new("notice-1", "Service maintenance", "operations", 0, "NOTICE-001"),
           new("notice-2", "Access review", "operations", 1, "NOTICE-002"),
           new("planned", "Planned work", null, 1, "planned"),
           new("notice-3", "Release briefing", "planned", 0, "NOTICE-003"),
           new("notice-4", "Policy update", "planned", 1, "NOTICE-004")]
        : [new("notice-1", "Service maintenance", null, 0, "NOTICE-001"),
           new("notice-2", "Access review", null, 1, "NOTICE-002"),
           new("notice-3", "Release briefing", null, 2, "NOTICE-003"),
           new("notice-4", "Policy update", null, 3, "NOTICE-004")];

    public static object Payload(ViewDemoRow row) => new
    {
        id = row.Id, label = row.Label, boundValue = row.Value,
        subtitle = row.Id.StartsWith("notice-", StringComparison.Ordinal) ? "Example notice" : null,
        data = new { code = row.Id, owner = "Documentation" },
    };

    public BootstrapTreeViewModel Tree
    {
        get
        {
            var root = new BootstrapTreeViewModel();
            var rows = Rows("treeview");
            foreach (var group in rows.Where(row => row.ParentId is null))
            {
                var node = ToTreeNode(group);
                node.Expanded = Operation != "lazy-loading";
                if (Operation == "lazy-loading") { node.Lazy = true; node.HasChildren = true; }
                else foreach (var child in rows.Where(row => row.ParentId == group.Id)) node.ChildrenCapture.Add(ToTreeNode(child));
                root.NodesCapture.Add(node);
            }
            return root;
        }
    }

    public BootstrapListViewModel List
    {
        get
        {
            var root = new BootstrapListViewModel();
            foreach (var row in Rows("listview")) root.ItemsCapture.Add(new BootstrapListViewItemModel
            {
                ItemId = row.Id, Label = row.Label, Subtitle = "Example notice", BoundValue = row.Value,
                Data = new { code = row.Id, owner = "Documentation" },
            });
            return root;
        }
    }

    private static BootstrapTreeViewNodeModel ToTreeNode(ViewDemoRow row) => new()
    {
        NodeId = row.Id, Label = row.Label, BoundValue = row.Value,
        Subtitle = row.ParentId is null ? null : "Example notice", Data = new { code = row.Id, owner = "Documentation" },
    };

    public BootstrapTreeViewDropdownModel DropdownTree
    {
        get
        {
            var model = new BootstrapTreeViewDropdownModel();
            foreach (var node in Tree.NodesCapture) model.NodesCapture.Add(node);
            return model;
        }
    }

    public BootstrapListViewDropdownModel DropdownList
    {
        get
        {
            var model = new BootstrapListViewDropdownModel();
            foreach (var item in List.ItemsCapture) model.ItemsCapture.Add(item);
            return model;
        }
    }
}
Isolated in-memory state and revision receipts
csharp
using CopelandSyst.BootstrapComponents.ViewModels;
using Microsoft.Extensions.Caching.Memory;

namespace CopelandSyst.Docs.Services;

// Synthetic data only. Cookie isolation and revision checks also apply across tabs.
public sealed class ViewDemoStore : IDisposable
{
    private const string CookieName = "CopelandDocs.ViewExamples";
    private readonly MemoryCache cache = new(new MemoryCacheOptions { SizeLimit = 128 });
    private readonly object gate = new();

    public Workspace Get(HttpContext context)
    {
        lock (gate)
        {
            var token = context.Request.Cookies[CookieName];
            if (Guid.TryParseExact(token, "N", out _) && cache.TryGetValue(token!, out Workspace? existing) && existing is not null)
                return existing;
            token = Guid.NewGuid().ToString("N");
            var workspace = new Workspace();
            cache.Set(token, workspace, new MemoryCacheEntryOptions { Size = 1, SlidingExpiration = TimeSpan.FromMinutes(30) });
            context.Response.Cookies.Append(CookieName, token, new CookieOptions
            {
                HttpOnly = true, SameSite = SameSiteMode.Strict, Secure = context.Request.IsHttps,
                IsEssential = true, Path = "/Bcl",
            });
            return workspace;
        }
    }

    public void Dispose() => cache.Dispose();

    public sealed class Workspace
    {
        public object SyncRoot { get; } = new();
        private readonly Dictionary<string, ScenarioState> scenarios = [];
        // Call under SyncRoot. Only allowlisted standalone and dropdown scenarios reach this method.
        public ScenarioState Get(string kind, string scenario)
        {
            var key = kind + ":" + scenario;
            if (!scenarios.TryGetValue(key, out var value)) scenarios[key] = value = new();
            return value;
        }
    }

    public sealed class ScenarioState
    {
        public string Revision { get; set; } = Guid.NewGuid().ToString("N");
        public BootstrapViewSnapshotModel? Snapshot { get; set; }
        public Dictionary<string, (string Hash, BootstrapViewAcknowledgmentModel Ack, BootstrapViewSnapshotModel Snapshot)> Receipts { get; } = [];
        public void Reset()
        {
            Snapshot = null;
            Revision = Guid.NewGuid().ToString("N");
            Receipts.Clear();
        }
    }
}

Register ViewDemoStore as a singleton and enable MVC antiforgery. The Razor form emits the token; fetch sends it in the RequestVerificationToken header. Persistence and application validation belong to these handlers, while the control supplies the change and acknowledgment contracts.