diff --git a/src/database/__tests__/database.test.js b/src/database/__tests__/database.test.js index 6849582..01150d4 100644 --- a/src/database/__tests__/database.test.js +++ b/src/database/__tests__/database.test.js @@ -73,7 +73,7 @@ jest.unstable_mockModule('../schemas/management/file.schema.js', () => ({ })); // Now import the database utilities -const { listObjects, getObject, newObject, editObject, deleteObject } = +const { listObjects, getObject, newObject, editObject, deleteObject, nestGroups } = await import('../database.js'); const { deleteAuditLog, deleteNotification, editAuditLog, editNotification, newAuditLog } = await import('../../utils.js'); @@ -303,3 +303,70 @@ describe('Database Utilities (CRUD)', () => { }); }); }); + +describe('nestGroups', () => { + const filament = { _id: '691a2d22d69f3e4be70a1347', name: 'CC3D PETG' }; + const sku = { _id: '69adc07ed99b07c037815af0', name: 'Orange' }; + const selected = { _id: 'file-1', name: 'Clothes Peg' }; + + const groups = [ + { + _id: { filament, filamentSku: sku }, + objects: [selected], + }, + { + _id: { filament: null, filamentSku: { _id: 'sku-empty', name: 'Blank' } }, + objects: [{ _id: 'file-2', name: 'No filament' }], + }, + { + _id: { filament: undefined, filamentSku: null }, + objects: [{ _id: 'file-3', name: 'Missing filament' }], + }, + ]; + + test('keeps an explicit null value for missing references', () => { + const result = nestGroups(groups, ['filament', 'filamentSku'], {}); + const empty = result.find((group) => group.value == null); + + expect(empty).toEqual( + expect.objectContaining({ + property: 'filament', + value: null, + children: [], + }) + ); + expect(Object.prototype.hasOwnProperty.call(empty, 'value')).toBe(true); + expect(result.filter((group) => group.value == null)).toHaveLength(1); + }); + + test('nests the selected item and the empty group when filtered', () => { + const selectedTree = nestGroups(groups, ['filament', 'filamentSku'], { + filament: filament._id, + filamentSku: sku._id, + }); + const selectedFilament = selectedTree.find((group) => group.value?._id === filament._id); + + expect(selectedFilament.children).toEqual([ + expect.objectContaining({ + property: 'filamentSku', + value: sku, + children: [selected], + }), + ]); + + const emptyTree = nestGroups(groups, ['filament', 'filamentSku'], { + filament: '__null__', + filamentSku: '__null__', + }); + const emptyFilament = emptyTree.find((group) => group.value == null); + const emptySku = emptyFilament.children.find((group) => group.value == null); + + expect(emptySku).toEqual( + expect.objectContaining({ + property: 'filamentSku', + children: [{ _id: 'file-3', name: 'Missing filament' }], + }) + ); + expect(emptySku.value).toBeNull(); + }); +}); diff --git a/src/database/database.js b/src/database/database.js index 7ac97e3..72e8707 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -891,46 +891,79 @@ const getPropertyValuesUnsafe = async ({ model, property, filter = {} }) => { return result.map((item) => expandObjectIds(item)); }; +// Sentinel for a missing reference (null / undefined). Object keys cannot hold +// null, and JSON drops undefined, so an empty group has to be explicit. +const EMPTY_GROUP_TOKEN = '__null__'; + +function isEmptyGroupValue(value) { + return value == null || value === '' || value === EMPTY_GROUP_TOKEN; +} + +// Helper to extract a stable group key and the values a filter may match. +function getKeyAndFilterVals(value) { + if (isEmptyGroupValue(value)) { + return { key: EMPTY_GROUP_TOKEN, filterVals: [null] }; + } + if (typeof value === 'object') { + if (value.name) { + return { + key: value.name, + filterVals: [value._id?.toString?.(), value.name].filter((val) => val != null && val !== ''), + }; + } + if (value._id != null && value._id !== '') { + const id = value._id.toString(); + return { key: id, filterVals: [id] }; + } + if (typeof value.toHexString === 'function') { + const id = value.toHexString(); + return { key: id, filterVals: [id] }; + } + } + return { key: String(value), filterVals: [value] }; +} + +function groupValueMatchesFilter(filterValue, rawValue) { + if (isEmptyGroupValue(filterValue)) return isEmptyGroupValue(rawValue); + const expected = String(filterValue); + const { filterVals } = getKeyAndFilterVals(rawValue); + return filterVals.some((val) => val != null && String(val) === expected); +} + +function groupNodeValue(value) { + const expanded = expandObjectIds(value ?? null); + return expanded == null ? null : expanded; +} + // Helper to build nested structure for listObjectsByProperty -function nestGroups(groups, props, filter, idx = 0) { +export function nestGroups(groups, props, filter, idx = 0) { if (idx >= props.length) return groups; const prop = props[idx]; const filterPresent = Object.prototype.hasOwnProperty.call(filter, prop); - // Helper to extract a display key and possible filter values from a value - function getKeyAndFilterVals(value) { - if (value && typeof value === 'object') { - if (value.name) return { key: value.name, filterVals: [value._id?.toString?.(), value.name] }; - if (value._id) return { key: value._id.toString(), filterVals: [value._id.toString()] }; - } - return { key: value, filterVals: [value] }; - } - // Build a map of key -> groups for this property - const keyToGroups = {}; + const keyToGroups = new Map(); for (const group of groups) { - const val = group._id[prop]; + const val = group._id?.[prop]; const { key } = getKeyAndFilterVals(val); - if (!keyToGroups[key]) { - keyToGroups[key] = { + if (!keyToGroups.has(key)) { + keyToGroups.set(key, { groups: [], - value: val, - }; + value: val ?? null, + }); } - keyToGroups[key].groups.push(group); + keyToGroups.get(key).groups.push(group); } let result = []; if (filterPresent) { - const filterValue = filter[prop]?.toString?.() ?? filter[prop]; - for (const [key, data] of Object.entries(keyToGroups)) { + for (const data of keyToGroups.values()) { const { groups: groupList, value } = data; - // Check if any group in this key matches the filter (by _id or name) - const matches = groupList.filter((group) => { - const { filterVals } = getKeyAndFilterVals(group._id[prop]); - return filterVals.some((val) => val?.toString() === filterValue); - }); + // Check if any group in this key matches the filter (by _id, name, or empty) + const matches = groupList.filter((group) => + groupValueMatchesFilter(filter[prop], group._id?.[prop]) + ); let children = []; if (matches.length > 0) { @@ -946,16 +979,16 @@ function nestGroups(groups, props, filter, idx = 0) { result.push({ property: prop, - value: expandObjectIds(value), + value: groupNodeValue(value), children: children, }); } } else { // No filter for this property, just show all keys at this level with empty objects - for (const [key, data] of Object.entries(keyToGroups)) { + for (const data of keyToGroups.values()) { result.push({ property: prop, - value: expandObjectIds(data.value), + value: groupNodeValue(data.value), children: [], }); }