From f619487f6f1c6ae584d150ade283f9fa9f37a5bd Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 18 Mar 2026 12:46:40 +0100 Subject: [PATCH] Implement automatic table Excel exports with unit tests: - Add `AutoTableExportService` for dynamic Excel export button generation and visible row extraction. - Integrate auto-export initialization in `main.js` for legacy non-paginated tables. - Extend `PaginationDisplay.vue` with support for paginated Excel export. - Add export functionality to vehicle views and dependencies, including full-table export handling. - Add tests for `TableExcelExportService.js`, export workflows, and normalization logic. - Enhance localization with `download_excel` translation key. --- package-lock.json | 106 +++++++++- package.json | 3 +- .../displays/pagination/PaginationDisplay.vue | 26 ++- .../pagination/TableLabeledPagination.vue | 4 +- .../models/UserDashboard/OrdersPagination.vue | 78 ++++---- .../selfServe/SelfServeQuestionCards.vue | 12 +- .../selfServe/SelfServeQuestionsStep.vue | 2 + src/components/pagination/paginatedList.vue | 7 + src/composables/useSelfServeLogic.js | 131 ++++++++++--- src/i18n/locales/da.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/en.json | 1 + src/i18n/locales/no.json | 1 + src/i18n/locales/sv.json | 1 + src/main.js | 5 +- src/services/AutoTableExportService.js | 159 +++++++++++++++ .../superUserDashboard/vehicle/Vehicle.vue | 159 +++++++++++++++ .../vehicle/imports/vehicleViewUtils.js | 16 +- .../userDashboard/wash/MyWashStart.vue | 5 + tests/unit/paginated-list-export.spec.js | 123 ++++++++++++ tests/unit/self-serve-questions-step.spec.js | 4 + .../unit/superuser-vehicle-analytics.spec.js | 12 ++ tests/unit/superuser-vehicle-view.spec.js | 10 + tests/unit/table-excel-export-service.spec.js | 66 +++++++ tests/unit/table-standards-contract.spec.js | 61 ++++++ tests/unit/use-self-serve-logic.spec.js | 185 ++++++++++++++++++ 26 files changed, 1103 insertions(+), 76 deletions(-) create mode 100644 src/services/AutoTableExportService.js create mode 100644 tests/unit/paginated-list-export.spec.js create mode 100644 tests/unit/table-excel-export-service.spec.js create mode 100644 tests/unit/table-standards-contract.spec.js diff --git a/package-lock.json b/package-lock.json index b79b29a3..376d12aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,8 @@ "vue-router": "^4.5.0", "vue-toast-notification": "^3.1.3", "vue3-cookies": "^1.0.6", - "vuex": "^4.1.0" + "vuex": "^4.1.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@creativebulma/bulma-divider": "^1.1.0", @@ -5052,6 +5053,15 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -5722,6 +5732,19 @@ "follow-redirects": "^1.15.6" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -5904,6 +5927,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", @@ -6060,6 +6092,18 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7263,6 +7307,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fs-extra": { "version": "11.3.3", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", @@ -11030,6 +11083,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -13604,6 +13669,24 @@ "node": ">=8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/workbox-background-sync": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz", @@ -14000,6 +14083,27 @@ "xtend": "^4.0.0" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 1a492a56..baeba7bf 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,8 @@ "vue-router": "^4.5.0", "vue-toast-notification": "^3.1.3", "vue3-cookies": "^1.0.6", - "vuex": "^4.1.0" + "vuex": "^4.1.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@creativebulma/bulma-divider": "^1.1.0", diff --git a/src/components/displays/pagination/PaginationDisplay.vue b/src/components/displays/pagination/PaginationDisplay.vue index ef59da8b..2d28416c 100644 --- a/src/components/displays/pagination/PaginationDisplay.vue +++ b/src/components/displays/pagination/PaginationDisplay.vue @@ -1,14 +1,25 @@ \ No newline at end of file + diff --git a/src/components/displays/pagination/models/UserDashboard/OrdersPagination.vue b/src/components/displays/pagination/models/UserDashboard/OrdersPagination.vue index 0918e810..20eea16f 100644 --- a/src/components/displays/pagination/models/UserDashboard/OrdersPagination.vue +++ b/src/components/displays/pagination/models/UserDashboard/OrdersPagination.vue @@ -13,7 +13,9 @@ import { setOrder, setPage, search, - setFilter + setFilter, + setExportTransform, + clearExportTransform } from "@/components/pagination/paginatedList.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue"; @@ -23,15 +25,29 @@ import PaginationDisplay from "@/components/displays/pagination/PaginationDispla import VehiclesTable from "@/components/displays/user/vehicles/vehiclesTable.vue"; import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue"; import InvoicesTable from "@/components/displays/user/invoices/invoicesTable.vue"; -import {defineProps} from "vue"; import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue"; import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue"; import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue"; import { useI18n } from 'vue-i18n'; -import {exportCsvFile} from "@/services/CSVService.js"; +import { onUnmounted } from "vue"; +import { getDepartmentName } from "@/components/pagination/departmentTabs.vue"; const { t } = useI18n(); + +const formatVehicleRegistrations = (item) => { + return [item.reg_1, item.reg_2, item.reg_3] + .filter((value) => value !== null && value !== undefined && value !== '') + .join(', '); +}; + +const formatOrderAmount = (item) => { + if (item.total_net_amount === null || item.total_net_amount === undefined || item.total_net_amount === '') { + return ''; + } + return SessionUser.functions.currency.toLocal(item.total_net_amount); +}; + const props = defineProps({ onlyFromInvoiceCollection: { type: Number, @@ -61,41 +77,22 @@ if (props.autoLoad) { * */ -const downloadCsv = () => { - const blob = exportCsvFile( - list.value.map((item) => ({ - id: item.id, - department_id: item.department_id, - reg_1: item.reg_1, - reference: item.reference, - notes: item.notes, - created_at: item.created_at, - total_net_amount: item.total_net_amount - })), - [ - SessionUser.objects.orders.columns.id.label, - SessionUser.objects.orders.columns.department_id.label, - SessionUser.functions.ucFirst(SessionUser.objects.vehicles.meta.labels.multiple), - SessionUser.objects.orders.columns.reference.label, - SessionUser.objects.orders.columns.notes.label, - SessionUser.objects.orders.columns.created_at.label, - t('tables.orders.amount') - ] - ) +setExportTransform((rows = []) => { + return rows.map((item) => ({ + [SessionUser.objects.orders.columns.id.label]: item.id ?? "", + [SessionUser.objects.orders.columns.department_id.label]: getDepartmentName(item.department_id) || item.department_id || "", + [SessionUser.functions.ucFirst(SessionUser.objects.vehicles.meta.labels.multiple)]: formatVehicleRegistrations(item), + [SessionUser.objects.orders.columns.reference.label]: item.reference ?? "", + [SessionUser.objects.orders.columns.notes.label]: item.notes ?? "", + [SessionUser.objects.orders.columns.po?.label || 'PO']: item.po ?? "", + [SessionUser.objects.orders.columns.created_at.label]: item.created_at ?? "", + [t('tables.orders.amount')]: formatOrderAmount(item), + })); +}); - if (blob) { - const url = URL.createObjectURL(blob); - - const link = document.createElement('a'); - link.setAttribute('href', url); - link.setAttribute('download', 'orders.csv'); - link.style.visibility = 'hidden'; - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } -} +onUnmounted(() => { + clearExportTransform(); +}); @@ -105,11 +102,6 @@ const downloadCsv = () => { -
- -