Server-side grid with Northwind OData

by stitot

HTML

<script src="https://cdn.jsdelivr.net/npm/@highcharts/grid-pro/grid-pro.js"></script>

<div class="demo-shell">
    <div class="demo-intro">
        <h1>Grid Pro with one-request server-side products</h1>
        <p>
            This demo uses the public Northwind OData API. Sorting, filtering,
            and pagination all happen on the server, and each interaction is
            handled with one request. Nice and tidy, like a query string that
            actually knows where it is going.
        </p>
    </div>

    <div id="notice" class="notice" aria-live="polite">
        Loading products from Northwind...
    </div>

    <div class="demo-actions">
        <button id="apply-multisort" type="button">
            Apply visible multi-sort
        </button>
        <p>
            This applies <strong>Category</strong> first and
            <strong>Price</strong> second, which makes the secondary sort easy
            to spot because category values repeat a lot.
        </p>
    </div>

    <div id="container"></div>

    <p class="highcharts-description">
        Click one header to set the primary sort. Hold Shift while clicking
        more headers to add secondary sorts. Use the filter icons in the
        headers to send matching OData filters to the server. For a clearly
        visible secondary sort, try <strong>Category</strong> and then
        <strong>Price</strong>.
    </p>
</div>

CSS

@import url("https://cdn.jsdelivr.net/npm/@highcharts/grid-pro/css/grid-pro.css");

body {
    font-family:
        -apple-system,
        BlinkMacSystemFont,
        "Segoe UI",
        Roboto,
        Helvetica,
        Arial,
        sans-serif;
    background:
        linear-gradient(180deg, #f7fafc 0%, #edf7f4 100%);
    color: #1f2937;
    margin: 0;
}

.demo-shell {
    max-width: 1120px;
    margin: 0 auto;
    padding: 20px 16px 28px;
}

.demo-intro {
    margin-bottom: 14px;
}

.demo-intro h1 {
    margin: 0 0 8px;
    font-size: 1.8rem;
    line-height: 1.1;
}

.demo-intro p,
.highcharts-description {
    margin: 0;
    max-width: 78ch;
    line-height: 1.55;
}

.notice {
    margin: 0 0 12px;
    padding: 10px 12px;
    border: 1px solid #cfe7df;
    border-radius: 10px;
    background: #f1fbf8;
    color: #1f5e52;
}

.notice[data-level="error"] {
    border-color: #f0b4b4;
    background: #fff1f1;
    color: #8b1e1e;
}

.demo-actions {
    display: flex;
    gap: 12px;
    align-items: center;
    margin: 0 0 12px;
    padding: 10px 12px;
    border-radius: 10px;
    background: rgba(255, 255, 255, 0.72);
}

.demo-actions p {
    margin: 0;
    line-height: 1.45;
}

.demo-actions button {
    border: 0;
    border-radius: 999px;
    padding: 10px 14px;
    background: #1f5e52;
    color: #fff;
    font: inherit;
    font-weight: 600;
    cursor: pointer;
    white-space: nowrap;
}

.demo-actions button:hover,
.demo-actions button:focus-visible {
    background: #184a40;
}

#container {
    min-height: 640px;
    max-height: 720px;
}

.highcharts-description {
    padding-top: 12px;
}

@media (max-width: 700px) {
    .demo-actions {
        flex-direction: column;
        align-items: flex-start;
    }
}

JavaScript

const API_BASE = 'https://services.odata.org/V4/Northwind/Northwind.svc/Products';
const COLUMN_IDS = [
    'ProductID',
    'ProductName',
    'CategoryID',
    'SupplierID',
    'UnitPrice',
    'UnitsInStock',
    'Discontinued'
];
const COLUMN_TYPES = {
    ProductID: 'number',
    ProductName: 'string',
    CategoryID: 'number',
    SupplierID: 'number',
    UnitPrice: 'number',
    UnitsInStock: 'number',
    Discontinued: 'boolean'
};
const currencyFormatter = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
});
const applyMultiSortEl = document.getElementById('apply-multisort');
const noticeEl = document.getElementById('notice');
let lastSortSummary = 'none';

function setNotice(message, level) {
    noticeEl.textContent = message;
    noticeEl.dataset.level = level || 'info';
}

function getActiveSortings(query) {
    const sortings = query.sorting.currentSortings ||
        (query.sorting.currentSorting ? [query.sorting.currentSorting] : []);

    return sortings.filter((sorting) => sorting?.columnId && sorting.order);
}

function getFilterRootCondition(state) {
    return state.query.filtering.modifier?.options?.condition;
}

function escapeODataString(value) {
    return String(value).replace(/'/g, "''");
}

function normalizeNumericValue(value) {
    const normalized = typeof value === 'number' ? value : Number(value);

    return Number.isFinite(normalized) ? String(normalized) : '';
}

function buildOrderBy(state) {
    const activeSortings = getActiveSortings(state.query);
    const orderBy = activeSortings
        .filter((sorting) => COLUMN_TYPES[sorting.columnId])
        .map((sorting) => `${sorting.columnId} ${sorting.order}`)
        .join(',');

    lastSortSummary = activeSortings.length ?
        activeSortings
            .map((sorting) => `${sorting.columnId} ${sorting.order}`)
            .join(' -> ') :
        'none';

    return orderBy ?...