38 KiB
Machine Context Custom Fields — Implementation Plan (SUPERSEDED)
This plan has been split into two parallel plans:
- Backend:
2026-04-02-machine-context-fields-backend.md- Frontend:
2026-04-02-machine-context-fields-frontend.mdUse those plans instead. This file is kept for reference only.
Goal: Allow defining custom fields on ModelTypes that only appear and store values per machine-link (not globally on the piece/composant).
Architecture: Add machineContextOnly boolean on CustomField. Add nullable FKs on CustomFieldValue pointing to MachineComponentLink / MachinePieceLink. The MachineStructureController exposes contextCustomFields and contextCustomFieldValues on each link in the response. Frontend structure editors get a toggle, machine detail components get a new "Champs contextuels" section, and standalone pages filter these fields out.
Tech Stack: Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5
Spec: docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md
File Map
Backend — Create
migrations/VersionXXXX_MachineContextCustomFields.php— migrationtests/Api/Entity/MachineContextCustomFieldTest.php— dedicated test class
Backend — Modify
src/Entity/CustomField.php— addmachineContextOnlypropertysrc/Entity/CustomFieldValue.php— addmachineComponentLinkandmachinePieceLinkFKssrc/Entity/MachineComponentLink.php— addcontextFieldValuescollectionsrc/Entity/MachinePieceLink.php— addcontextFieldValuescollectionsrc/Controller/MachineStructureController.php— normalize context fields in structure response + clone context valuessrc/Controller/CustomFieldValueController.php— support link-based upsert/lookuptests/AbstractApiTestCase.php— addmachineContextOnlyparam tocreateCustomField(), add link params tocreateCustomFieldValue()
Frontend — Modify
frontend/app/shared/types/inventory.ts— addmachineContextOnlyto custom field typesfrontend/app/components/PieceModelStructureEditor.vue— add checkbox toggle per fieldfrontend/app/components/StructureNodeEditor.vue— add checkbox toggle per fieldfrontend/app/composables/usePieceStructureEditorLogic.ts— addmachineContextOnly: falseincreateEmptyField()frontend/app/composables/useStructureNodeCrud.ts— addmachineContextOnly: falseinaddCustomField()frontend/app/composables/useEntityCustomFields.ts— filter outmachineContextOnlyfieldsfrontend/app/composables/useMachineDetailCustomFields.ts— propagate context fields + filter from normal mergefrontend/app/components/ComponentItem.vue— display context custom fields sectionfrontend/app/components/PieceItem.vue— display context custom fields section
Task 1: Migration + Entity CustomField — machineContextOnly
Files:
-
Modify:
src/Entity/CustomField.php(add property after line 56) -
Step 1: Add
machineContextOnlyproperty toCustomFieldentity
In src/Entity/CustomField.php, add after the $required property (line 56):
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private bool $machineContextOnly = false;
Add getter/setter before the closing }:
public function isMachineContextOnly(): bool
{
return $this->machineContextOnly;
}
public function setMachineContextOnly(bool $machineContextOnly): static
{
$this->machineContextOnly = $machineContextOnly;
return $this;
}
- Step 2: Generate and adjust migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
Edit the generated migration to use idempotent SQL:
ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL;
- Step 3: Run migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
- Step 4: Run linter
make php-cs-fixer-allow-risky
- Step 5: Commit
git add src/Entity/CustomField.php migrations/
git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity"
Task 2: Entity CustomFieldValue — link FKs + inverse collections
Files:
-
Modify:
src/Entity/CustomFieldValue.php(add after line 67 —$productproperty) -
Modify:
src/Entity/MachineComponentLink.php(add after line 72 —$productLinks) -
Modify:
src/Entity/MachinePieceLink.php(add after line 61 —$productLinks) -
Step 1: Add FKs to
CustomFieldValue
In src/Entity/CustomFieldValue.php, add after the $product property (line 67):
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineComponentLink $machineComponentLink = null;
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachinePieceLink $machinePieceLink = null;
Add getters/setters before the closing }:
public function getMachineComponentLink(): ?MachineComponentLink
{
return $this->machineComponentLink;
}
public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static
{
$this->machineComponentLink = $machineComponentLink;
return $this;
}
public function getMachinePieceLink(): ?MachinePieceLink
{
return $this->machinePieceLink;
}
public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static
{
$this->machinePieceLink = $machinePieceLink;
return $this;
}
- Step 2: Add
contextFieldValuescollection toMachineComponentLink
In src/Entity/MachineComponentLink.php, add after the $productLinks collection (line 72):
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
In the constructor (line 95), add:
$this->contextFieldValues = new ArrayCollection();
Add getter before the closing }:
/**
* @return Collection<int, CustomFieldValue>
*/
public function getContextFieldValues(): Collection
{
return $this->contextFieldValues;
}
- Step 3: Add
contextFieldValuescollection toMachinePieceLink
In src/Entity/MachinePieceLink.php, add after the $productLinks collection (line 61):
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
In the constructor (line 86), add:
$this->contextFieldValues = new ArrayCollection();
Add getter:
/**
* @return Collection<int, CustomFieldValue>
*/
public function getContextFieldValues(): Collection
{
return $this->contextFieldValues;
}
- Step 4: Generate and adjust migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
Edit migration to use idempotent SQL:
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL;
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link
FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link
FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid);
CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid);
- Step 5: Run migration + linter
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
make php-cs-fixer-allow-risky
- Step 6: Commit
git add src/Entity/CustomFieldValue.php src/Entity/MachineComponentLink.php src/Entity/MachinePieceLink.php migrations/
git commit -m "feat(custom-fields) : add link FKs to CustomFieldValue for machine context"
Task 3: Test factories — extend helpers
Files:
-
Modify:
tests/AbstractApiTestCase.php:399-461 -
Step 1: Update
createCustomField()factory
In tests/AbstractApiTestCase.php, add bool $machineContextOnly = false parameter at the end of createCustomField (line 399), and add $cf->setMachineContextOnly($machineContextOnly); after $cf->setOrderIndex($orderIndex); (line 411).
- Step 2: Update
createCustomFieldValue()factory
Add two new nullable parameters at the end of createCustomFieldValue (line 432):
?MachineComponentLink $machineComponentLink = null,
?MachinePieceLink $machinePieceLink = null,
Add the corresponding setter calls after the $product setter (line 453):
if (null !== $machineComponentLink) {
$cfv->setMachineComponentLink($machineComponentLink);
}
if (null !== $machinePieceLink) {
$cfv->setMachinePieceLink($machinePieceLink);
}
- Step 3: Run linter
make php-cs-fixer-allow-risky
- Step 4: Commit
git add tests/AbstractApiTestCase.php
git commit -m "test(custom-fields) : extend factories for machineContextOnly and link params"
Task 4: MachineStructureController — normalize context fields
Files:
-
Modify:
src/Controller/MachineStructureController.php -
Step 1: Add
machineContextOnlyto all normalization methods
In normalizeCustomFields (line 601), add to the output array at line 615:
'machineContextOnly' => $customField->isMachineContextOnly(),
In normalizeCustomFieldDefinitions (line 838), add to the output array at line 852:
'machineContextOnly' => $cf->isMachineContextOnly(),
In normalizeCustomFieldValues (line 861), add to the nested customField array at line 879:
'machineContextOnly' => $cf->isMachineContextOnly(),
- Step 2: Add
normalizeContextCustomFieldDefinitionshelper
Add a new private method after normalizeCustomFieldValues:
private function normalizeContextCustomFieldDefinitions(Collection $customFields): array
{
$items = [];
foreach ($customFields as $cf) {
if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) {
continue;
}
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'machineContextOnly' => true,
];
}
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
return $items;
}
- Step 3: Update
normalizeComponentLinksto include context fields
In normalizeComponentLinks (line 622), add $type variable and context field keys to the returned array:
private function normalizeComponentLinks(array $links): array
{
return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant();
$parentLink = $link->getParentLink();
$type = $composant->getTypeComposant();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [],
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
];
}, $links);
}
- Step 4: Update
normalizePieceLinksto include context fields
In normalizePieceLinks (line 644), same pattern:
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$parentLink = $link->getParentLink();
$type = $piece->getTypePiece();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'quantity' => $this->resolvePieceQuantity($link),
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [],
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
];
}, $links);
}
- Step 5: Run linter
make php-cs-fixer-allow-risky
- Step 6: Commit
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : expose context custom fields in machine structure response"
Task 5: CustomFieldValueController — support link-based upsert
Files:
-
Modify:
src/Controller/CustomFieldValueController.php -
Step 1: Inject link repositories in constructor
In the constructor (line 24), add:
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
Add use statements at the top:
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
- Step 2: Extend
resolveTargetto support link entities
In resolveTarget (line 211), the method applies strtolower() on entityType at line 213. The candidate loop (line 217) and match block (line 233) must both use lowercase.
Update the candidate list in the foreach:
foreach (['machine', 'composant', 'piece', 'product'] as $candidate) {
Replace with:
foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) {
The $entityType is lowercased at line 213, so when machineComponentLinkId is found, $entityType becomes machinecomponentlink. Update the match block:
return match ($entityType) {
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository),
'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository),
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
};
Note: the match keys are lowercase (post-strtolower), but resolveEntity returns the original camelCase type for applyTarget.
- Step 3: Extend
applyTargetfor link entities
Add two new cases in applyTarget (line 252):
case 'machineComponentLink':
$value->setMachineComponentLink($entity);
$value->setComposant($entity->getComposant());
break;
case 'machinePieceLink':
$value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece());
break;
- Step 4: Run linter
make php-cs-fixer-allow-risky
- Step 5: Commit
git add src/Controller/CustomFieldValueController.php
git commit -m "feat(custom-fields) : support link-based upsert in CustomFieldValueController"
Task 6: Clone support — copy context field values
Files:
-
Modify:
src/Controller/MachineStructureController.php(clone methods, around line 163) -
Step 1: Add
cloneContextFieldValueshelper method
Add after cloneProductLinks:
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $pieceLinkMap
*/
private function cloneContextFieldValues(
array $componentLinkMap,
array $pieceLinkMap,
): void {
foreach ($componentLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machineComponentLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachineComponentLink($newLink);
$newValue->setComposant($newLink->getComposant());
$this->entityManager->persist($newValue);
}
}
foreach ($pieceLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machinePieceLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachinePieceLink($newLink);
$newValue->setPiece($newLink->getPiece());
$this->entityManager->persist($newValue);
}
}
}
- Step 2: Call from
cloneMachinemethod
In cloneMachine (line 113), after the cloneProductLinks call (line 163) and before $this->entityManager->flush() (line 165), add:
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
- Step 3: Run linter
make php-cs-fixer-allow-risky
- Step 4: Commit
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : clone context field values on machine clone"
Task 7: Backend tests
Files:
-
Create:
tests/Api/Entity/MachineContextCustomFieldTest.php -
Step 1: Write test class
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
class MachineContextCustomFieldTest extends AbstractApiTestCase
{
public function testStructureReturnsContextFieldsOnComponentLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site A');
$modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Voltage',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$normalField = $this->createCustomField(
name: 'Serial',
type: 'text',
typeComposant: $modelType,
);
$machine = $this->createMachine('Machine A', $site);
$composant = $this->createComposant('Motor 1', 'MOT-001', $modelType);
$link = $this->createMachineComponentLink($machine, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '220',
composant: $composant,
machineComponentLink: $link,
);
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$componentLink = $data['componentLinks'][0];
// Context fields on the link
$this->assertArrayHasKey('contextCustomFields', $componentLink);
$this->assertCount(1, $componentLink['contextCustomFields']);
$this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']);
$this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']);
// Context values on the link
$this->assertArrayHasKey('contextCustomFieldValues', $componentLink);
$this->assertCount(1, $componentLink['contextCustomFieldValues']);
$this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']);
// Normal fields still in composant.customFields
$normalFields = array_filter(
$componentLink['composant']['customFields'],
fn (array $f) => $f['name'] === 'Serial',
);
$this->assertCount(1, $normalFields);
}
public function testStructureReturnsContextFieldsOnPieceLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site B');
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
$contextField = $this->createCustomField(
name: 'Wear Level',
type: 'select',
typePiece: $modelType,
machineContextOnly: true,
);
$contextField->setOptions(['Good', 'Fair', 'Replace']);
$this->getEntityManager()->flush();
$machine = $this->createMachine('Machine B', $site);
$piece = $this->createPiece('Bearing 1', 'BRG-001', $modelType);
$link = $this->createMachinePieceLink($machine, $piece);
$this->createCustomFieldValue(
customField: $contextField,
value: 'Fair',
piece: $piece,
machinePieceLink: $link,
);
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
$data = $response->toArray();
$pieceLink = $data['pieceLinks'][0];
$this->assertCount(1, $pieceLink['contextCustomFields']);
$this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']);
$this->assertCount(1, $pieceLink['contextCustomFieldValues']);
$this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']);
}
public function testUpsertContextFieldValueViaComponentLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site C');
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Flow Rate',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$machine = $this->createMachine('Machine C', $site);
$composant = $this->createComposant('Pump 1', 'PMP-001', $modelType);
$link = $this->createMachineComponentLink($machine, $composant);
$response = $client->request('POST', '/api/custom-fields/values/upsert', [
'json' => [
'customFieldId' => $contextField->getId(),
'machineComponentLinkId' => $link->getId(),
'value' => '380',
],
]);
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertSame('380', $data['value']);
}
public function testSameComposantDifferentValuesPerMachine(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site D');
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Pressure',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$machineA = $this->createMachine('Machine A', $site);
$machineB = $this->createMachine('Machine B', $site);
$composant = $this->createComposant('Valve 1', 'VLV-001', $modelType);
$linkA = $this->createMachineComponentLink($machineA, $composant);
$linkB = $this->createMachineComponentLink($machineB, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '100',
composant: $composant,
machineComponentLink: $linkA,
);
$this->createCustomFieldValue(
customField: $contextField,
value: '200',
composant: $composant,
machineComponentLink: $linkB,
);
$dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray();
$this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
$dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray();
$this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
}
public function testMachineContextOnlyFieldSerialization(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site E');
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Calibration Date',
type: 'date',
typeComposant: $modelType,
machineContextOnly: true,
);
$response = $client->request('GET', '/api/custom_fields/'.$contextField->getId());
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertTrue($data['machineContextOnly']);
}
public function testCloneMachineCopiesContextFieldValues(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site F');
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'RPM Setting',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$source = $this->createMachine('Source Machine', $site);
$composant = $this->createComposant('Motor C', 'MOTC-001', $modelType);
$link = $this->createMachineComponentLink($source, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '3000',
composant: $composant,
machineComponentLink: $link,
);
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
'json' => [
'name' => 'Cloned Machine',
'siteId' => $site->getId(),
],
]);
$this->assertResponseStatusCodeSame(201);
$data = $response->toArray();
$clonedLink = $data['componentLinks'][0] ?? null;
$this->assertNotNull($clonedLink);
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
}
}
- Step 2: Run tests
make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php
Expected: All 6 tests pass.
- Step 3: Run linter
make php-cs-fixer-allow-risky
- Step 4: Commit
git add tests/Api/Entity/MachineContextCustomFieldTest.php
git commit -m "test(custom-fields) : add tests for machine context custom fields"
Task 8: Frontend types — add machineContextOnly
Files:
-
Modify:
frontend/app/shared/types/inventory.ts -
Step 1: Add
machineContextOnlytoComponentModelCustomField
In the ComponentModelCustomField interface (around line 14), add:
machineContextOnly?: boolean
- Step 2: Add
machineContextOnlytoPieceModelCustomField
In the PieceModelCustomField interface (around line 65), add:
machineContextOnly?: boolean
- Step 3: Commit
cd frontend && git add app/shared/types/inventory.ts
git commit -m "feat(custom-fields) : add machineContextOnly to custom field types"
Task 9: Structure editors — add toggle
Files:
-
Modify:
frontend/app/components/PieceModelStructureEditor.vue:122-125 -
Modify:
frontend/app/composables/usePieceStructureEditorLogic.ts:283-290 -
Modify:
frontend/app/components/StructureNodeEditor.vue:121-125 -
Modify:
frontend/app/composables/useStructureNodeCrud.ts:49-62 -
Step 1: Add toggle in
PieceModelStructureEditor.vue
After the "Obligatoire" checkbox block (line 125), add:
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
- Step 2: Update
createEmptyFieldinusePieceStructureEditorLogic.ts
In createEmptyField (line 283), add machineContextOnly: false to the returned object:
const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid('field'),
name: '',
type: 'text',
required: false,
optionsText: '',
machineContextOnly: false,
orderIndex,
})
- Step 3: Add toggle in
StructureNodeEditor.vue
After the "Obligatoire" checkbox (around line 121-125), add:
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
- Step 4: Update
addCustomFieldinuseStructureNodeCrud.ts
In addCustomField (line 49), add machineContextOnly: false to the pushed object at line 53:
fields.push({
name: '',
type: 'text',
required: false,
optionsText: '',
options: [],
machineContextOnly: false,
orderIndex: nextIndex,
})
- Step 5: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 6: Commit
cd frontend && git add .
git commit -m "feat(custom-fields) : add machineContextOnly toggle in structure editors"
Task 10: Frontend — filter context fields on standalone pages + machine-detail transform
Files:
-
Modify:
frontend/app/composables/useEntityCustomFields.ts:42-49 -
Modify:
frontend/app/composables/useMachineDetailCustomFields.ts:96,186,141-154,241-256 -
Step 1: Filter
machineContextOnlyfromdisplayedCustomFieldsinuseEntityCustomFields.ts
Update the displayedCustomFields computed (line 42):
const displayedCustomFields = computed(() =>
dedupeMergedFields(
mergeFieldDefinitionsWithValues(
definitionSources.value,
entity().customFieldValues,
),
).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly),
)
- Step 2: Filter
machineContextOnlyfrom normal customFields merge inuseMachineDetailCustomFields.ts
In transformCustomFields (line 70), after the customFields merge at line 96, filter out context fields. Change the return object (line 141-154) to filter:
Replace customFields, (line 143) with:
customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: piece.contextCustomFields ?? [],
contextCustomFieldValues: piece.contextCustomFieldValues ?? [],
In transformComponentCustomFields (line 158), same pattern. Replace customFields, (line 243) with:
customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: component.contextCustomFields ?? [],
contextCustomFieldValues: component.contextCustomFieldValues ?? [],
- Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 4: Commit
cd frontend && git add .
git commit -m "feat(custom-fields) : filter machineContextOnly from standalone and machine-detail views"
Task 11: Frontend — display context fields in machine view
Files:
- Modify:
frontend/app/components/ComponentItem.vue - Modify:
frontend/app/components/PieceItem.vue
Context fields are on the component / piece object (set by the transform in Task 10), not as separate props.
- Step 1: Add context fields section in
ComponentItem.vue
After the existing CustomFieldDisplay block (line 195), add:
<!-- Context custom fields (machine-specific) -->
<div v-if="mergedContextFields.length" class="mt-4">
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
Champs contextuels
</h4>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
@field-blur="updateContextCustomField"
/>
</div>
In the script section, add:
import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic'
const mergedContextFields = computed(() => {
const definitions = props.component?.contextCustomFields ?? []
const values = props.component?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return dedupeMergedFields(
mergeFieldDefinitionsWithValues(definitions, values),
)
})
const updateContextCustomField = async (field: any) => {
const linkId = props.component?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId || field.customField?.id
if (!customFieldId) return
const { upsertCustomFieldValue } = useCustomFields()
const result: any = await upsertCustomFieldValue(
customFieldId,
'machineComponentLink',
linkId,
field.value ?? '',
)
if (result.success) {
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
} else {
showError(`Erreur lors de la mise à jour du champ contextuel`)
}
}
Note: useCustomFields, showSuccess, and showError may need to be imported or may already be available in the component. Check the existing imports and add if missing:
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
// ... in setup:
const { showSuccess, showError } = useToast()
- Step 2: Add context fields section in
PieceItem.vue
Same pattern. After the existing CustomFieldDisplay (line 236), add:
<!-- Context custom fields (machine-specific) -->
<div v-if="mergedContextFields.length" class="mt-4">
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
Champs contextuels
</h4>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
@field-blur="updateContextCustomField"
/>
</div>
In the script:
const mergedContextFields = computed(() => {
const definitions = props.piece?.contextCustomFields ?? []
const values = props.piece?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return dedupeMergedFields(
mergeFieldDefinitionsWithValues(definitions, values),
)
})
const updateContextCustomField = async (field: any) => {
const linkId = props.piece?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId || field.customField?.id
if (!customFieldId) return
const { upsertCustomFieldValue } = useCustomFields()
const result: any = await upsertCustomFieldValue(
customFieldId,
'machinePieceLink',
linkId,
field.value ?? '',
)
if (result.success) {
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
} else {
showError(`Erreur lors de la mise à jour du champ contextuel`)
}
}
- Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 4: Commit
cd frontend && git add .
git commit -m "feat(custom-fields) : display context custom fields in machine view"
Task 12: Frontend build + full backend test verification
- Step 1: Run full frontend build
cd frontend && npm run build
Expected: Build succeeds.
- Step 2: Run all backend tests
make test
Expected: All tests pass.
- Step 3: Update frontend submodule pointer
cd /home/matthieu/dev_malio/Inventory
git add frontend
git commit -m "chore : update frontend submodule for context custom fields"