added controller for handling exporting project BOM data as CSV file

This commit is contained in:
swdee 2026-07-12 21:25:23 +12:00
parent 5ae095a920
commit 969ef1fa42
2 changed files with 322 additions and 0 deletions

View file

@ -0,0 +1,176 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = {
url: String,
};
async export(event) {
event.preventDefault();
const tableElement = this.element
.closest('#bom-tab-pane')
?.querySelector('table');
if (!tableElement) {
throw new Error('Could not find the project BOM table.');
}
if (
typeof window.jQuery === 'undefined'
|| !window.jQuery.fn.DataTable.isDataTable(tableElement)
) {
throw new Error('The project BOM DataTable is not initialized.');
}
const dataTable = window.jQuery(tableElement).DataTable();
const ajaxParameters = dataTable.ajax.params();
const parameters = this.toSearchParameters(ajaxParameters);
/*
* Export the currently visible columns in their current display order.
* Exclude the picture column because it has no useful CSV value.
*/
dataTable
.columns()
.every(function () {
/*
* column.visible() returns DataTables' configured visibility.
*
* Responsive may hide a column visually and move it into a child
* row, but it does not change this configured visibility state.
*/
if (!this.visible()) {
return;
}
const index = this.index();
const settings = dataTable.settings()[0];
const columnSettings = settings.aoColumns[index];
const name = columnSettings.data;
/*
* The picture column is not useful in CSV.
*/
if (!name || name === 'picture') {
return;
}
const heading = this.header().textContent.trim();
parameters.append('exportColumns[]', name);
parameters.append(
'exportLabels[]',
heading || name
);
});
await this.downloadExport(parameters);
}
async downloadExport(parameters) {
const response = await fetch(this.urlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
},
body: parameters.toString(),
credentials: 'same-origin',
});
if (!response.ok) {
const errorBody = await response.text();
console.error('BOM CSV export failed:', {
status: response.status,
statusText: response.statusText,
body: errorBody,
});
throw new Error(
`BOM CSV export failed with HTTP ${response.status}`
);
}
const blob = await response.blob();
const filename = this.getFilename(response)
?? 'project_bom.csv';
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
}
getFilename(response) {
const disposition = response.headers.get('Content-Disposition');
if (!disposition) {
return null;
}
/*
* Prefer RFC 5987 filename*=UTF-8''... when present.
*/
const encodedMatch = disposition.match(
/filename\*=UTF-8''([^;]+)/i
);
if (encodedMatch) {
return decodeURIComponent(encodedMatch[1]);
}
const filenameMatch = disposition.match(
/filename="?([^";]+)"?/i
);
return filenameMatch ? filenameMatch[1] : null;
}
/**
* Convert DataTables' nested AJAX object into query-string parameters.
*/
toSearchParameters(object) {
const parameters = new URLSearchParams();
const append = (key, value) => {
if (Array.isArray(value)) {
value.forEach((item, index) => {
append(`${key}[${index}]`, item);
});
return;
}
if (value !== null && typeof value === 'object') {
Object.entries(value).forEach(([childKey, childValue]) => {
const fullKey = key
? `${key}[${childKey}]`
: childKey;
append(fullKey, childValue);
});
return;
}
parameters.append(key, String(value ?? ''));
};
Object.entries(object).forEach(([key, value]) => {
append(key, value);
});
return parameters;
}
}

View file

@ -46,6 +46,10 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use App\Helpers\FilenameSanatizer;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Serializer\SerializerInterface;
use App\Services\ImportExportSystem\ProjectBomExporter;
use function Symfony\Component\Translation\t;
@ -128,6 +132,148 @@ class ProjectController extends AbstractController
]);
}
#[Route(
path: '/{id}/bom/export',
name: 'project_bom_export',
requirements: ['id' => '\d+'],
methods: ['POST']
)]
public function exportBOM(
Project $project,
Request $request,
ProjectBomExporter $projectBomExporter,
SerializerInterface $serializer,
): Response {
$this->denyAccessUnlessGranted('read', $project);
/*
* First run the normal BOM DataTable callback. This applies exactly the
* same project restriction, search criteria and active column ordering
* as the displayed table.
*
* We only use its hidden ID column. Rendered cell contents are discarded.
*/
$table = $this->dataTableFactory->createFromType(
ProjectBomEntriesDataTable::class,
['project' => $project],
);
$request->request->set('_dt', $table->getName());
/*
* Export every row matching the current search/filter.
* Ignore the page currently displayed in the browser.
*/
$request->request->set('start', 0);
$request->request->set('length', -1);
$table->handleRequest($request);
if (!$table->isCallback()) {
throw new \RuntimeException(
'The BOM export request was not recognised as a DataTable callback.'
);
}
$tableResponse = $table->getResponse();
/** @var array{
* data?: list<array<string, mixed>>
* } $payload
*/
$payload = json_decode(
$tableResponse->getContent() ?: '{}',
true,
512,
JSON_THROW_ON_ERROR,
);
/*
* The DataTable already contains a hidden ID column. Collect those IDs in
* their returned order. That order is the currently selected table order.
*/
$orderedIds = [];
foreach ($payload['data'] ?? [] as $tableRow) {
$id = filter_var(
$tableRow['id'] ?? null,
FILTER_VALIDATE_INT,
);
if ($id !== false) {
$orderedIds[] = $id;
}
}
$columns = array_values(
array_filter(
$request->request->all('exportColumns'),
static fn(mixed $column): bool => is_string($column),
)
);
$labels = array_values(
array_filter(
$request->request->all('exportLabels'),
static fn(mixed $label): bool => is_string($label),
)
);
if ($columns === []) {
throw new \InvalidArgumentException(
'No columns were specified for BOM export.'
);
}
/*
* Reload raw entities from Doctrine. The exporter verifies that every
* entry belongs to this project and restores the DataTable's ID order.
*/
$entries = $projectBomExporter->getOrderedEntries(
$project,
$orderedIds,
);
$rows = [];
foreach ($entries as $entry) {
$rows[] = $projectBomExporter->createRow(
$entry,
$columns,
$labels,
);
}
$csv = $serializer->serialize($rows, 'csv', [
'as_collection' => true,
'csv_delimiter' => ';',
]);
$filename = FilenameSanatizer::sanitizeFilename(
sprintf(
'project_%s_bom.csv',
$project->getName(),
)
);
$response = new Response($csv);
$response->headers->set(
'Content-Type',
'text/csv; charset=UTF-8',
);
$response->headers->set(
'Content-Disposition',
$response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename,
'project_bom.csv',
)
);
return $response;
}
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
public function importBOM(
Request $request,