33 KiB
Machine Single Save Button + Link Versioning — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the machine page's auto-save-on-blur with a single "Enregistrer les modifications" button (matching composant/pièce/produit pages), and add versioning for machine link add/remove operations.
Architecture: Frontend changes remove all @blur save triggers from MachineInfoCard, consolidate saves into a single submitEdition() method in the orchestrator composable, and add Save/Cancel buttons at the bottom of the page. MachineInfoCard exposes saveFieldDefinitions() via defineExpose so the parent can call it during submit. Backend changes extend MachineAuditSubscriber's onFlush to detect link entity insertions/deletions and create audit entries on the parent machine.
Tech Stack: Vue 3 / Nuxt 4, Symfony 8, Doctrine ORM, PHP 8.4
File Structure
| Action | File | Responsibility |
|---|---|---|
| Modify | frontend/app/components/machine/MachineInfoCard.vue |
Remove blur-triggered saves, expose saveFieldDefinitions via defineExpose |
| Modify | frontend/app/components/machine/MachineCustomFieldDefEditor.vue |
Remove standalone save button |
| Modify | frontend/app/pages/machine/[id].vue |
Add Save/Cancel buttons, wire submitEdition via template ref |
| Modify | frontend/app/composables/useMachineDetailData.ts |
Add submitEdition, cancelEdition, saving, canSubmit |
| Modify | frontend/app/composables/useMachineDetailUpdates.ts |
Remove auto-save from handleMachineConstructeurChange |
| Modify | frontend/app/composables/useMachineDetailCustomFields.ts |
Add saveAllMachineCustomFields batch method |
| Modify | src/EventSubscriber/MachineAuditSubscriber.php |
Enrich snapshot with links + detect link changes in onFlush |
Task 1: Remove blur-triggered saves from MachineInfoCard
Files:
-
Modify:
frontend/app/components/machine/MachineInfoCard.vue -
Step 1: Remove
@blurfrom name input (line 17)
Replace:
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur-field')"
With:
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
- Step 2: Remove
$emit('blur-field')from site select (line 31)
Replace:
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value); $emit('blur-field')"
With:
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value)"
- Step 3: Remove
@blurfrom reference input (line 57)
Replace:
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur-field')"
With:
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
- Step 4: Remove
@blurfrom all custom field inputs
For each custom field input, remove the @blur="$emit('update-custom-field', field)" line:
-
text input (line 118)
-
number input (line 127)
-
select (line 135)
-
boolean checkbox (line 152)
-
date input (line 163)
-
Step 5: Remove
@savehandler from MachineCustomFieldDefEditor usage (line 187)
Replace:
<MachineCustomFieldDefEditor
:fields="fieldDefs.fields.value"
:saving="fieldDefs.saving.value"
:reorder-class="fieldDefs.reorderClass"
:on-drag-start="fieldDefs.onDragStart"
:on-drag-enter="fieldDefs.onDragEnter"
:on-drop="fieldDefs.onDrop"
:on-drag-end="fieldDefs.onDragEnd"
@save="fieldDefs.saveDefinitions()"
@add-field="fieldDefs.addField()"
@remove-field="fieldDefs.removeField($event)"
/>
With:
<MachineCustomFieldDefEditor
:fields="fieldDefs.fields.value"
:saving="fieldDefs.saving.value"
:reorder-class="fieldDefs.reorderClass"
:on-drag-start="fieldDefs.onDragStart"
:on-drag-enter="fieldDefs.onDragEnter"
:on-drop="fieldDefs.onDrop"
:on-drag-end="fieldDefs.onDragEnd"
@add-field="fieldDefs.addField()"
@remove-field="fieldDefs.removeField($event)"
/>
- Step 6: Remove unused emit declarations and add defineExpose
Replace the defineEmits block (lines 222-231):
const emit = defineEmits<{
'update:machine-name': [value: string]
'update:machine-reference': [value: string]
'update:machine-site-id': [value: string]
'update:constructeur-ids': [ids: unknown]
'blur-field': []
'set-custom-field-value': [field: any, value: unknown]
'update-custom-field': [field: any]
'custom-fields-saved': []
}>()
With:
const emit = defineEmits<{
'update:machine-name': [value: string]
'update:machine-reference': [value: string]
'update:machine-site-id': [value: string]
'update:constructeur-ids': [ids: unknown]
'set-custom-field-value': [field: any, value: unknown]
'custom-fields-saved': []
}>()
Then, at the end of the <script setup> block (after the watch at line 239-241), add:
defineExpose({
saveFieldDefinitions: () => fieldDefs.saveDefinitions(),
})
- Step 7: Commit
git add frontend/app/components/machine/MachineInfoCard.vue
git commit -m "refactor(machine) : remove blur-triggered auto-saves from MachineInfoCard"
Task 2: Remove standalone save button from MachineCustomFieldDefEditor
Files:
-
Modify:
frontend/app/components/machine/MachineCustomFieldDefEditor.vue -
Step 1: Remove the "Enregistrer les champs" button (lines 7-15)
Replace the header div (lines 3-16):
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
Définitions des champs personnalisés
</h3>
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="saving"
@click="$emit('save')"
>
<span v-if="saving" class="loading loading-spinner loading-xs" />
Enregistrer les champs
</button>
</div>
With:
<h3 class="text-sm font-semibold">
Définitions des champs personnalisés
</h3>
- Step 2: Remove the
saveemit from defineEmits (line 120)
Replace:
defineEmits<{
save: []
'add-field': []
'remove-field': [index: number]
}>()
With:
defineEmits<{
'add-field': []
'remove-field': [index: number]
}>()
- Step 3: Commit
git add frontend/app/components/machine/MachineCustomFieldDefEditor.vue
git commit -m "refactor(machine) : remove standalone save button from custom field def editor"
Task 3: Stop auto-save in handleMachineConstructeurChange
Files:
-
Modify:
frontend/app/composables/useMachineDetailUpdates.ts:211-214 -
Step 1: Remove the auto-save call
Replace:
const handleMachineConstructeurChange = async (value: unknown) => {
machineConstructeurIds.value = uniqueConstructeurIds(value)
await updateMachineInfo()
}
With:
const handleMachineConstructeurChange = (value: unknown) => {
machineConstructeurIds.value = uniqueConstructeurIds(value)
}
- Step 2: Commit
git add frontend/app/composables/useMachineDetailUpdates.ts
git commit -m "refactor(machine) : stop auto-saving on constructeur change"
Task 4: Add batch custom field save method
Files:
-
Modify:
frontend/app/composables/useMachineDetailCustomFields.ts -
Step 1: Add
saveAllMachineCustomFieldsmethod
After the updatePieceCustomField method (before the return block at line 379), add:
const saveAllMachineCustomFields = async () => {
if (!machine.value) return
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
const fieldsToSave = fields.filter(
(field) => field.value !== undefined && field.value !== null && String(field.value).trim() !== '',
)
for (const field of fieldsToSave) {
const { id: customFieldId, customFieldValueId } = field
try {
if (customFieldValueId) {
await updateCustomFieldValueApi(customFieldValueId as string, {
value: field.value ?? '',
} as any)
} else if (customFieldId) {
const result: any = await upsertCustomFieldValue(
customFieldId as string,
'machine',
machine.value.id as string,
field.value ?? '',
)
if (result.success) {
const createdValue = result.data as AnyRecord
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
if (!createdValue.customField) {
createdValue.customField = {
id: customFieldId,
name: field.name,
type: field.type,
required: field.required,
options: field.options,
}
}
const existingValues = Array.isArray(machine.value.customFieldValues)
? (machine.value.customFieldValues as AnyRecord[]).filter(
(item) => item.id !== createdValue.id,
)
: []
machine.value.customFieldValues = [...existingValues, createdValue]
}
}
}
} catch (error) {
console.error('Erreur lors de la sauvegarde du champ personnalisé:', error)
throw error
}
}
}
- Step 2: Export the new method in the return block
Add saveAllMachineCustomFields to the return object:
Replace:
// Methods
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
With:
// Methods
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
saveAllMachineCustomFields,
- Step 3: Commit
git add frontend/app/composables/useMachineDetailCustomFields.ts
git commit -m "feat(machine) : add batch saveAllMachineCustomFields method"
Task 5: Add submitEdition, cancelEdition, saving, canSubmit to orchestrator
Files:
-
Modify:
frontend/app/composables/useMachineDetailData.ts -
Step 1: Add
savingref in the core state block (after line 63)
After const printAreaRef = ref<HTMLElement | null>(null), add:
const saving = ref(false)
- Step 2: Destructure
saveAllMachineCustomFieldsfrom custom fields sub-composable (line 145)
Replace:
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
With:
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
saveAllMachineCustomFields,
- Step 3: Add
canSubmitcomputed after theisEditModeref block (after line 111)
const canSubmit = computed(() => {
if (!machine.value) return false
if (saving.value) return false
if (!machineName.value.trim()) return false
return true
})
computed is already imported from vue at line 8.
- Step 4: Add
submitEditionandcancelEditionmethods aftertoggleAllPieces(after line 303)
Note: submitEdition does NOT call fieldDefs.saveDefinitions() directly — the page handles that via a template ref on MachineInfoCard, since fieldDefs is instantiated inside MachineInfoCard, not in the orchestrator.
const submitEdition = async () => {
if (!machine.value || saving.value) return
saving.value = true
try {
// 1. Save machine info (name, reference, site, constructeurs)
await updateMachineInfo()
// 2. Save all custom field values
await saveAllMachineCustomFields()
// 3. Reload machine data to get fresh state
await loadMachineData()
// 4. Exit edit mode
isEditMode.value = false
toast.showSuccess('Machine mise à jour avec succès')
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error)
toast.showError('Erreur lors de la sauvegarde de la machine')
} finally {
saving.value = false
}
}
const cancelEdition = () => {
// Reset local state from machine.value
initMachineFields()
syncMachineCustomFields()
isEditMode.value = false
}
- Step 5: Expose new items in the return block (line 423+)
Add to the return object:
// Save
saving, canSubmit, submitEdition, cancelEdition,
- Step 6: Commit
git add frontend/app/composables/useMachineDetailData.ts
git commit -m "feat(machine) : add submitEdition, cancelEdition, saving, canSubmit"
Task 6: Wire Save/Cancel buttons in the page
Files:
-
Modify:
frontend/app/pages/machine/[id].vue -
Step 1: Add template ref on MachineInfoCard (line 56)
Replace:
<MachineInfoCard
With:
<MachineInfoCard
ref="machineInfoCardRef"
- Step 2: Remove the blur-field and update-custom-field handlers from MachineInfoCard (lines 73-77)
Replace:
@update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="() => { d.updateMachineInfo(); refreshVersions() }"
@set-custom-field-value="d.setMachineCustomFieldValue"
@update-custom-field="d.updateMachineCustomField"
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
With:
@update:constructeur-ids="d.handleMachineConstructeurChange"
@set-custom-field-value="d.setMachineCustomFieldValue"
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
- Step 3: Add version refresh to handleAddEntity and link removal handlers
Replace handleAddEntity (lines 253-261):
const handleAddEntity = async (entityId) => {
if (addModalKind.value === 'component') {
await d.addComponentLink(entityId)
} else if (addModalKind.value === 'piece') {
await d.addPieceLink(entityId)
} else {
await d.addProductLink(entityId)
}
}
With:
const handleAddEntity = async (entityId) => {
if (addModalKind.value === 'component') {
await d.addComponentLink(entityId)
} else if (addModalKind.value === 'piece') {
await d.addPieceLink(entityId)
} else {
await d.addProductLink(entityId)
}
refreshVersions()
}
Replace the three remove link handlers in the template:
MachineProductsCard (line 100):
@remove-product="d.removeProductLink"
→
@remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
MachineComponentsCard (line 115):
@remove-component="d.removeComponentLink"
→
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
MachinePiecesCard (line 129):
@remove-piece="d.removePieceLink"
→
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
- Step 4: Add Save/Cancel buttons before EntityHistorySection (before line 141)
Insert before <!-- Historique -->:
<!-- Save / Cancel buttons -->
<div v-if="d.isEditMode.value" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button
type="button"
class="btn btn-ghost"
:class="{ 'btn-disabled': d.saving.value }"
@click="d.cancelEdition()"
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!d.canSubmit.value"
@click="submitMachineEdition"
>
<span v-if="d.saving.value" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
- Step 5: Add ref and submitMachineEdition in the script section
After const d = useMachineDetailData(machineId) (line 226), add:
const machineInfoCardRef = ref(null)
After machineViewTitle computed (line 265), add:
const submitMachineEdition = async () => {
// Save field definitions via template ref (fieldDefs lives inside MachineInfoCard)
if (machineInfoCardRef.value?.saveFieldDefinitions) {
await machineInfoCardRef.value.saveFieldDefinitions()
}
await d.submitEdition()
refreshVersions()
}
- Step 6: Add link versioning labels to historyFieldLabels (lines 237-243)
Replace:
const historyFieldLabels = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
site: 'Site',
constructeurIds: 'Fournisseurs',
}
With:
const historyFieldLabels = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
site: 'Site',
constructeurIds: 'Fournisseurs',
addedComponent: 'Composant ajouté',
removedComponent: 'Composant supprimé',
addedPiece: 'Pièce ajoutée',
removedPiece: 'Pièce supprimée',
addedProduct: 'Produit ajouté',
removedProduct: 'Produit supprimé',
}
- Step 7: Commit
git add frontend/app/pages/machine/[id].vue
git commit -m "feat(machine) : add single save button and wire cancel/submit"
Task 7: Enrich machine snapshot with link data
Files:
-
Modify:
src/EventSubscriber/MachineAuditSubscriber.php -
Step 1: Add link data to snapshotEntity
Replace the snapshotEntity method (lines 37-59):
protected function snapshotEntity(object $entity): array
{
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'prix' => $this->safeGet($entity, 'getPrix'),
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'customFieldValues' => $customFieldValues,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
With:
protected function snapshotEntity(object $entity): array
{
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
$componentLinks = [];
foreach ($entity->getComponentLinks() as $link) {
$componentLinks[] = [
'id' => $link->getId(),
'composantId' => $link->getComposant()->getId(),
'composantName' => $link->getComposant()->getName(),
];
}
$pieceLinks = [];
foreach ($entity->getPieceLinks() as $link) {
$pieceLinks[] = [
'id' => $link->getId(),
'pieceId' => $link->getPiece()->getId(),
'pieceName' => $link->getPiece()->getName(),
'quantity' => $link->getQuantity(),
];
}
$productLinks = [];
foreach ($entity->getProductLinks() as $link) {
$productLinks[] = [
'id' => $link->getId(),
'productId' => $link->getProduct()->getId(),
'productName' => $link->getProduct()->getName(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'prix' => $this->safeGet($entity, 'getPrix'),
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'customFieldValues' => $customFieldValues,
'componentLinks' => $componentLinks,
'pieceLinks' => $pieceLinks,
'productLinks' => $productLinks,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
- Step 2: Run php-cs-fixer
make php-cs-fixer-allow-risky
- Step 3: Commit
git add src/EventSubscriber/MachineAuditSubscriber.php
git commit -m "feat(versioning) : enrich machine snapshot with component/piece/product links"
Task 8: Add link change detection to MachineAuditSubscriber
This uses the existing onFlush pattern (same as collectCustomFieldValueChanges in AbstractAuditSubscriber) instead of a separate subscriber with postPersist/preRemove — which would cause a flush() inside flush() anti-pattern.
Files:
-
Modify:
src/EventSubscriber/MachineAuditSubscriber.php -
Step 1: Add use statements
At the top of the file, add:
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
- Step 2: Override
onFlushto also collect link changes
The base onFlushComplex in AbstractAuditSubscriber already builds $pendingUpdates, $pendingSnapshots, $pendingEntities for collection + custom field changes, then persists audit logs. We need to hook into this.
However, onFlushComplex is private in AbstractAuditSubscriber. So we override onFlush in MachineAuditSubscriber to add link tracking after the parent's processing.
Actually, the simplest correct approach: override the onFlush method in MachineAuditSubscriber to call parent::onFlush() first, then process link entities in a second pass.
But there's a subtlety: after parent::onFlush(), the UnitOfWork state may have changed. The cleaner approach is to add a new protected method in AbstractAuditSubscriber that subclasses can override to inject additional pending changes.
The simplest approach that works with the current architecture: Add a new Doctrine listener on MachineAuditSubscriber for postFlush. After all entities are flushed, check if any link entities were involved, and create a separate audit log.
Actually, let's take an even simpler approach: use a separate subscriber that listens to onFlush (NOT postPersist/preRemove), detects link entity insertions/deletions in the UnitOfWork, and creates audit logs for the parent machine within the same flush cycle — exactly how AbstractAuditSubscriber does it.
Replace the existing MachineAuditSubscriber.php entirely:
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\UnitOfWork;
#[AsDoctrineListener(event: Events::onFlush)]
final class MachineAuditSubscriber extends AbstractAuditSubscriber
{
protected function supports(object $entity): bool
{
return $entity instanceof Machine;
}
protected function entityType(): string
{
return 'machine';
}
protected function hasCollectionTracking(): bool
{
return true;
}
protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object
{
$owner = $cfv->getMachine();
return $owner instanceof Machine ? $owner : null;
}
public function onFlush(OnFlushEventArgs $args): void
{
// Let parent handle regular Machine entity changes (fields, collections, custom fields)
parent::onFlush($args);
// Now handle link entity changes
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
$this->processLinkChanges($em, $uow, $actorProfileId);
}
private function processLinkChanges(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId): void
{
$machineChanges = [];
// Detect inserted links
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$info = $this->extractLinkInfo($entity, 'added');
if (null === $info) {
continue;
}
$machineId = (string) $info['machine']->getId();
if ('' === $machineId) {
continue;
}
$machineChanges[$machineId] ??= ['machine' => $info['machine'], 'diffs' => []];
$machineChanges[$machineId]['diffs'][$info['diffKey']] = [
'from' => null,
'to' => $info['diffValue'],
];
}
// Detect deleted links
foreach ($uow->getScheduledEntityDeletions() as $entity) {
$info = $this->extractLinkInfo($entity, 'removed');
if (null === $info) {
continue;
}
$machineId = (string) $info['machine']->getId();
if ('' === $machineId) {
continue;
}
$machineChanges[$machineId] ??= ['machine' => $info['machine'], 'diffs' => []];
$machineChanges[$machineId]['diffs'][$info['diffKey']] = [
'from' => $info['diffValue'],
'to' => null,
];
}
// Create audit logs for each affected machine
foreach ($machineChanges as $machineId => $change) {
$machine = $change['machine'];
$diff = $change['diffs'];
if ([] === $diff) {
continue;
}
$version = $this->incrementEntityVersion($machine, $em, $uow);
$snapshot = $this->snapshotEntity($machine);
$this->persistAuditLog(
$em,
new \App\Entity\AuditLog('machine', $machineId, 'update', $diff, $snapshot, $actorProfileId, $version),
);
}
}
/**
* @return array{machine: Machine, diffKey: string, diffValue: array{id: string, name: string}}|null
*/
private function extractLinkInfo(object $entity, string $action): ?array
{
if ($entity instanceof MachineComponentLink) {
return [
'machine' => $entity->getMachine(),
'diffKey' => $action . 'Component',
'diffValue' => [
'id' => $entity->getComposant()->getId(),
'name' => $entity->getComposant()->getName(),
],
];
}
if ($entity instanceof MachinePieceLink) {
return [
'machine' => $entity->getMachine(),
'diffKey' => $action . 'Piece',
'diffValue' => [
'id' => $entity->getPiece()->getId(),
'name' => $entity->getPiece()->getName(),
],
];
}
if ($entity instanceof MachineProductLink) {
return [
'machine' => $entity->getMachine(),
'diffKey' => $action . 'Product',
'diffValue' => [
'id' => $entity->getProduct()->getId(),
'name' => $entity->getProduct()->getName(),
],
];
}
return null;
}
protected function snapshotEntity(object $entity): array
{
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
$componentLinks = [];
foreach ($entity->getComponentLinks() as $link) {
$componentLinks[] = [
'id' => $link->getId(),
'composantId' => $link->getComposant()->getId(),
'composantName' => $link->getComposant()->getName(),
];
}
$pieceLinks = [];
foreach ($entity->getPieceLinks() as $link) {
$pieceLinks[] = [
'id' => $link->getId(),
'pieceId' => $link->getPiece()->getId(),
'pieceName' => $link->getPiece()->getName(),
'quantity' => $link->getQuantity(),
];
}
$productLinks = [];
foreach ($entity->getProductLinks() as $link) {
$productLinks[] = [
'id' => $link->getId(),
'productId' => $link->getProduct()->getId(),
'productName' => $link->getProduct()->getName(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'prix' => $this->safeGet($entity, 'getPrix'),
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'customFieldValues' => $customFieldValues,
'componentLinks' => $componentLinks,
'pieceLinks' => $pieceLinks,
'productLinks' => $productLinks,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
}
Note: This combines Task 7 (snapshot enrichment) into this file. If you already committed Task 7 separately, this replaces the full file content.
- Step 2: Run php-cs-fixer
make php-cs-fixer-allow-risky
- Step 3: Run tests
make test
- Step 4: Commit
git add src/EventSubscriber/MachineAuditSubscriber.php
git commit -m "feat(versioning) : detect machine link add/remove in onFlush and create audit logs"
Task 9: Frontend lint and typecheck
Files: All modified frontend files
- Step 1: Run ESLint fix
cd frontend && npm run lint:fix
- Step 2: Run typecheck
cd frontend && npx nuxi typecheck
Expected: 0 errors.
- Step 3: Fix any issues found and commit
git add -A && git commit -m "fix(machine) : fix lint and type issues from single save refactor"
Task 10: Backend lint and test
Files: All modified backend files
- Step 1: Run php-cs-fixer
make php-cs-fixer-allow-risky
- Step 2: Run tests
make test
Expected: All tests pass.
- Step 3: Fix any issues and commit
git add -A && git commit -m "fix(machine) : fix cs-fixer and test issues from single save refactor"
Task 11: Manual verification
- Step 1: Start the app
make start
cd frontend && npm run dev
- Step 2: Test single save flow
- Navigate to a machine detail page
- Click edit
- Change name, reference, site, constructeurs, custom field values, custom field definitions
- Verify NO API calls are fired while editing (check network tab)
- Click "Enregistrer les modifications"
- Verify batch of API calls fires (machine info + custom fields + field defs)
- Verify page reloads in view mode with updated values
- Verify new version appears in version list
- Step 3: Test cancel flow
- Edit a machine, make changes to all fields
- Click "Annuler"
- Verify all fields reset to original values
- Verify no API calls were made
- Step 4: Test link versioning
- Add a composant to a machine via modal
- Check the versions section — should show new version with "Composant ajouté"
- Remove a pièce — should show "Pièce supprimée"
- Add/remove a produit — same pattern
- Step 5: Test documents still work
- Upload a document — should save immediately
- Delete a document — should delete immediately
- No change in behavior