Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions assets/controllers/project_bom_export_controller.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
146 changes: 146 additions & 0 deletions src/Controller/ProjectController.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
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;

Expand Down Expand Up @@ -128,6 +132,148 @@ public function build(Project $project, Request $request, ProjectBuildHelper $bu
]);
}

#[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,
Expand Down
Loading
Loading