From 453f728b5e5b803c718d253e60612a24aaa2c103 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 17:37:00 -0500 Subject: [PATCH] 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 --- dDashboardServer/routes/UgcAssemblies.h | 4 +- dDashboardServer/routes/UgcRoutes.cpp | 19 +- dDashboardServer/static/js/ugc.js | 200 ++++++++++++------ dDashboardServer/templates/ugc.jinja2 | 19 +- dDatabase/GameDatabase/ITables/IUgcLookup.h | 1 + dDatabase/GameDatabase/ITables/UgcLookupSql.h | 14 +- tests/dWebTests/UgcLookupTests.cpp | 15 ++ 7 files changed, 191 insertions(+), 81 deletions(-) diff --git a/dDashboardServer/routes/UgcAssemblies.h b/dDashboardServer/routes/UgcAssemblies.h index a2c2508e8..45c423dc6 100644 --- a/dDashboardServer/routes/UgcAssemblies.h +++ b/dDashboardServer/routes/UgcAssemblies.h @@ -139,7 +139,8 @@ namespace UgcAssemblies { return assembly.key; } - inline void Sort(std::vector& list, eSort sort, const std::map& modules) { + // reverse: the sort's other direction (the order is flipped as a whole) + inline void Sort(std::vector& list, eSort sort, const std::map& 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()); } } diff --git a/dDashboardServer/routes/UgcRoutes.cpp b/dDashboardServer/routes/UgcRoutes.cpp index 23790ef56..bc2aa2852 100644 --- a/dDashboardServer/routes/UgcRoutes.cpp +++ b/dDashboardServer/routes/UgcRoutes.cpp @@ -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(QueryValue(context.queryString, "page")).value_or(0); const auto size = std::clamp(GeneralUtils::TryParse(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 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(page) * size; i < assemblies.size() && i < static_cast(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(QueryValue(context.queryString, "size")).value_or(25), 1u, 200u); auto page = GeneralUtils::TryParse(QueryValue(context.queryString, "page")).value_or(0); if (const auto wanted = GeneralUtils::TryParse(QueryValue(context.queryString, "build"))) { diff --git a/dDashboardServer/static/js/ugc.js b/dDashboardServer/static/js/ugc.js index edddabf8a..e226205a5 100644 --- a/dDashboardServer/static/js/ugc.js +++ b/dDashboardServer/static/js/ugc.js @@ -67,6 +67,7 @@ $('typeFilter').value = list.type; $('sortSelect').innerHTML = SORTS[list.kind].map(function (o) { return ''; }).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 '
' + iconImg(i, 128) + '
' + title + '
' + sub + '
' + badge(i.state) + extra + '
'; } - function listHead() { - return list.kind === 'modular' ? 'IconTypeModulesStateBuildsOwners' - : 'IconIDOwnerStateMadeSizeDetails'; + function actions(i, remakeId) { + return '' + + (canManage ? '' : ''); } - function row(i) { - var view = ''; - var error = i.error ? '
' + esc(i.error) + '
' : ''; - if (list.kind === 'modular') { - return '' + iconImg(i, 48) + '' + esc(i.kindLabel || i.buildType) + '' + esc(moduleNames(i)) + '
' + esc(i.key) + '
' + error + - '' + badge(i.state) + '' + esc(i.uses) + '' + esc(i.owners) + '' + view + - (canManage ? '' : '') + ''; - } - return '' + iconImg(i, 48) + '' + esc(i.id) + '' + owner(i) + '' + badge(i.state) + - (i.attempts ? ' ' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '' : '') + waitBadge(i) + '' + - '' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '' + (i.bricks ? esc(i.bricks) + ' bricks
' + esc(i.triangles.toLocaleString()) + ' triangles' : '') + - '' + esc(i.detail || '') + error + '' + view + - (canManage ? '' : '') + ''; + function errorText(i) { return i.error ? '
' + esc(i.error) + '
' : ''; } + + // 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 '' + esc(i.id) + ''; }, 'newest', true), + column('Owner', owner, 'owner'), + column('State', function (i) { + return badge(i.state) + (i.attempts ? ' ' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '' : '') + waitBadge(i); + }), + column('Made', function (i) { return '' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + ''; }), + column('Size', function (i) { return '' + (i.bricks ? esc(i.bricks) + ' bricks
' + esc(i.triangles.toLocaleString()) + ' triangles' : '') + '
'; }, 'bricks', true), + column('File', function (i) { return '' + esc(i.detail || '') + '' + errorText(i); }, 'name'), + column('', function (i) { return '
' + actions(i, i.id) + '
'; }) + ], + modular: [ + column('Icon', function (i) { return iconImg(i, 48); }), + column('Newest build', function (i) { return '' + esc(i.newestBuild) + ''; }, 'newest', true), + column('Type', function (i) { return esc(i.kindLabel || i.buildType); }), + column('Modules', function (i) { return '' + esc(moduleNames(i)) + '
' + esc(i.key) + '
' + 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 '
' + actions(i, i.iconBuild) + '
'; }) + ] + }; + // 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 = '' + kinds.filter(function (k) { return k.buildType !== undefined; }) + .map(function (k) { return ''; }).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 = '' + kinds.filter(function (k) { return k.buildType !== undefined; }) - .map(function (k) { return ''; }).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('') || '
' + nothing + '
'; - else { - $('listHead').innerHTML = listHead(); - $('rows').innerHTML = items.map(row).join('') || '' + nothing + ''; - } + applyMeta(d); + $('gallery').innerHTML = items.map(tile).join('') || '
' + (list.q || list.state || list.type ? 'Nothing matches.' : 'Nothing here.') + '
'; 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('
') || 'Not found placed, mailed or with its creator'; } + // 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 '' + esc(b.id) + ''; }, 'id', true), + column('Owner', owner, 'owner'), + column('Account', function (b) { return b.accountId ? '' + esc(b.accountName || b.accountId) + '' : ''; }, '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 ''; }) + ]; + var refTable = null; function loadRefs() { if (!refs) return; - var wanted = refs; - $('refRows').innerHTML = 'Loading…'; - 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 = '' + esc(d.error || 'Failed') + ''; return; } - refs.page = d.page; - $('refRows').innerHTML = d.items.map(function (b) { - return '' + esc(b.id) + '' + owner(b) + '' + - (b.accountId ? '' + esc(b.accountName || b.accountId) + '' : '') + '' + badge(b.state) + - (b.error ? '
' + esc(b.error) + '
' : '') + '' + whereText(b.where) + '' + - 'Find'; - }).join('') || 'No builds match.'; - 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) { diff --git a/dDashboardServer/templates/ugc.jinja2 b/dDashboardServer/templates/ugc.jinja2 index 202529380..4356c2587 100644 --- a/dDashboardServer/templates/ugc.jinja2 +++ b/dDashboardServer/templates/ugc.jinja2 @@ -72,12 +72,12 @@ -
- - - -
-
+
+
+
IconIDOwnerStateMadeSizeFile
+
+
IconNewest buildTypeModulesStateBuildsOwners
+
@@ -170,11 +170,8 @@
References the builds that use these modules
-
- -
Build (blueprint id)OwnerAccountStateWhere it is
- +
+
Build (blueprint id)OwnerAccountStateWhere it is
diff --git a/dDatabase/GameDatabase/ITables/IUgcLookup.h b/dDatabase/GameDatabase/ITables/IUgcLookup.h index 261779e5b..6284829cc 100644 --- a/dDatabase/GameDatabase/ITables/IUgcLookup.h +++ b/dDatabase/GameDatabase/ITables/IUgcLookup.h @@ -67,6 +67,7 @@ public: UgcSearch search; std::optional 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 }; }; diff --git a/dDatabase/GameDatabase/ITables/UgcLookupSql.h b/dDatabase/GameDatabase/ITables/UgcLookupSql.h index 9e8c21f8d..20412aee8 100644 --- a/dDatabase/GameDatabase/ITables/UgcLookupSql.h +++ b/dDatabase/GameDatabase/ITables/UgcLookupSql.h @@ -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) + " "; } } diff --git a/tests/dWebTests/UgcLookupTests.cpp b/tests/dWebTests/UgcLookupTests.cpp index e0d519523..bb33f559f 100644 --- a/tests/dWebTests/UgcLookupTests.cpp +++ b/tests/dWebTests/UgcLookupTests.cpp @@ -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{ 1000, 1001 })); + query.reverse = true; // the fewest bricks first + EXPECT_EQ(List(query, false).first, (std::vector{ 1001, 1000 })); + query.reverse = false; query.sort = IUgcLookup::eSort::NAME; // boat before tower EXPECT_EQ(List(query, false).first, (std::vector{ 1001, 1000 })); + query.reverse = true; // tower before boat + EXPECT_EQ(List(query, false).first, (std::vector{ 1000, 1001 })); + query.reverse = false; + query.sort = IUgcLookup::eSort::NEWEST; + query.reverse = true; // the oldest first + EXPECT_EQ(List(query, false).first, (std::vector{ 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")); }