Add mass-import feature for customer data including UI, parsing enhancements, and E2E testing
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -13,6 +13,7 @@ const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `po
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
|
||||
const serverOutputLimit = 80;
|
||||
const activeOutputReaders = [];
|
||||
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
|
||||
const viteDevArgs = [
|
||||
"run",
|
||||
@@ -289,12 +290,107 @@ async function warmUpAsset(url, expectedContentType, timeoutMs = 120_000) {
|
||||
throw new Error(`Timed out warming Playwright dev asset ${url}.`);
|
||||
}
|
||||
|
||||
async function fetchWarmedAsset(url, expectedContentType, timeoutMs = 120_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const perRequestTimeoutMs = 10_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(perRequestTimeoutMs),
|
||||
});
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (response.ok && contentType.toLowerCase().includes(expectedContentType)) {
|
||||
return await response.text();
|
||||
}
|
||||
} catch {
|
||||
// keep polling until the Vite transform is ready
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out warming Playwright dev asset ${url}.`);
|
||||
}
|
||||
|
||||
function extractModuleImports(source) {
|
||||
const specifiers = new Set();
|
||||
const importPattern =
|
||||
/\bimport(?:\s+[^"'()]+?\s+from\s+)?["']([^"']+)["']|\bimport\(\s*["']([^"']+)["']\s*\)/g;
|
||||
|
||||
for (const match of source.matchAll(importPattern)) {
|
||||
const specifier = match[1] || match[2];
|
||||
if (!specifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (specifier.startsWith("/src/") || specifier.startsWith("/node_modules/.vite/deps/")) {
|
||||
specifiers.add(specifier);
|
||||
}
|
||||
}
|
||||
|
||||
return [...specifiers];
|
||||
}
|
||||
|
||||
function resolveExpectedContentType(specifier) {
|
||||
const cleanSpecifier = specifier.split("?")[0];
|
||||
|
||||
if (cleanSpecifier.endsWith(".css")) {
|
||||
return "css";
|
||||
}
|
||||
|
||||
if (cleanSpecifier.endsWith(".json")) {
|
||||
return "json";
|
||||
}
|
||||
|
||||
return "javascript";
|
||||
}
|
||||
|
||||
async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}) {
|
||||
const queue = [{ url: entryUrl, depth: 0 }];
|
||||
const visited = new Set();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current || visited.has(current.url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(current.url);
|
||||
|
||||
const source = await fetchWarmedAsset(current.url, "javascript", timeoutMs);
|
||||
if (current.depth >= depth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const specifier of extractModuleImports(source)) {
|
||||
const expectedContentType = resolveExpectedContentType(specifier);
|
||||
const importUrl = new URL(specifier, current.url).toString();
|
||||
|
||||
if (expectedContentType === "javascript" && specifier.startsWith("/src/")) {
|
||||
queue.push({
|
||||
url: importUrl,
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await warmUpAsset(importUrl, expectedContentType, timeoutMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function warmUpDevServer(url) {
|
||||
const warmupTargets = await buildWarmupTargets();
|
||||
|
||||
for (const target of warmupTargets) {
|
||||
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
|
||||
}
|
||||
|
||||
await warmModuleGraph(new URL("/src/main.js", url).toString());
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
@@ -339,6 +435,7 @@ export default async function globalSetup() {
|
||||
const stderrLines = [];
|
||||
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines);
|
||||
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines);
|
||||
activeOutputReaders.push(stdoutReader, stderrReader);
|
||||
serverProcess.unref();
|
||||
|
||||
await fs.writeFile(pidFile, JSON.stringify({ pid: serverProcess.pid, port: devPort }), "utf8");
|
||||
@@ -349,8 +446,5 @@ export default async function globalSetup() {
|
||||
} catch (error) {
|
||||
await killProcessTree(serverProcess.pid);
|
||||
throw error;
|
||||
} finally {
|
||||
stdoutReader.close();
|
||||
stderrReader.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
<script setup>
|
||||
import UsersPagination from "@/components/displays/pagination/models/SuperUserDashboard/UsersPagination.vue";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import { showCreateUserForm } from "@/components/forms/superUser/createUserForm.vue";
|
||||
import CustomersPagination from "@/components/displays/pagination/models/SuperUserDashboard/CustomersPagination.vue";
|
||||
import { showMassDataInsertModal } from "@/components/forms/other/functions/showModal.vue";
|
||||
import { init as initCustomerMassImport } from "@/components/forms/other/structures/CustomerMassImportDataStructure.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showCustomerMassImportModal = () => {
|
||||
initCustomerMassImport();
|
||||
showMassDataInsertModal();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageTitle :title="t('superuser.pages.customers.title')" :subtitle="t('superuser.pages.customers.subtitle')">
|
||||
<template #buttons>
|
||||
<!--<button class="button is-dark" @click="showCreateUserForm">
|
||||
<button class="button is-dark" data-testid="superuser-customers-mass-import-button" @click="showCustomerMassImportModal">
|
||||
<span class="icon">
|
||||
<i class="fas fa-plus"></i>
|
||||
<i class="fas fa-file-import"></i>
|
||||
</span>
|
||||
<span>{{ t('superuser.pages.customers.create') }}</span>
|
||||
</button> -->
|
||||
<span>Mass Import</span>
|
||||
</button>
|
||||
</template>
|
||||
</PageTitle>
|
||||
<CustomersPagination auto-load="true" />
|
||||
@@ -26,4 +31,4 @@ const { t } = useI18n();
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -153,7 +153,7 @@ window.addEventListener("keydown", handleKeydown);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal is-active" ref="modal">
|
||||
<div class="modal is-active" ref="modal" data-testid="mass-data-inserter-modal">
|
||||
<div class="modal-background" @click="closeModal"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
@@ -229,4 +229,4 @@ window.addEventListener("keydown", handleKeydown);
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -141,7 +141,7 @@ const getProductOptionsLabel = (vehicle) => {
|
||||
<th v-if="!props.compact">{{ $t('objects.columns.id') }}</th>
|
||||
<th v-if="!props.compact">{{ $t('objects.columns.customer_id') }}</th>
|
||||
<th>{{ $t('objects.bookings.columns.reg_1') }}</th>
|
||||
<th>{{ $t('objects.columns.type') }}</th>
|
||||
<th>{{ $t('vehicles.type') }}</th>
|
||||
<th>{{ $t('objects.vehicles.columns.wash_subscription') }}</th>
|
||||
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
|
||||
<th v-if="!props.compact">{{ $t('objects.columns.reference') }}</th>
|
||||
|
||||
@@ -23,6 +23,7 @@ const onClickSave = () => {
|
||||
<!-- Confirm, save and close -->
|
||||
<button
|
||||
class="button is-light is-fullwidth"
|
||||
data-testid="mass-data-insert-save-button"
|
||||
v-bind:disabled="getDataRows().length === 0"
|
||||
@click="onClickSave"
|
||||
>
|
||||
@@ -35,4 +36,4 @@ const onClickSave = () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,7 @@ const onClickClear = () => {
|
||||
<!-- Add rows -->
|
||||
<button
|
||||
class="button is-light"
|
||||
data-testid="mass-data-insert-add-button"
|
||||
@click="onClickAdd"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
@@ -34,6 +35,7 @@ const onClickClear = () => {
|
||||
<!-- Clear rows -->
|
||||
<button
|
||||
class="button is-danger is-light"
|
||||
data-testid="mass-data-insert-clear-button"
|
||||
@click="onClickClear"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
@@ -46,4 +48,4 @@ const onClickClear = () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import * as XLSX from "xlsx";
|
||||
// This is used to make the user select a file (from their computer) to parse locally
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { setDataRows } from "@/components/forms/other/MassDataInserter.vue";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// File content
|
||||
@@ -23,6 +23,10 @@ const showError = (message) => {
|
||||
});
|
||||
}
|
||||
|
||||
const isSpreadsheetFile = (file) => {
|
||||
return /\.(xlsx|xls)$/i.test(file?.name ?? '');
|
||||
};
|
||||
|
||||
export const initiateFileSelection = (onParsedFunction = null) => {
|
||||
// Reset the file content
|
||||
fileContent.value = '';
|
||||
@@ -39,7 +43,7 @@ export const initiateFileSelection = (onParsedFunction = null) => {
|
||||
// Create a file input element
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.csv, .txt'; // Accept only CSV and TXT files
|
||||
fileInput.accept = '.xlsx, .xls, .csv, .txt, .tsv';
|
||||
|
||||
// Listen for the change event
|
||||
fileInput.addEventListener('change', (event) => {
|
||||
@@ -47,15 +51,24 @@ export const initiateFileSelection = (onParsedFunction = null) => {
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (isSpreadsheetFile(file)) {
|
||||
if (validateSpreadsheetContent(e.target.result)) {
|
||||
parseSpreadsheetContent(e.target.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fileContent.value = e.target.result;
|
||||
// You can now use the file content as needed
|
||||
//console.log('File content:', fileContent.value);
|
||||
if (validateFileContent(fileContent.value)) {
|
||||
// If the file content is valid, parse it
|
||||
parseFileContent(fileContent.value);
|
||||
if (validateDelimitedFileContent(fileContent.value)) {
|
||||
parseDelimitedFileContent(fileContent.value);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
if (isSpreadsheetFile(file)) {
|
||||
reader.readAsArrayBuffer(file);
|
||||
} else {
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,7 +80,7 @@ export const getFileContent = () => {
|
||||
return fileContent.value;
|
||||
};
|
||||
|
||||
const validateFileContent = (content) => {
|
||||
const validateDelimitedFileContent = (content) => {
|
||||
// Check if the content is empty
|
||||
if (!content) {
|
||||
showError('File content is empty');
|
||||
@@ -75,61 +88,129 @@ const validateFileContent = (content) => {
|
||||
}
|
||||
|
||||
// Check if the content has at least one row
|
||||
const rows = content.split('\n');
|
||||
const rows = content
|
||||
.split('\n')
|
||||
.map((row) => normalizeLine(row))
|
||||
.filter((row) => row.trim() !== '');
|
||||
if (rows.length === 0) {
|
||||
showError('File has no rows');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the first row has at least one column
|
||||
const columns = rows[0].split(',');
|
||||
if (columns.length === 0) {
|
||||
showError('File has no columns');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const cleanRow = (row) => {
|
||||
// Remove leading and trailing whitespace
|
||||
row = row.trim();
|
||||
// Remove any extra commas
|
||||
row = row.replace(/,+/g, ',');
|
||||
// Remove any trailing commas
|
||||
row = row.replace(/,$/, '');
|
||||
// Remove \r and \n characters
|
||||
row = row.replace(/\r/g, '');
|
||||
// Replace \t with ;
|
||||
row = row.replace(/\t/g, ';');
|
||||
return row;
|
||||
const validateSpreadsheetContent = (content) => {
|
||||
if (!(content instanceof ArrayBuffer) || content.byteLength === 0) {
|
||||
showError('File content is empty');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const parseFileContent = (content) => {
|
||||
// Split the content by new lines
|
||||
const rows = content.split('\n');
|
||||
// Clean up the rows
|
||||
rows.forEach((row, index) => {
|
||||
rows[index] = cleanRow(row);
|
||||
});
|
||||
// Split each row by commas (or other delimiters)
|
||||
// This assumes that the file is a CSV file
|
||||
const parsedData = rows.map(row => row.split(';'));
|
||||
// Remove empty rows, and columns
|
||||
parsedData.forEach((row, index) => {
|
||||
if (row.length === 0 || row[0] === '') {
|
||||
parsedData.splice(index, 1);
|
||||
const normalizeLine = (row) => {
|
||||
return String(row ?? '')
|
||||
.replace(/\uFEFF/g, '')
|
||||
.replace(/\r/g, '')
|
||||
.trimEnd();
|
||||
};
|
||||
|
||||
const detectDelimiter = (rows) => {
|
||||
const sampleRows = rows
|
||||
.map((row) => normalizeLine(row))
|
||||
.filter((row) => row.trim() !== '')
|
||||
.slice(0, 5);
|
||||
|
||||
const candidates = ['\t', ';', ','];
|
||||
let selectedDelimiter = ';';
|
||||
let highestScore = -1;
|
||||
|
||||
candidates.forEach((candidate) => {
|
||||
const score = sampleRows.reduce((sum, row) => {
|
||||
return sum + ((row.match(new RegExp(candidate === '\t' ? '\\t' : `\\${candidate}`, 'g')) || []).length);
|
||||
}, 0);
|
||||
|
||||
if (score > highestScore) {
|
||||
highestScore = score;
|
||||
selectedDelimiter = candidate;
|
||||
}
|
||||
row.forEach((column, index) => {
|
||||
if (column === '') {
|
||||
row.splice(index, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Set the parsed data to the dataRows
|
||||
console.log(parsedData);
|
||||
|
||||
return selectedDelimiter;
|
||||
};
|
||||
|
||||
const parseDelimitedRow = (row, delimiter) => {
|
||||
const values = [];
|
||||
let currentValue = '';
|
||||
let insideQuotes = false;
|
||||
|
||||
for (let index = 0; index < row.length; index += 1) {
|
||||
const character = row[index];
|
||||
const nextCharacter = row[index + 1];
|
||||
|
||||
if (character === '"') {
|
||||
if (insideQuotes && nextCharacter === '"') {
|
||||
currentValue += '"';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
insideQuotes = !insideQuotes;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!insideQuotes && character === delimiter) {
|
||||
values.push(currentValue.trim());
|
||||
currentValue = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
currentValue += character;
|
||||
}
|
||||
|
||||
values.push(currentValue.trim());
|
||||
return values;
|
||||
};
|
||||
|
||||
const parseDelimitedFileContent = (content) => {
|
||||
const rows = content
|
||||
.split('\n')
|
||||
.map((row) => normalizeLine(row))
|
||||
.filter((row) => row.trim() !== '');
|
||||
|
||||
const delimiter = detectDelimiter(rows);
|
||||
const parsedData = rows
|
||||
.map((row) => parseDelimitedRow(row, delimiter))
|
||||
.filter((row) => row.some((column) => column !== ''));
|
||||
|
||||
parsedFileContent.value = parsedData;
|
||||
onParsed(onParsedFunctionCache.value);
|
||||
};
|
||||
|
||||
const parseSpreadsheetContent = (content) => {
|
||||
const workbook = XLSX.read(content, {
|
||||
type: 'array',
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
if (!firstSheetName) {
|
||||
showError('File has no sheets');
|
||||
return;
|
||||
}
|
||||
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
const parsedData = XLSX.utils.sheet_to_json(worksheet, {
|
||||
header: 1,
|
||||
blankrows: false,
|
||||
defval: '',
|
||||
raw: true,
|
||||
})
|
||||
.map((row) => (Array.isArray(row) ? row.map((column) => String(column ?? '').trim()) : []))
|
||||
.filter((row) => row.some((column) => column !== ''));
|
||||
|
||||
parsedFileContent.value = parsedData;
|
||||
// Run the onParsed function
|
||||
onParsed(onParsedFunctionCache.value);
|
||||
};
|
||||
|
||||
@@ -143,4 +224,4 @@ const onParsed = (onParsedFunction) => {
|
||||
showError('No onParsedFunction set');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<script>
|
||||
import {
|
||||
clearProgress,
|
||||
getCompleted,
|
||||
getFailed,
|
||||
getPending,
|
||||
getProcessing,
|
||||
getRequestLimit,
|
||||
getResponses,
|
||||
setCompleted,
|
||||
setDataColumns,
|
||||
setDataRows,
|
||||
setFailed,
|
||||
setParsedDataFormatFunction,
|
||||
setPending,
|
||||
setProcessing,
|
||||
setRequestLimit,
|
||||
setResponses,
|
||||
setSaveFunction,
|
||||
setSubtitle,
|
||||
setTitle,
|
||||
} from "@/components/forms/other/MassDataInserter.vue";
|
||||
import { generateNewColumn } from "@/components/forms/other/functions/manageDataColumns.vue";
|
||||
import { columnTypes } from "@/components/forms/other/types/dataColumnsType.vue";
|
||||
import { applyColumnProperties } from "@/components/forms/other/types/dataColumn.vue";
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
const HEADER_ALIASES = {
|
||||
cvr: ["cvr", "vat", "vatnumber", "corporateidentificationnumber"],
|
||||
name: ["navn", "name", "company", "companyname", "firmanavn"],
|
||||
email: ["email", "mail", "e-mail", "invoiceemail", "contactemail"],
|
||||
ean: ["ean"],
|
||||
phone: ["telefon", "telefonnummer", "telefonnummer", "phone", "phonenumber", "customernumber", "kundenummer"],
|
||||
};
|
||||
|
||||
const normalizeHeader = (value) => {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
};
|
||||
|
||||
const normalizeText = (value) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
return normalized !== "" ? normalized : null;
|
||||
};
|
||||
|
||||
const normalizeDigits = (value) => {
|
||||
const digits = String(value ?? "").replace(/\D+/g, "").trim();
|
||||
return digits !== "" ? digits : null;
|
||||
};
|
||||
|
||||
const resolveHeaderField = (value) => {
|
||||
const normalized = normalizeHeader(value);
|
||||
|
||||
return Object.entries(HEADER_ALIASES).find(([, aliases]) => aliases.includes(normalized))?.[0] || null;
|
||||
};
|
||||
|
||||
const detectHeaderMap = (row) => {
|
||||
if (!Array.isArray(row)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const map = {};
|
||||
|
||||
row.forEach((value, index) => {
|
||||
const field = resolveHeaderField(value);
|
||||
if (field && map[field] === undefined) {
|
||||
map[field] = index;
|
||||
}
|
||||
});
|
||||
|
||||
const matchedFields = Object.keys(map);
|
||||
if (matchedFields.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (map.cvr === undefined && map.phone === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
const extractRowValue = (row, field, headerMap) => {
|
||||
if (Array.isArray(row)) {
|
||||
if (headerMap && headerMap[field] !== undefined) {
|
||||
return row[headerMap[field]] ?? null;
|
||||
}
|
||||
|
||||
const fallbackIndexByField = {
|
||||
cvr: 0,
|
||||
name: 1,
|
||||
email: 2,
|
||||
ean: 3,
|
||||
phone: 4,
|
||||
};
|
||||
|
||||
const fallbackIndex = fallbackIndexByField[field];
|
||||
return fallbackIndex !== undefined ? row[fallbackIndex] ?? null : null;
|
||||
}
|
||||
|
||||
if (row && typeof row === "object") {
|
||||
const aliases = HEADER_ALIASES[field] || [];
|
||||
const keys = [field, ...aliases];
|
||||
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(row, key)) {
|
||||
return row[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseRows = (rows) => {
|
||||
const safeRows = Array.isArray(rows) ? rows : [];
|
||||
const headerMap = detectHeaderMap(safeRows[0]);
|
||||
const sourceRows = headerMap ? safeRows.slice(1) : safeRows;
|
||||
const parsedRows = [];
|
||||
|
||||
sourceRows.forEach((row, index) => {
|
||||
const parsedRow = {
|
||||
cvr: normalizeDigits(extractRowValue(row, "cvr", headerMap)),
|
||||
name: normalizeText(extractRowValue(row, "name", headerMap)),
|
||||
email: normalizeText(extractRowValue(row, "email", headerMap)),
|
||||
ean: normalizeDigits(extractRowValue(row, "ean", headerMap)),
|
||||
phone: normalizeDigits(extractRowValue(row, "phone", headerMap)),
|
||||
};
|
||||
|
||||
parsedRow.customer_number = parsedRow.phone;
|
||||
|
||||
if (!parsedRow.cvr && !parsedRow.name && !parsedRow.email && !parsedRow.ean && !parsedRow.phone) {
|
||||
return;
|
||||
}
|
||||
|
||||
parsedRow.id = `customer-import-${index + 1}-${parsedRow.phone || parsedRow.cvr || index + 1}`;
|
||||
parsedRows.push(parsedRow);
|
||||
});
|
||||
|
||||
setDataRows(parsedRows);
|
||||
return parsedRows;
|
||||
};
|
||||
|
||||
const processPendingCustomers = () => {
|
||||
while (getPending().length > 0 && getProcessing().length < getRequestLimit()) {
|
||||
const object = getPending()[0];
|
||||
setPending(getPending().slice(1));
|
||||
setProcessing([...getProcessing(), object]);
|
||||
processCustomer(object);
|
||||
}
|
||||
};
|
||||
|
||||
const processCustomer = (object) => {
|
||||
authenticatedRequest("/customers/import", "POST", {
|
||||
customer_number: object.customer_number,
|
||||
phone: object.phone,
|
||||
cvr: object.cvr,
|
||||
name: object.name,
|
||||
email: object.email,
|
||||
ean: object.ean,
|
||||
}).then((response) => {
|
||||
setResponses([
|
||||
...getResponses(),
|
||||
{
|
||||
object,
|
||||
success: true,
|
||||
data: response.data,
|
||||
},
|
||||
]);
|
||||
setCompleted([...getCompleted(), object]);
|
||||
setProcessing(getProcessing().filter((processingObject) => processingObject !== object));
|
||||
}).catch((error) => {
|
||||
setResponses([
|
||||
...getResponses(),
|
||||
{
|
||||
object,
|
||||
success: false,
|
||||
error,
|
||||
},
|
||||
]);
|
||||
setFailed([...getFailed(), object]);
|
||||
setProcessing(getProcessing().filter((processingObject) => processingObject !== object));
|
||||
}).finally(() => {
|
||||
processPendingCustomers();
|
||||
});
|
||||
};
|
||||
|
||||
export const init = () => {
|
||||
clearProgress();
|
||||
setDataRows([]);
|
||||
setTitle("Mass Import Customers");
|
||||
setSubtitle("Import or create company customers from spreadsheet rows");
|
||||
setRequestLimit(3);
|
||||
|
||||
setDataColumns([
|
||||
generateNewColumn("cvr", columnTypes.string, applyColumnProperties({ required: true, label: "CVR" })),
|
||||
generateNewColumn("name", columnTypes.string, applyColumnProperties({ required: false, label: "Name" })),
|
||||
generateNewColumn("email", columnTypes.string, applyColumnProperties({ required: false, label: "Email" })),
|
||||
generateNewColumn("ean", columnTypes.string, applyColumnProperties({ required: false, label: "EAN" })),
|
||||
generateNewColumn("phone", columnTypes.string, applyColumnProperties({ required: true, label: "Phone" })),
|
||||
]);
|
||||
|
||||
setParsedDataFormatFunction((objects) => parseRows(objects));
|
||||
|
||||
setSaveFunction((objects) => {
|
||||
clearProgress();
|
||||
setResponses([]);
|
||||
setPending([...objects]);
|
||||
setProcessing([]);
|
||||
setCompleted([]);
|
||||
setFailed([]);
|
||||
processPendingCustomers();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -34,7 +34,7 @@ export const Vehicles = {
|
||||
}
|
||||
},
|
||||
type: {
|
||||
label: t('objects.columns.type'),
|
||||
label: t('vehicles.type'),
|
||||
type: "select",
|
||||
sortable: true,
|
||||
creation: {
|
||||
@@ -248,4 +248,4 @@ export const Vehicles = {
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"role": "IT chef"
|
||||
},
|
||||
"mads": {
|
||||
"role": "VD"
|
||||
"role": "Direktør"
|
||||
},
|
||||
"mikkel": {
|
||||
"role1": "Grundlægger",
|
||||
@@ -631,7 +631,7 @@
|
||||
"status_barred": "Spærret",
|
||||
"status_booking": "Booking",
|
||||
"status_unknown": "Ny",
|
||||
"type": "Typ",
|
||||
"type": "Type",
|
||||
"unknown": "Ugendt",
|
||||
"use": "Brug",
|
||||
"variant": "Variant",
|
||||
@@ -2415,7 +2415,7 @@
|
||||
"task": "Opgave",
|
||||
"title": "Titel",
|
||||
"total_net_amount": "Total netto beløb",
|
||||
"type": "Typ",
|
||||
"type": "Type",
|
||||
"updated_at": "Opdateret",
|
||||
"url": "URL",
|
||||
"user_id": "Bruger ID",
|
||||
@@ -4143,7 +4143,7 @@
|
||||
"self_service": "Selvvask",
|
||||
"self_service_placeholder": "Placeholder for køretøjer med selvbetjening",
|
||||
"subscriptions_placeholder": "Placeholder for køretøjer med aktive vaskeabonnementer",
|
||||
"type_label": "Typ",
|
||||
"type_label": "Type",
|
||||
"vehicle_count_placeholder": "Placeholder for køretøjsantal",
|
||||
"vehicle_type": "Vogntype",
|
||||
"vehicles_with_self_service": "Køretøjer med selvbetjening",
|
||||
@@ -4185,7 +4185,7 @@
|
||||
"customer": "Kunde",
|
||||
"license_plate": "Registreringsnummer",
|
||||
"submit": "Lagre",
|
||||
"type": "Typ"
|
||||
"type": "Type"
|
||||
},
|
||||
"history": "Historik",
|
||||
"license_plate": "Registreringsnummer",
|
||||
@@ -4204,7 +4204,7 @@
|
||||
"singular": "Kjøretøy",
|
||||
"subtitle": "Administrer kjøretøy",
|
||||
"title": "Køretøjer",
|
||||
"type": "Typ",
|
||||
"type": "Type",
|
||||
"types": {
|
||||
"bus": "Bus",
|
||||
"car": "Bil",
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { seedAuthenticatedState } from "./support/network.js";
|
||||
import { isDesktopProject } from "./support/projects";
|
||||
|
||||
const json = (body: unknown, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const buildWorkbookBuffer = () => {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([
|
||||
["CVR", "Navn", "Email", "EAN", "Telefon nummer"],
|
||||
["31744520", "SPF-DANMARK A/S", "spf@example.com", "5790000000001", "76964600"],
|
||||
["26761751", "STEA A/S", "", "", "75773355"],
|
||||
]);
|
||||
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Customers");
|
||||
return Buffer.from(XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }));
|
||||
};
|
||||
|
||||
async function gotoCustomersImportPage(page: Page) {
|
||||
const importButton = page.getByTestId("superuser-customers-mass-import-button");
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
await page.goto("/superuser/customers", { waitUntil: "domcontentloaded" });
|
||||
|
||||
if ((await importButton.count()) > 0) {
|
||||
return importButton;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1_000 * attempt);
|
||||
}
|
||||
|
||||
return importButton;
|
||||
}
|
||||
|
||||
test.describe("Superuser customers mass import", () => {
|
||||
test("parses spreadsheet rows with blank middle columns and submits them row-by-row", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
const importPayloads: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route(/https?:\/\/(?:api\.truckwash\.io(?::\d+)?)\/.*/i, async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
const method = request.method();
|
||||
|
||||
if (pathname.endsWith("/auth/session") && method === "GET") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
id: 1,
|
||||
customer_number: 12345,
|
||||
group_id: 1,
|
||||
email: "superuser@example.com",
|
||||
phone: {
|
||||
number: "12345678",
|
||||
country_code: 45,
|
||||
},
|
||||
notifications: {
|
||||
wash_certificate_email: null,
|
||||
email_notifications_enabled: true,
|
||||
sms_notifications_enabled: false,
|
||||
},
|
||||
display_name: "E2E Superuser",
|
||||
permissions: ["superuser", "search_customers", "add_user", "user"],
|
||||
economic_customer: [],
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(pathname.endsWith("/auth/recaptcha/pre-check") || pathname.endsWith("/auth/reCAPTCHA/public")) &&
|
||||
method === "GET"
|
||||
) {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
recaptcha: {
|
||||
enabled: false,
|
||||
site_key: "",
|
||||
},
|
||||
rate_limit: {
|
||||
enabled: false,
|
||||
limit: 0,
|
||||
remaining: 0,
|
||||
reset: 0,
|
||||
warning: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/ping") && method === "GET") {
|
||||
await route.fulfill(json({ data: { ok: true } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/worker/version") && method === "GET") {
|
||||
await route.fulfill(json({ data: { version: "test-build" } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/departments") && method === "GET") {
|
||||
await route.fulfill(json({ data: [{ id: 1, name: "Hvidovre", visible: true }] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/customers") && method === "GET") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: 88,
|
||||
customerNumber: 44556677,
|
||||
name: "Existing Customer",
|
||||
email: "existing@example.com",
|
||||
balance: 0,
|
||||
currency: "DKK",
|
||||
barred: false,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/customers/import") && method === "POST") {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
importPayloads.push(body);
|
||||
|
||||
if (String(body.customer_number) === "75773355") {
|
||||
await route.fulfill(
|
||||
json(
|
||||
{
|
||||
data: {
|
||||
message: "Customer already exists locally and already has a login account.",
|
||||
},
|
||||
},
|
||||
409
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
action: "created_customer",
|
||||
message: "Created the customer in e-conomic and imported it locally.",
|
||||
customer_number: Number(body.customer_number),
|
||||
has_account: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(json({ data: [] }));
|
||||
});
|
||||
|
||||
await seedAuthenticatedState(page, "superuser-customers-import-token");
|
||||
|
||||
const importButton = await gotoCustomersImportPage(page);
|
||||
await expect(importButton).toBeVisible();
|
||||
await importButton.click();
|
||||
await expect(page.getByTestId("mass-data-inserter-modal")).toBeVisible();
|
||||
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser"),
|
||||
page.getByTestId("mass-data-insert-add-button").click(),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles({
|
||||
name: "customers.xlsx",
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
buffer: buildWorkbookBuffer(),
|
||||
});
|
||||
|
||||
const modal = page.getByTestId("mass-data-inserter-modal");
|
||||
await expect(modal).toContainText("SPF-DANMARK A/S");
|
||||
await expect(modal).toContainText("76964600");
|
||||
await expect(modal).toContainText("STEA A/S");
|
||||
await expect(modal).toContainText("75773355");
|
||||
|
||||
await page.getByTestId("mass-data-insert-save-button").click();
|
||||
|
||||
await expect.poll(() => importPayloads.length).toBe(2);
|
||||
|
||||
expect(importPayloads[0]).toMatchObject({
|
||||
cvr: "31744520",
|
||||
name: "SPF-DANMARK A/S",
|
||||
email: "spf@example.com",
|
||||
ean: "5790000000001",
|
||||
phone: "76964600",
|
||||
customer_number: "76964600",
|
||||
});
|
||||
|
||||
expect(importPayloads[1]).toMatchObject({
|
||||
cvr: "26761751",
|
||||
name: "STEA A/S",
|
||||
email: null,
|
||||
ean: null,
|
||||
phone: "75773355",
|
||||
customer_number: "75773355",
|
||||
});
|
||||
|
||||
await expect(modal.locator(".tag.is-success")).toHaveCount(1);
|
||||
await expect(modal.locator(".tag.is-danger")).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,30 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { loginAsUser } from "./fixtures";
|
||||
|
||||
const json = (body, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const suppressVersionCheck = async (page) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("lastVersionCheck", String(Date.now()));
|
||||
});
|
||||
|
||||
await page.route(/\/worker\/version(\?.*)?$/i, async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
version: "test-build",
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
test("[PAGES][User][/user/vehicles] shows the current vehicles overview shell", async ({ page }) => {
|
||||
await suppressVersionCheck(page);
|
||||
await loginAsUser(page);
|
||||
await page.goto("/user/vehicles");
|
||||
|
||||
@@ -10,6 +33,7 @@ test("[PAGES][User][/user/vehicles] shows the current vehicles overview shell",
|
||||
});
|
||||
|
||||
test("[PAGES][User][/user/vehicles] opens the add-vehicle dialog", async ({ page }) => {
|
||||
await suppressVersionCheck(page);
|
||||
await loginAsUser(page);
|
||||
await page.goto("/user/vehicles");
|
||||
|
||||
@@ -17,6 +41,7 @@ test("[PAGES][User][/user/vehicles] opens the add-vehicle dialog", async ({ page
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole("heading", { name: /opret/i })).toBeVisible();
|
||||
await expect(dialog.locator("label").filter({ hasText: /^Type$/ })).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: /gem/i })).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: /annuller/i })).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user