diff --git a/CHANGELOG.md b/CHANGELOG.md index a610ff65..3abccbd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,21 @@ ADR-007 and ADR-002. ## [Unreleased] +### Fixed + +- Pickers and batch label sheets no longer stop at 100 rows. `perpage` is + clamped to `MAX_PAGE_SIZE` (100) server-side and the response says nothing + about it, so a control asking for 500 or 1000 received the first 100 rows and + a success. On a site with 126 applications the 26 sorting last could not be + chosen as a knowledge-base topic; batch label printing rendered a sheet for + the first 100 of 262 machines that looked complete. New `fetchAllPages()` + helper plus `listAll()` on the applications, models, machines, computers, + printers, network, measuring-tools, USB and printed-parts APIs, used by the + KB article form and topic filter, the notification form, the report filter + builder, `AssetLabelBatch`, `PrinterQRBatch`, `USBLabelBatch`, + `PrintedPartsLabels`, `MachineForm` and `NetworkDeviceForm`. Lists that render + a page at a time are unchanged - they page deliberately. + ## [0.11.0] - 2026-08-17 The floor map became a set of drawings instead of one picture. A site can hold diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 831dcb59..3592d725 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -50,6 +50,30 @@ api.interceptors.response.use( export default api +// The backend clamps perpage to MAX_PAGE_SIZE (100) and says nothing about it, +// so asking for `perpage: 1000` silently returns the first 100 rows and drops +// the rest. A picker built that way looks complete and is not: with 126 +// applications on a live site, the 26 sorting last were simply unselectable. +// +// Use this wherever a control needs the WHOLE list (dropdowns, pickers, label +// batches) rather than a page of it. Returns the full array directly, not an +// axios response. Anything that renders a paged table should keep calling +// list() with a real page number instead. +export async function fetchAllPages(path, params = {}) { + const first = await api.get(path, { params: { ...params, perpage: 100, page: 1 } }) + let items = first.data.data || [] + const totalpages = first.data.meta?.pagination?.totalpages || 1 + if (totalpages > 1) { + const rest = await Promise.all( + Array.from({ length: totalpages - 1 }, (_, i) => + api.get(path, { params: { ...params, perpage: 100, page: i + 2 } }) + ) + ) + rest.forEach(response => { items = items.concat(response.data.data || []) }) + } + return items +} + // Auth API export const authApi = { login(username, password) { @@ -77,6 +101,13 @@ export const machinesApi = { list(params = {}) { return api.get('/machines', { params }) }, + // Every machine, paged past the backend's 100-row cap. Batch label printing + // and "pick any record" dropdowns must use this: list() with a large + // perpage is clamped to 100 and still returns a success response, so + // the tail simply goes missing. + listAll(params = {}) { + return fetchAllPages('/machines', params) + }, get(id) { return api.get(`/machines/${id}`) }, @@ -120,6 +151,13 @@ export const computersApi = { list(params = {}) { return api.get('/computers', { params }) }, + // Every PC, paged past the backend's 100-row cap. Batch label printing + // and "pick any record" dropdowns must use this: list() with a large + // perpage is clamped to 100 and still returns a success response, so + // the tail simply goes missing. + listAll(params = {}) { + return fetchAllPages('/computers', params) + }, displayKiosks() { return api.get('/computers/display-kiosks') }, @@ -268,6 +306,13 @@ export const printersApi = { list(params = {}) { return api.get('/printers', { params }) }, + // Every printer, paged past the backend's 100-row cap. Batch label printing + // and "pick any record" dropdowns must use this: list() with a large + // perpage is clamped to 100 and still returns a success response, so + // the tail simply goes missing. + listAll(params = {}) { + return fetchAllPages('/printers', params) + }, get(id) { return api.get(`/printers/${id}`) }, @@ -388,19 +433,8 @@ export const modelsApi = { // Backend caps perpage at 100, so page through every model. Returns the // full array directly (not an axios response). Use in forms whose model // dropdown must include the editing record's model regardless of page. - async listAll() { - const first = await api.get('/models', { params: { perpage: 100, page: 1 } }) - let items = first.data.data || [] - const totalpages = first.data.meta?.pagination?.totalpages || 1 - if (totalpages > 1) { - const rest = await Promise.all( - Array.from({ length: totalpages - 1 }, (_, i) => - api.get('/models', { params: { perpage: 100, page: i + 2 } }) - ) - ) - rest.forEach(r => { items = items.concat(r.data.data || []) }) - } - return items + listAll(params = {}) { + return fetchAllPages('/models', params) }, get(id) { return api.get(`/models/${id}`) @@ -468,6 +502,12 @@ export const applicationsApi = { list(params = {}) { return api.get('/applications', { params }) }, + // Every application, paged past the backend's 100-row cap. The catalogue is + // already over 100 entries on a live site, so any picker offering "all + // applications" must use this and not list({ perpage: }). + listAll(params = {}) { + return fetchAllPages('/applications', params) + }, get(id) { return api.get(`/applications/${id}`) }, @@ -713,6 +753,13 @@ export const usbApi = { list(params = {}) { return api.get('/usb', { params }) }, + // Every USB device, paged past the backend's 100-row cap. Batch label printing + // and "pick any record" dropdowns must use this: list() with a large + // perpage is clamped to 100 and still returns a success response, so + // the tail simply goes missing. + listAll(params = {}) { + return fetchAllPages('/usb', params) + }, get(id) { return api.get(`/usb/${id}`) }, @@ -1046,6 +1093,13 @@ export const networkApi = { list(params = {}) { return api.get('/network', { params }) }, + // Every network device, paged past the backend's 100-row cap. Batch label + // printing and "pick any record" dropdowns must use this: list() with a + // large perpage is clamped to 100 and still returns a success response, so + // the tail simply goes missing. + listAll(params = {}) { + return fetchAllPages('/network', params) + }, get(id) { return api.get(`/network/${id}`) }, @@ -1192,6 +1246,13 @@ export const measuringtoolsApi = { list(params = {}) { return api.get('/measuringtools', { params }) }, + // Every measuring tool, paged past the backend's 100-row cap. Batch label printing + // and "pick any record" dropdowns must use this: list() with a large + // perpage is clamped to 100 and still returns a success response, so + // the tail simply goes missing. + listAll(params = {}) { + return fetchAllPages('/measuringtools', params) + }, get(id) { return api.get(`/measuringtools/${id}`) }, @@ -1235,6 +1296,11 @@ export const printedpartsApi = { list(params = {}) { return api.get('/printedparts/items', { params }) }, + // Every printed item, paged past the backend's 100-row cap. The label sheet + // must print the whole selection, not the first page of it. + listAll(params = {}) { + return fetchAllPages('/printedparts/items', params) + }, get(printeditemid) { return api.get(`/printedparts/items/${printeditemid}`) }, diff --git a/frontend/src/views/print/AssetLabelBatch.vue b/frontend/src/views/print/AssetLabelBatch.vue index 8ecd28be..96ec4d17 100644 --- a/frontend/src/views/print/AssetLabelBatch.vue +++ b/frontend/src/views/print/AssetLabelBatch.vue @@ -295,8 +295,10 @@ onMounted(async () => { codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr' encodes.value = await resolveDefaultEncodes(assettype) try { - const response = await config.api.list({ perpage: 500 }) - assets.value = response.data.data || [] + // listAll, not list: perpage is clamped to 100 server-side, so a batch + // sheet built from list() silently omitted every asset past the first + // 100 and printed a short run that looked complete. + assets.value = await config.api.listAll() } catch (err) { console.error('Error loading assets:', err) } finally { diff --git a/frontend/src/views/reports/ReportsIndex.vue b/frontend/src/views/reports/ReportsIndex.vue index c2a5ccc3..34457c3d 100644 --- a/frontend/src/views/reports/ReportsIndex.vue +++ b/frontend/src/views/reports/ReportsIndex.vue @@ -270,8 +270,10 @@ async function loadFilterOptions(fields) { filterOptions.value.locations = response.data.data || [] } if (fields.includes('application') && !filterOptions.value.applications.length) { - const response = await applicationsApi.list({ perpage: 100 }) - filterOptions.value.applications = response.data.data || [] + // listAll: 100 is the server-side cap, not a generous limit, and the + // catalogue is past it - a report filtered by a late-alphabet + // application could not be built. + filterOptions.value.applications = await applicationsApi.listAll() } } catch (error) { console.error('Error loading filter options:', error) diff --git a/plugins/knowledgebase/frontend/views/KnowledgeBaseForm.vue b/plugins/knowledgebase/frontend/views/KnowledgeBaseForm.vue index 8e155864..942c5f25 100644 --- a/plugins/knowledgebase/frontend/views/KnowledgeBaseForm.vue +++ b/plugins/knowledgebase/frontend/views/KnowledgeBaseForm.vue @@ -106,10 +106,13 @@ const applications = ref([]) onMounted(async () => { try { // Load applications for topic dropdown - const appsRes = await applicationsApi.list({ perpage: 1000, showhidden: true }) // isactive is the only filter that applies to a topic: - // ishidden governs whether an application shows on the tiles page, which - // says nothing about whether it can be the subject of an article. - applications.value = appsRes.data.data || [] + // listAll, not list: the backend clamps perpage to 100 without saying so, + // and the catalogue is past that, so a topic sorting late in the alphabet + // was silently missing from this dropdown. + // isactive is the only filter that applies to a topic: ishidden governs + // whether an application shows on the tiles page, which says nothing about + // whether it can be the subject of an article. + applications.value = await applicationsApi.listAll({ showhidden: true }) // Load article if editing if (isEdit.value) { diff --git a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue index 700576af..01c048ca 100644 --- a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue +++ b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue @@ -171,10 +171,13 @@ async function loadArticles() { async function loadTopics() { try { - const response = await applicationsApi.list({ perpage: 1000, showhidden: true }) // isactive is the only filter that applies to a topic: - // ishidden governs whether an application shows on the tiles page, which - // says nothing about whether it can be the subject of an article. - topics.value = response.data.data || [] + // listAll, not list: the backend clamps perpage to 100 without saying so, + // and the catalogue is past that, so topics sorting late in the alphabet + // were silently missing from this filter. + // isactive is the only filter that applies to a topic: ishidden governs + // whether an application shows on the tiles page, which says nothing about + // whether it can be the subject of an article. + topics.value = await applicationsApi.listAll({ showhidden: true }) } catch (error) { console.error('Error loading topics:', error) } diff --git a/plugins/machines/frontend/views/MachineForm.vue b/plugins/machines/frontend/views/MachineForm.vue index 3d499ed0..198ce161 100644 --- a/plugins/machines/frontend/views/MachineForm.vue +++ b/plugins/machines/frontend/views/MachineForm.vue @@ -469,7 +469,7 @@ onMounted(async () => { locationsApi.list({ perpage: 500 }), modelsApi.listAll(), // backend caps perpage at 100; page through all businessunitsApi.list({ perpage: 500 }), - computersApi.list({ perpage: 500 }), + computersApi.listAll(), // backend caps perpage at 100; page through all assetsApi.types.list() // Used for relationship types, will fix below ]) diff --git a/plugins/network/frontend/views/NetworkDeviceForm.vue b/plugins/network/frontend/views/NetworkDeviceForm.vue index d9bccd1c..c53b7a9c 100644 --- a/plugins/network/frontend/views/NetworkDeviceForm.vue +++ b/plugins/network/frontend/views/NetworkDeviceForm.vue @@ -428,8 +428,9 @@ async function loadVendors() { try { const response = await vendorsApi.list({ perpage: 100 }) vendors.value = response.data.data || [] - const modelResponse = await modelsApi.list({ perpage: 500 }) - models.value = modelResponse.data.data || [] + // listAll: the models catalogue is already past the backend's 100-row + // cap, so list() left the tail of it unselectable. + models.value = await modelsApi.listAll() } catch (err) { console.error('Error loading vendors:', err) } diff --git a/plugins/notifications/frontend/views/NotificationForm.vue b/plugins/notifications/frontend/views/NotificationForm.vue index a48fff23..f80bd2d7 100644 --- a/plugins/notifications/frontend/views/NotificationForm.vue +++ b/plugins/notifications/frontend/views/NotificationForm.vue @@ -318,7 +318,10 @@ onMounted(async () => { const [typesRes, buRes, appsRes, tzRes] = await Promise.all([ notificationsApi.types.list(), businessUnitsApi.list().catch(() => ({ data: { data: [] } })), - applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } })), + // listAll: perpage is clamped to 100 server-side, and the catalogue is + // already past that, so this dropdown was missing its tail. + applicationsApi.listAll().then(items => ({ data: { data: items } })) + .catch(() => ({ data: { data: [] } })), settingsApi.get('site_timezone').catch(() => null) ]) diff --git a/plugins/printedparts/frontend/views/PrintedPartsLabels.vue b/plugins/printedparts/frontend/views/PrintedPartsLabels.vue index 73cdb91a..88f74310 100644 --- a/plugins/printedparts/frontend/views/PrintedPartsLabels.vue +++ b/plugins/printedparts/frontend/views/PrintedPartsLabels.vue @@ -78,8 +78,9 @@ function labelText(item) { onMounted(async () => { try { - const response = await printedpartsApi.list({ perpage: 500 }) - items.value = response.data.data || [] + // listAll: perpage is clamped to 100, and a label sheet must cover every + // item, not the first page of them. + items.value = await printedpartsApi.listAll() // ?item= preselects one part (the Detail-page print button) const preselect = new URLSearchParams(window.location.search).get('item') if (preselect) { diff --git a/plugins/printers/frontend/views/PrinterQRBatch.vue b/plugins/printers/frontend/views/PrinterQRBatch.vue index ec7d7807..2a092312 100644 --- a/plugins/printers/frontend/views/PrinterQRBatch.vue +++ b/plugins/printers/frontend/views/PrinterQRBatch.vue @@ -91,8 +91,9 @@ const pages = computed(() => { onMounted(async () => { try { - const response = await printersApi.list({ perpage: 500 }) - printers.value = response.data.data || [] + // listAll: perpage is clamped to 100, and a batch sheet must cover every + // printer, not the first page of them. + printers.value = await printersApi.listAll() } catch (error) { console.error('Error loading printers:', error) } finally { diff --git a/plugins/usb/frontend/views/USBLabelBatch.vue b/plugins/usb/frontend/views/USBLabelBatch.vue index ed0110cc..a43576a1 100644 --- a/plugins/usb/frontend/views/USBLabelBatch.vue +++ b/plugins/usb/frontend/views/USBLabelBatch.vue @@ -145,8 +145,9 @@ const sheetPages = computed(() => { onMounted(async () => { labelStyle.value = (await getSetting('usb_label_style', 'barcode')) === 'qr' ? 'qr' : 'barcode' try { - const response = await usbApi.list({ perpage: 500 }) - devices.value = response.data.data || [] + // listAll: perpage is clamped to 100, and a batch sheet must cover every + // device, not the first page of them. + devices.value = await usbApi.listAll() } catch (error) { console.error('Error loading USB devices:', error) } finally {