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 ?...