feat(dashboard): UGC List view and References as sortable DataTables

The UGC page's List view (models and car/rocket assemblies) and an
assembly's References panel are server-side DataTables like the rest of
the dashboard: sort by clicking a column heading, in either direction,
with the page length and sort remembered per user. They still read
/api/ugc and /api/ugc/assembly/builds, so the prefix search and filters
above the list keep working; the gallery keeps its own pager and sort.

/api/ugc takes reverse=1 for a sort's other direction (the model list's
SQL order and the assembly sort both flip), and the references endpoint
sorts by id, owner, account or state. Tests cover the reversed orders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 17:37:00 -05:00
parent 5bb05775ee
commit 453f728b5e
7 changed files with 191 additions and 81 deletions

View File

@@ -139,7 +139,8 @@ namespace UgcAssemblies {
return assembly.key;
}
inline void Sort(std::vector<Assembly>& list, eSort sort, const std::map<uint32_t, ModuleInfo>& modules) {
// reverse: the sort's other direction (the order is flipped as a whole)
inline void Sort(std::vector<Assembly>& list, eSort sort, const std::map<uint32_t, ModuleInfo>& modules, bool reverse = false) {
std::stable_sort(list.begin(), list.end(), [&](const Assembly& a, const Assembly& b) {
switch (sort) {
case eSort::OLDEST: return a.builds.back() < b.builds.back();
@@ -151,5 +152,6 @@ namespace UgcAssemblies {
default: return a.builds.front() > b.builds.front();
}
});
if (reverse) std::reverse(list.begin(), list.end());
}
}

View File

@@ -241,7 +241,7 @@ namespace UgcRoutes {
"A page of player models (kind=model) or of car and rocket assemblies (kind=modular: one per combination of modules, however many builds use "
"it). Query: q= (\"state:\", \"kind:\"/\"type:\" (a build type, e.g. build6), \"owner:\", \"account:\", \"property:\", \"name:\", "
"\"lot:\"/\"module:\" (a LOT or a module's name), \"id:\", or plain text across names, owners and ids), state=, type=, sort=newest|oldest|owner|name|"
"bricks|triangles (models) or newest|oldest|references|name (assemblies), page= (from 0), size= (1-200). {items, total, page, size, counts, "
"bricks|triangles (models) or newest|oldest|references|name (assemblies), reverse=1 (the sort's other direction), page= (from 0), size= (1-200). {items, total, page, size, counts, "
"kinds, ugcPublicUrl, canManage}",
[](HTTPReply& reply, const HTTPContext& context) {
const bool modular = QueryValue(context.queryString, "kind") == "modular";
@@ -251,6 +251,7 @@ namespace UgcRoutes {
const auto page = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
const auto size = std::clamp(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "size")).value_or(PAGE_SIZE), 1u, 200u);
const auto sortText = QueryValue(context.queryString, "sort");
const bool reverse = QueryValue(context.queryString, "reverse") == "1";
nlohmann::json items = nlohmann::json::array();
uint64_t total = 0;
const auto kinds = IconKinds();
@@ -261,6 +262,7 @@ namespace UgcRoutes {
static const std::map<std::string, IUgcLookup::eSort> SORTS = { { "newest", IUgcLookup::eSort::NEWEST }, { "oldest", IUgcLookup::eSort::OLDEST },
{ "owner", IUgcLookup::eSort::OWNER }, { "name", IUgcLookup::eSort::NAME }, { "bricks", IUgcLookup::eSort::BRICKS }, { "triangles", IUgcLookup::eSort::TRIANGLES } };
if (const auto it = SORTS.find(sortText); it != SORTS.end()) query.sort = it->second;
query.reverse = reverse;
query.offset = page * size;
query.limit = size;
const auto [entries, count] = Database::Get()->ListUgc(IUgcLookup::eUgcKind::MODEL, query);
@@ -294,7 +296,7 @@ namespace UgcRoutes {
}
auto assemblies = UgcAssemblies::Group(AllBuilds(), modules);
std::erase_if(assemblies, [&](const auto& a) { return !UgcAssemblies::Matches(a, filter, modules); });
UgcAssemblies::Sort(assemblies, UgcAssemblies::ParseSort(sortText).value_or(UgcAssemblies::eSort::NEWEST), modules);
UgcAssemblies::Sort(assemblies, UgcAssemblies::ParseSort(sortText).value_or(UgcAssemblies::eSort::NEWEST), modules, reverse);
total = assemblies.size();
for (size_t i = static_cast<size_t>(page) * size; i < assemblies.size() && i < static_cast<size_t>(page + 1) * size; i++) {
const auto& a = assemblies[i];
@@ -321,7 +323,8 @@ namespace UgcRoutes {
Route(eHTTPMethod::GET, "/api/ugc/assembly/builds", Perm("properties_view"),
"The builds (ugc_modular_build rows) that use a combination of modules: {items: [{id, characterId, characterName, accountId, accountName, state, "
"attempts, processedAt, error, where: [{type: property|mail|inventory, ...}]}], total, page, size}. Query: modules= (the combination), q= (owner, "
"account, property or id, as the list's search), page=, size=, build= (a build to show: the page holding it is given)",
"account, property or id, as the list's search), sort=id|owner|account|state (default id, the newest first; owner and account A to Z, state "
"by state then newest), reverse=1 (the other direction), page=, size=, build= (a build to show: the page holding it is given)",
[](HTTPReply& reply, const HTTPContext& context) {
auto asked = QueryValue(context.queryString, "modules");
std::replace(asked.begin(), asked.end(), '-', '+');
@@ -330,7 +333,15 @@ namespace UgcRoutes {
const auto parsed = ParseListSearch(QueryValue(context.queryString, "q").substr(0, 100));
auto builds = AllBuilds(parsed.search);
std::erase_if(builds, [&](const auto& b) { return UgcModularKey::Normalize(b.detail) != key || (parsed.state && b.state != *parsed.state); });
std::sort(builds.begin(), builds.end(), [](const auto& a, const auto& b) { return a.id > b.id; });
const auto sort = QueryValue(context.queryString, "sort");
const auto lowerName = [](const std::string& name) { return UgcAssemblies::Lower(name); };
std::stable_sort(builds.begin(), builds.end(), [&](const auto& a, const auto& b) {
if (sort == "owner" && lowerName(a.characterName) != lowerName(b.characterName)) return lowerName(a.characterName) < lowerName(b.characterName);
if (sort == "account" && lowerName(a.accountName) != lowerName(b.accountName)) return lowerName(a.accountName) < lowerName(b.accountName);
if (sort == "state" && a.state != b.state) return a.state < b.state;
return a.id > b.id;
});
if (QueryValue(context.queryString, "reverse") == "1") std::reverse(builds.begin(), builds.end());
const auto size = std::clamp(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "size")).value_or(25), 1u, 200u);
auto page = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
if (const auto wanted = GeneralUtils::TryParse<LWOOBJID>(QueryValue(context.queryString, "build"))) {

View File

@@ -67,6 +67,7 @@
$('typeFilter').value = list.type;
$('sortSelect').innerHTML = SORTS[list.kind].map(function (o) { return '<option value="' + o[0] + '">' + esc(o[1]) + '</option>'; }).join('');
$('sortSelect').value = SORTS[list.kind].some(function (o) { return o[0] === list.sort; }) ? list.sort : 'newest';
$('sortSelect').classList.toggle('d-none', list.view !== 'gallery'); // the List view sorts by its column headings
$('pageSize').value = String(pageSize());
}
@@ -98,23 +99,47 @@
return '<div class="card ugc-tile p-2" tabindex="0" role="button" data-preview="' + esc(i.id) + '" title="' + esc(i.error || sub) + '">' + iconImg(i, 128) +
'<div class="small text-truncate mt-1">' + title + '</div><div class="small text-body-secondary text-truncate">' + sub + '</div><div>' + badge(i.state) + extra + '</div></div>';
}
function listHead() {
return list.kind === 'modular' ? '<tr><th>Icon</th><th>Type</th><th>Modules</th><th>State</th><th>Builds</th><th>Owners</th><th></th></tr>'
: '<tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Made</th><th>Size</th><th>Details</th><th></th></tr>';
function actions(i, remakeId) {
return '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>' +
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(remakeId) + '">Make again</button>' : '');
}
function row(i) {
var view = '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>';
var error = i.error ? '<div class="small text-danger">' + esc(i.error) + '</div>' : '';
if (list.kind === 'modular') {
return '<tr><td>' + iconImg(i, 48) + '</td><td>' + esc(i.kindLabel || i.buildType) + '</td><td class="small">' + esc(moduleNames(i)) + '<div><code>' + esc(i.key) + '</code></div>' + error +
'</td><td>' + badge(i.state) + '</td><td>' + esc(i.uses) + '</td><td>' + esc(i.owners) + '</td><td class="text-end text-nowrap">' + view +
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(i.iconBuild) + '">Make again</button>' : '') + '</td></tr>';
}
return '<tr><td>' + iconImg(i, 48) + '</td><td class="small">' + esc(i.id) + '</td><td>' + owner(i) + '</td><td>' + badge(i.state) +
(i.attempts ? ' <span class="small text-body-secondary">' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '</span>' : '') + waitBadge(i) + '</td>' +
'<td class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</td><td class="small">' + (i.bricks ? esc(i.bricks) + ' bricks<br>' + esc(i.triangles.toLocaleString()) + ' triangles' : '') +
'</td><td class="small">' + esc(i.detail || '') + error + '</td><td class="text-end text-nowrap">' + view +
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(i.id) + '">Make again</button>' : '') + '</td></tr>';
function errorText(i) { return i.error ? '<div class="small text-danger">' + esc(i.error) + '</div>' : ''; }
// The List view's columns. A column with a sort key sorts on the server by that key; desc marks the key's own
// direction (e.g. "bricks" is the most first), so clicking the other way asks for the reverse.
function column(title, render, sort, desc) {
return { data: null, title: title, orderable: !!sort, sortKey: sort, desc: !!desc, orderSequence: desc ? ['desc', 'asc'] : ['asc', 'desc'],
render: function (x, type, i) { return render(i); } };
}
var COLUMNS = {
model: [
column('Icon', function (i) { return iconImg(i, 48); }),
column('ID', function (i) { return '<span class="small">' + esc(i.id) + '</span>'; }, 'newest', true),
column('Owner', owner, 'owner'),
column('State', function (i) {
return badge(i.state) + (i.attempts ? ' <span class="small text-body-secondary">' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '</span>' : '') + waitBadge(i);
}),
column('Made', function (i) { return '<span class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</span>'; }),
column('Size', function (i) { return '<span class="small">' + (i.bricks ? esc(i.bricks) + ' bricks<br>' + esc(i.triangles.toLocaleString()) + ' triangles' : '') + '</span>'; }, 'bricks', true),
column('File', function (i) { return '<span class="small">' + esc(i.detail || '') + '</span>' + errorText(i); }, 'name'),
column('', function (i) { return '<div class="text-end text-nowrap">' + actions(i, i.id) + '</div>'; })
],
modular: [
column('Icon', function (i) { return iconImg(i, 48); }),
column('Newest build', function (i) { return '<span class="small">' + esc(i.newestBuild) + '</span>'; }, 'newest', true),
column('Type', function (i) { return esc(i.kindLabel || i.buildType); }),
column('Modules', function (i) { return '<span class="small">' + esc(moduleNames(i)) + '</span><div><code>' + esc(i.key) + '</code></div>' + errorText(i); }, 'name'),
column('State', function (i) { return badge(i.state); }),
column('Builds', function (i) { return esc(i.uses); }, 'references', true),
column('Owners', function (i) { return esc(i.owners); }),
column('', function (i) { return '<div class="text-end text-nowrap">' + actions(i, i.iconBuild) + '</div>'; })
]
};
// The sort a DataTables order asks for, as /api/ugc's sort= and reverse=
function orderQuery(columns, order) {
var o = order && order[0], c = o && columns[o.column];
if (!c || !c.sortKey) return 'sort=newest';
return 'sort=' + c.sortKey + ((o.dir === 'desc') !== c.desc ? '&reverse=1' : '');
}
// Numbered pages with first and last, around the current one
@@ -140,35 +165,74 @@
};
}
// What every /api/ugc answer carries besides the items
function applyMeta(d) {
canManage = d.canManage;
publicUrl = (d.ugcPublicUrl || '').replace(/\/+$/, '');
if (d.kinds && !kinds.length) {
kinds = d.kinds;
$('typeFilter').innerHTML = '<option value="">Every type</option>' + kinds.filter(function (k) { return k.buildType !== undefined; })
.map(function (k) { return '<option value="' + esc(k.kind) + '">' + esc(k.label) + '</option>'; }).join('');
$('typeFilter').value = list.type;
}
$('manageButtons').classList.toggle('d-none', !canManage);
$('cacheCard').classList.toggle('d-none', !canManage);
$('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets (builds)', d.counts.modular);
}
function filterQuery() {
return 'kind=' + list.kind + '&q=' + encodeURIComponent(list.q) + '&state=' + list.state + '&type=' + encodeURIComponent(list.kind === 'modular' ? list.type : '');
}
// The List view: a server-side DataTable per kind, made the first time it shows. Search and filters stay the
// page's own (above); DataTables does the paging, and sorting by the column headings.
var tables = {}, tableLoaded = null;
function listTable(kind) {
if (tables[kind]) return tables[kind];
var columns = COLUMNS[kind];
tables[kind] = serverTable('#' + (kind === 'modular' ? 'ugcAssemblyTable' : 'ugcModelTable'), null, columns, { dataTable: {
order: [[1, 'desc']],
pageLength: 50,
autoWidth: false,
layout: { topStart: 'pageLength', topEnd: 'info', bottomStart: null, bottomEnd: 'paging' },
language: { emptyTable: 'Nothing here.', zeroRecords: 'Nothing matches.' },
ajax: function (data, callback) {
var size = data.length > 0 ? data.length : 200;
api.get('/api/ugc?' + filterQuery() + '&' + orderQuery(columns, data.order) + '&page=' + Math.floor(data.start / size) + '&size=' + size).then(function (d) {
if (!d.success) { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [], error: d.error || 'Failed' }); return; }
applyMeta(d);
if (list.kind === kind) items = d.items;
callback({ draw: data.draw, recordsTotal: d.total, recordsFiltered: d.total, data: d.items });
if (tableLoaded) { tableLoaded(); tableLoaded = null; }
}).catch(function () { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [] }); });
}
} });
return tables[kind];
}
var loadSequence = 0;
function load() {
var size = pageSize(), sequence = ++loadSequence;
showControls();
var query = 'kind=' + list.kind + '&q=' + encodeURIComponent(list.q) + '&state=' + list.state + '&type=' + encodeURIComponent(list.kind === 'modular' ? list.type : '') +
'&sort=' + list.sort + '&page=' + list.page + '&size=' + size;
return api.get('/api/ugc?' + query).then(function (d) {
var gallery = list.view === 'gallery';
$('gallery').classList.toggle('d-none', !gallery);
$('pagerBar').classList.toggle('d-none', !gallery);
$('listCard').classList.toggle('d-none', gallery);
$('modelTableBox').classList.toggle('d-none', list.kind !== 'model');
$('assemblyTableBox').classList.toggle('d-none', list.kind !== 'modular');
if (!gallery) {
return new Promise(function (resolve) {
tableLoaded = resolve;
var fresh = !tables[list.kind];
var table = listTable(list.kind);
if (!fresh) table.ajax.reload(null, true);
});
}
var size = pageSize(), sequence = ++loadSequence;
return api.get('/api/ugc?' + filterQuery() + '&sort=' + list.sort + '&page=' + list.page + '&size=' + size).then(function (d) {
if (!d.success || sequence !== loadSequence) return;
items = d.items;
total = d.total;
canManage = d.canManage;
publicUrl = (d.ugcPublicUrl || '').replace(/\/+$/, '');
if (d.kinds && !kinds.length) {
kinds = d.kinds;
$('typeFilter').innerHTML = '<option value="">Every type</option>' + kinds.filter(function (k) { return k.buildType !== undefined; })
.map(function (k) { return '<option value="' + esc(k.kind) + '">' + esc(k.label) + '</option>'; }).join('');
$('typeFilter').value = list.type;
}
$('manageButtons').classList.toggle('d-none', !canManage);
$('cacheCard').classList.toggle('d-none', !canManage);
$('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets (builds)', d.counts.modular);
$('gallery').classList.toggle('d-none', list.view !== 'gallery');
$('listCard').classList.toggle('d-none', list.view !== 'list');
var nothing = list.q || list.state || list.type ? 'Nothing matches.' : 'Nothing here.';
if (list.view === 'gallery') $('gallery').innerHTML = items.map(tile).join('') || '<div class="text-body-secondary">' + nothing + '</div>';
else {
$('listHead').innerHTML = listHead();
$('rows').innerHTML = items.map(row).join('') || '<tr><td colspan="8" class="text-body-secondary">' + nothing + '</td></tr>';
}
applyMeta(d);
$('gallery').innerHTML = items.map(tile).join('') || '<div class="text-body-secondary">' + (list.q || list.state || list.type ? 'Nothing matches.' : 'Nothing here.') + '</div>';
var pages = Math.max(1, Math.ceil(total / size));
pager($('pager'), list.page, pages, function (p) { list.page = p; writeUrl(true); load(); });
$('jumpPage').max = pages;
@@ -377,31 +441,49 @@
return 'In the creator\'s ' + esc(x.inventory || 'inventory');
}).join('<br>') || '<span class="text-body-secondary">Not found placed, mailed or with its creator</span>';
}
// The builds that use the open assembly: a server-side DataTable, made once and reloaded for each assembly. A
// linked build (refs.highlight) opens on the page holding it, marked.
var REF_COLUMNS = [
column('Build (blueprint id)', function (b) { return '<code>' + esc(b.id) + '</code>'; }, 'id', true),
column('Owner', owner, 'owner'),
column('Account', function (b) { return b.accountId ? '<a href="/accounts/' + esc(b.accountId) + '">' + esc(b.accountName || b.accountId) + '</a>' : ''; }, 'account'),
column('State', function (b) { return badge(b.state) + errorText(b); }, 'state'),
column('Where it is', function (b) { return whereText(b.where); }),
column('', function (b) { return '<div class="text-end"><a class="btn btn-sm btn-outline-secondary" href="/ugc_search?q=' + encodeURIComponent('id:' + b.id) + '">Find</a></div>'; })
];
var refTable = null;
function loadRefs() {
if (!refs) return;
var wanted = refs;
$('refRows').innerHTML = '<tr><td colspan="6" class="text-body-secondary">Loading…</td></tr>';
api.get('/api/ugc/assembly/builds?modules=' + encodeURIComponent(refs.key) + '&q=' + encodeURIComponent(refs.q) + '&page=' + refs.page + '&size=25' +
(refs.highlight ? '&build=' + encodeURIComponent(refs.highlight) : '')).then(function (d) {
if (refs !== wanted) return;
if (!d.success) { $('refRows').innerHTML = '<tr><td colspan="6" class="text-danger">' + esc(d.error || 'Failed') + '</td></tr>'; return; }
refs.page = d.page;
$('refRows').innerHTML = d.items.map(function (b) {
return '<tr' + (b.id === refs.highlight ? ' class="ugc-highlight" id="refHighlight"' : '') + '><td><code>' + esc(b.id) + '</code></td><td>' + owner(b) + '</td><td>' +
(b.accountId ? '<a href="/accounts/' + esc(b.accountId) + '">' + esc(b.accountName || b.accountId) + '</a>' : '') + '</td><td>' + badge(b.state) +
(b.error ? '<div class="text-danger">' + esc(b.error) + '</div>' : '') + '</td><td>' + whereText(b.where) + '</td><td class="text-end">' +
'<a class="btn btn-sm btn-outline-secondary" href="/ugc_search?q=' + encodeURIComponent('id:' + b.id) + '">Find</a></td></tr>';
}).join('') || '<tr><td colspan="6" class="text-body-secondary">No builds match.</td></tr>';
pager($('refPager'), d.page, Math.max(1, Math.ceil(d.total / d.size)), function (p) { refs.page = p; refs.highlight = ''; loadRefs(); });
$('refTotal').textContent = d.total + ' build' + (d.total === 1 ? '' : 's');
var hl = $('refHighlight');
if (hl) hl.scrollIntoView({ block: 'nearest' });
});
if (refTable) { refTable.ajax.reload(null, true); return; }
refTable = serverTable('#refTable', null, REF_COLUMNS, { dataTable: {
order: [[0, 'desc']],
pageLength: 25,
autoWidth: false,
layout: { topStart: 'pageLength', topEnd: 'info', bottomStart: null, bottomEnd: 'paging' },
language: { emptyTable: 'No builds use these modules.', zeroRecords: 'No builds match.' },
createdRow: function (tr, b) { if (refs && b.id === refs.highlight) tr.classList.add('ugc-highlight'); },
ajax: function (data, callback) {
if (!refs) { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [] }); return; }
var wanted = refs, size = data.length > 0 ? data.length : 200, highlight = refs.highlight && !refs.shown ? refs.highlight : '';
var sort = orderQuery(REF_COLUMNS, data.order).replace('sort=newest', 'sort=id');
api.get('/api/ugc/assembly/builds?modules=' + encodeURIComponent(refs.key) + '&q=' + encodeURIComponent(refs.q) + '&' + sort +
'&page=' + Math.floor(data.start / size) + '&size=' + size + (highlight ? '&build=' + encodeURIComponent(highlight) : '')).then(function (d) {
if (refs !== wanted) return;
if (!d.success) { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [], error: d.error || 'Failed' }); return; }
refs.shown = true;
callback({ draw: data.draw, recordsTotal: d.total, recordsFiltered: d.total, data: d.items });
// The linked build is on another page: go there (the server said which)
if (highlight && d.page * size !== data.start) setTimeout(function () { refTable.page(d.page).draw('page'); }, 0);
var marked = document.querySelector('#refTable .ugc-highlight');
if (marked) marked.scrollIntoView({ block: 'nearest' });
}).catch(function () { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [] }); });
}
} });
}
$('refSearch').addEventListener('input', function () {
var value = this.value.trim();
clearTimeout(refTimer);
refTimer = setTimeout(function () { if (refs) { refs.q = value; refs.page = 0; refs.highlight = ''; loadRefs(); } }, 300);
refTimer = setTimeout(function () { if (refs) { refs.q = value; refs.highlight = ''; loadRefs(); } }, 300);
});
// ---- controls ----
@@ -443,7 +525,7 @@
if (item) preview(item);
}
}
$('rows').addEventListener('click', onItemClick);
$('listCard').addEventListener('click', onItemClick);
$('gallery').addEventListener('click', onItemClick);
$('gallery').addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onItemClick(e); } });
$('previewLinks').addEventListener('click', function (e) {

View File

@@ -72,12 +72,12 @@
</div>
<div id="gallery" class="ugc-gallery mb-2"></div>
<div class="card d-none" id="listCard">
<table class="table table-sm align-middle mb-0 table-stack">
<thead id="listHead"></thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="card d-none" id="listCard"><div class="card-body p-2">
<div id="modelTableBox"><table class="table table-sm align-middle mb-0 w-100 table-stack" id="ugcModelTable">
<thead><tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Made</th><th>Size</th><th>File</th><th></th></tr></thead></table></div>
<div id="assemblyTableBox" class="d-none"><table class="table table-sm align-middle mb-0 w-100 table-stack" id="ugcAssemblyTable">
<thead><tr><th>Icon</th><th>Newest build</th><th>Type</th><th>Modules</th><th>State</th><th>Builds</th><th>Owners</th><th></th></tr></thead></table></div>
</div></div>
<div class="d-flex flex-wrap gap-2 align-items-center mt-2" id="pagerBar">
<nav aria-label="Pages"><ul class="pagination pagination-sm mb-0" id="pager"></ul></nav>
<label class="d-flex align-items-center gap-1 small">Page <input type="number" min="1" class="form-control form-control-sm" style="width:5rem" id="jumpPage" aria-label="Go to page"></label>
@@ -170,11 +170,8 @@
<div class="card mt-3 d-none" id="referencesCard">
<div class="card-header py-2 small fw-semibold d-flex flex-wrap align-items-center gap-2">References <span class="text-body-secondary fw-normal">the builds that use these modules</span>
<input type="search" class="form-control form-control-sm ms-auto" style="max-width: 16rem" id="refSearch" placeholder="Owner, account, property or id" aria-label="Search the builds"></div>
<div class="table-responsive"><table class="table table-sm small align-middle mb-0">
<thead><tr><th>Build (blueprint id)</th><th>Owner</th><th>Account</th><th>State</th><th>Where it is</th><th></th></tr></thead>
<tbody id="refRows"></tbody></table></div>
<div class="card-footer py-1 d-flex align-items-center gap-2 small"><nav aria-label="Reference pages"><ul class="pagination pagination-sm mb-0" id="refPager"></ul></nav>
<span class="ms-auto text-body-secondary" id="refTotal"></span></div>
<div class="card-body p-2"><table class="table table-sm small align-middle mb-0 w-100" id="refTable">
<thead><tr><th>Build (blueprint id)</th><th>Owner</th><th>Account</th><th>State</th><th>Where it is</th><th></th></tr></thead></table></div>
</div>
</div>
</div></div>

View File

@@ -67,6 +67,7 @@ public:
UgcSearch search;
std::optional<IUgc::eProcessState> state;
eSort sort{ eSort::NEWEST };
bool reverse{}; // the sort's other direction (e.g. the fewest bricks first)
uint32_t offset{};
uint32_t limit{ 50 };
};

View File

@@ -50,13 +50,15 @@ namespace UgcLookupSql {
inline std::string ListOrder(const IUgcLookup::UgcListQuery& query, bool modular) {
using eSort = IUgcLookup::eSort;
const std::string id = modular ? "b.ugc_id" : "u.id";
// A sort's own direction, or the other one when the query is reversed; ties stay newest first
const auto dir = [&query](bool descending) { return descending != query.reverse ? " DESC" : " ASC"; };
switch (query.sort) {
case eSort::OLDEST: return "ORDER BY " + id + " ASC ";
case eSort::OWNER: return "ORDER BY c.name ASC, " + id + " DESC ";
case eSort::NAME: return std::string("ORDER BY ") + (modular ? "b.ldf_config" : "u.filename") + " ASC, " + id + " DESC ";
case eSort::BRICKS: return modular ? "ORDER BY " + id + " DESC " : "ORDER BY u.brick_count DESC, u.id DESC ";
case eSort::TRIANGLES: return modular ? "ORDER BY " + id + " DESC " : "ORDER BY u.triangle_count DESC, u.id DESC ";
default: return "ORDER BY " + id + " DESC ";
case eSort::OLDEST: return "ORDER BY " + id + dir(false) + " ";
case eSort::OWNER: return "ORDER BY c.name" + std::string(dir(false)) + ", " + id + " DESC ";
case eSort::NAME: return std::string("ORDER BY ") + (modular ? "b.ldf_config" : "u.filename") + dir(false) + ", " + id + " DESC ";
case eSort::BRICKS: return modular ? "ORDER BY " + id + dir(true) + " " : "ORDER BY u.brick_count" + std::string(dir(true)) + ", u.id DESC ";
case eSort::TRIANGLES: return modular ? "ORDER BY " + id + dir(true) + " " : "ORDER BY u.triangle_count" + std::string(dir(true)) + ", u.id DESC ";
default: return "ORDER BY " + id + dir(true) + " ";
}
}

View File

@@ -170,8 +170,19 @@ TEST_F(UgcLookupSqlTests, ListsPagesWithCounts) {
EXPECT_EQ(total, 2);
query.sort = IUgcLookup::eSort::BRICKS;
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1000, 1001 }));
query.reverse = true; // the fewest bricks first
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1001, 1000 }));
query.reverse = false;
query.sort = IUgcLookup::eSort::NAME; // boat before tower
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1001, 1000 }));
query.reverse = true; // tower before boat
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1000, 1001 }));
query.reverse = false;
query.sort = IUgcLookup::eSort::NEWEST;
query.reverse = true; // the oldest first
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1000, 1001 }));
query.reverse = false;
query.sort = IUgcLookup::eSort::NAME;
query.limit = 1;
query.offset = 1;
std::tie(ids, total) = List(query, false);
@@ -251,6 +262,10 @@ TEST(UgcAssemblies, GroupsBuildsByTheirModules) {
EXPECT_EQ(list.front().key, "4713-4714-4715"); // Classic before Racing
UgcAssemblies::Sort(list, UgcAssemblies::eSort::OLDEST, modules);
EXPECT_EQ(list.front().key, "4713-4714-4715"); // build 10
UgcAssemblies::Sort(list, UgcAssemblies::eSort::REFERENCES, modules, true);
EXPECT_EQ(list.back().key, "4713-4714-4715"); // the most builds last
UgcAssemblies::Sort(list, UgcAssemblies::eSort::NAME, modules, true);
EXPECT_EQ(list.back().key, "4713-4714-4715"); // Racing before Classic
EXPECT_EQ(UgcAssemblies::ParseSort("uses"), UgcAssemblies::eSort::REFERENCES);
EXPECT_FALSE(UgcAssemblies::ParseSort("nonsense"));
}