410 lines
11 KiB
Markdown
410 lines
11 KiB
Markdown
# Parc Machines UX Improvements — 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:** Multi-select site filter with checkboxes, alphabetical sorting on Parc Machines, and OR search (name/reference) on catalog pages.
|
|
|
|
**Architecture:** Frontend-only changes for tasks 1-2 (Vue reactivity + computed sort). Backend Doctrine Extension for task 3 that intercepts `?q=` parameter and builds an OR clause across `name` and `reference` fields, with corresponding frontend composable changes.
|
|
|
|
**Tech Stack:** Vue 3 (reactive Set), DaisyUI 5 checkboxes, Symfony/API Platform Doctrine ORM Extension, PHPUnit
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-23-parc-machines-ux-design.md`
|
|
|
|
---
|
|
|
|
### Task 1: Multi-select site checkboxes on Parc Machines
|
|
|
|
**Files:**
|
|
- Modify: `frontend/app/pages/machines/index.vue`
|
|
|
|
- [ ] **Step 1: Replace `selectedSite` ref with reactive Set**
|
|
|
|
In `<script setup>`, replace:
|
|
```js
|
|
const selectedSite = ref('')
|
|
```
|
|
with:
|
|
```js
|
|
const selectedSites = reactive(new Set())
|
|
```
|
|
|
|
- [ ] **Step 2: Replace `<select>` with checkboxes in template**
|
|
|
|
Replace the site filter `<div class="form-control">` block (the one containing the `<select>`) with:
|
|
```vue
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Sites</span>
|
|
</label>
|
|
<div class="flex flex-wrap gap-3">
|
|
<label
|
|
v-for="site in sites"
|
|
:key="site.id"
|
|
class="flex items-center gap-2 cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
class="checkbox checkbox-sm"
|
|
:checked="selectedSites.has(site.id)"
|
|
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
|
|
>
|
|
<span class="text-sm">{{ site.name }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 3: Update `filteredMachines` computed for multi-select**
|
|
|
|
Replace:
|
|
```js
|
|
if (selectedSite.value) {
|
|
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
|
|
}
|
|
```
|
|
with:
|
|
```js
|
|
if (selectedSites.size > 0) {
|
|
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Clean up unused `ref` import if needed**
|
|
|
|
Check if `ref` is still used elsewhere in the file (it is — `searchQuery` uses it). If so, keep it. Remove only if no longer referenced.
|
|
|
|
- [ ] **Step 5: Add `reactive` to imports**
|
|
|
|
Add `reactive` to the import from `vue`:
|
|
```js
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
```
|
|
|
|
- [ ] **Step 6: Verify in browser**
|
|
|
|
Open `http://localhost:3001/machines`. Confirm:
|
|
- Checkboxes appear for each site
|
|
- Checking one site filters machines to that site only
|
|
- Checking multiple sites shows machines from all selected sites
|
|
- Unchecking all shows all machines
|
|
|
|
- [ ] **Step 7: Run frontend lint**
|
|
|
|
Run: `cd frontend && npm run lint:fix`
|
|
|
|
---
|
|
|
|
### Task 2: Alphabetical sorting on Parc Machines
|
|
|
|
**Files:**
|
|
- Modify: `frontend/app/pages/machines/index.vue`
|
|
|
|
- [ ] **Step 1: Add sort to `filteredMachines` computed**
|
|
|
|
At the end of the `filteredMachines` computed, just before `return filtered`, add:
|
|
```js
|
|
filtered = [...filtered].sort((a, b) =>
|
|
(a.name || '').localeCompare(b.name || '', 'fr')
|
|
)
|
|
```
|
|
|
|
The full computed should now be:
|
|
```js
|
|
const filteredMachines = computed(() => {
|
|
let filtered = enrichedMachines.value
|
|
|
|
if (selectedSites.size > 0) {
|
|
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
|
|
}
|
|
|
|
if (searchQuery.value.trim()) {
|
|
const term = searchQuery.value.trim().toLowerCase()
|
|
filtered = filtered.filter(machine =>
|
|
machine.name?.toLowerCase().includes(term)
|
|
|| machine.reference?.toLowerCase().includes(term),
|
|
)
|
|
}
|
|
|
|
filtered = [...filtered].sort((a, b) =>
|
|
(a.name || '').localeCompare(b.name || '', 'fr')
|
|
)
|
|
|
|
return filtered
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Verify in browser**
|
|
|
|
Open `http://localhost:3001/machines`. Confirm machines are sorted A→Z by name. Test with site filter active — should still be sorted.
|
|
|
|
- [ ] **Step 3: Commit Tasks 1 + 2**
|
|
|
|
```bash
|
|
cd frontend && git add app/pages/machines/index.vue && git commit -m "feat(machines) : multi-select site checkboxes + alphabetical sort"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Backend — Doctrine Extension for OR search
|
|
|
|
**Files:**
|
|
- Create: `src/Doctrine/SearchByNameOrReferenceExtension.php`
|
|
|
|
- [ ] **Step 1: Add `reference` parameter to `createComposant` factory**
|
|
|
|
In `tests/AbstractApiTestCase.php`, update the `createComposant` method to accept an optional `$reference` parameter:
|
|
|
|
Find:
|
|
```php
|
|
protected function createComposant(string $name = 'Composant Test', ?ModelType $type = null): Composant
|
|
{
|
|
$c = new Composant();
|
|
$c->setName($name);
|
|
if (null !== $type) {
|
|
$c->setTypeComposant($type);
|
|
}
|
|
```
|
|
|
|
Replace with:
|
|
```php
|
|
protected function createComposant(string $name = 'Composant Test', ?string $reference = null, ?ModelType $type = null): Composant
|
|
{
|
|
$c = new Composant();
|
|
$c->setName($name);
|
|
if (null !== $reference) {
|
|
$c->setReference($reference);
|
|
}
|
|
if (null !== $type) {
|
|
$c->setTypeComposant($type);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing tests for OR search**
|
|
|
|
Add new test methods in `tests/Api/FilterTest.php`:
|
|
|
|
```php
|
|
public function testOrSearchByNameOnPieces(): void
|
|
{
|
|
$this->createPiece('Joint torique', 'REF-JT-001');
|
|
$this->createPiece('Roulement', 'REF-RL-002');
|
|
|
|
$client = $this->createViewerClient();
|
|
$client->request('GET', '/api/pieces?q=joint');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$this->assertJsonContains(['totalItems' => 1]);
|
|
}
|
|
|
|
public function testOrSearchByReferenceOnPieces(): void
|
|
{
|
|
$this->createPiece('Joint torique', 'REF-JT-001');
|
|
$this->createPiece('Roulement', 'REF-RL-002');
|
|
|
|
$client = $this->createViewerClient();
|
|
$client->request('GET', '/api/pieces?q=RL-002');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$this->assertJsonContains(['totalItems' => 1]);
|
|
}
|
|
|
|
public function testOrSearchMatchesBothNameAndReference(): void
|
|
{
|
|
$this->createComposant('Pompe REF-X', 'REF-POMPE-01');
|
|
$this->createComposant('Vanne', 'REF-VANNE-01');
|
|
$this->createComposant('Moteur', 'POMPE-MOTEUR');
|
|
|
|
$client = $this->createViewerClient();
|
|
$client->request('GET', '/api/composants?q=pompe');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
// Matches "Pompe REF-X" (name) and "Moteur" (reference contains POMPE)
|
|
$this->assertJsonContains(['totalItems' => 2]);
|
|
}
|
|
|
|
public function testOrSearchEmptyQueryReturnsAll(): void
|
|
{
|
|
$this->createProduct('Produit A', 'REF-A');
|
|
$this->createProduct('Produit B', 'REF-B');
|
|
|
|
$client = $this->createViewerClient();
|
|
$client->request('GET', '/api/products?q=');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$data = $client->getResponse()->toArray();
|
|
$this->assertGreaterThanOrEqual(2, $data['totalItems']);
|
|
}
|
|
|
|
public function testOrSearchOnProducts(): void
|
|
{
|
|
$this->createProduct('Huile moteur', 'HM-500');
|
|
$this->createProduct('Graisse', 'GR-100');
|
|
|
|
$client = $this->createViewerClient();
|
|
$client->request('GET', '/api/products?q=HM-500');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$this->assertJsonContains(['totalItems' => 1]);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests to verify they fail**
|
|
|
|
Run: `make test FILES=tests/Api/FilterTest.php`
|
|
Expected: New tests fail (the `q` parameter is not handled yet).
|
|
|
|
- [ ] **Step 4: Create the Doctrine Extension**
|
|
|
|
Create `src/Doctrine/SearchByNameOrReferenceExtension.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Doctrine;
|
|
|
|
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
|
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
|
use ApiPlatform\Metadata\Operation;
|
|
use App\Entity\Composant;
|
|
use App\Entity\Piece;
|
|
use App\Entity\Product;
|
|
use Doctrine\ORM\QueryBuilder;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
|
|
final class SearchByNameOrReferenceExtension implements QueryCollectionExtensionInterface
|
|
{
|
|
private const SUPPORTED_CLASSES = [
|
|
Piece::class,
|
|
Composant::class,
|
|
Product::class,
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly RequestStack $requestStack,
|
|
) {}
|
|
|
|
public function applyToCollection(
|
|
QueryBuilder $queryBuilder,
|
|
QueryNameGeneratorInterface $queryNameGenerator,
|
|
string $resourceClass,
|
|
?Operation $operation = null,
|
|
array $context = [],
|
|
): void {
|
|
if (!\in_array($resourceClass, self::SUPPORTED_CLASSES, true)) {
|
|
return;
|
|
}
|
|
|
|
$request = $this->requestStack->getCurrentRequest();
|
|
if (null === $request) {
|
|
return;
|
|
}
|
|
|
|
$q = $request->query->get('q', '');
|
|
if (!\is_string($q) || '' === trim($q)) {
|
|
return;
|
|
}
|
|
|
|
$escaped = addcslashes(trim($q), '%_');
|
|
$paramName = $queryNameGenerator->generateParameterName('searchQ');
|
|
$alias = $queryBuilder->getRootAliases()[0];
|
|
|
|
$queryBuilder
|
|
->andWhere(sprintf('LOWER(%s.name) LIKE :%s OR LOWER(%s.reference) LIKE :%s', $alias, $paramName, $alias, $paramName))
|
|
->setParameter($paramName, '%' . strtolower($escaped) . '%');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
Run: `make test FILES=tests/Api/FilterTest.php`
|
|
Expected: All tests pass, including the new OR search tests.
|
|
|
|
- [ ] **Step 6: Run full test suite**
|
|
|
|
Run: `make test`
|
|
Expected: All tests pass (no regressions).
|
|
|
|
- [ ] **Step 7: Run php-cs-fixer**
|
|
|
|
Run: `make php-cs-fixer-allow-risky`
|
|
|
|
- [ ] **Step 8: Commit backend changes**
|
|
|
|
```bash
|
|
git add src/Doctrine/SearchByNameOrReferenceExtension.php tests/Api/FilterTest.php tests/AbstractApiTestCase.php && git commit -m "feat(search) : OR search extension for name/reference on Piece, Composant, Product"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Frontend — Switch composables from `name` to `q`
|
|
|
|
**Files:**
|
|
- Modify: `frontend/app/composables/usePieces.ts`
|
|
- Modify: `frontend/app/composables/useComposants.ts`
|
|
- Modify: `frontend/app/composables/useProducts.ts`
|
|
|
|
- [ ] **Step 1: Update `usePieces.ts`**
|
|
|
|
In the `loadPieces` function, replace:
|
|
```ts
|
|
if (search && search.trim()) {
|
|
params.set('name', search.trim())
|
|
}
|
|
```
|
|
with:
|
|
```ts
|
|
if (search && search.trim()) {
|
|
params.set('q', search.trim())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update `useComposants.ts`**
|
|
|
|
Same change in the `loadComposants` function:
|
|
```ts
|
|
params.set('name', search.trim())
|
|
```
|
|
→
|
|
```ts
|
|
params.set('q', search.trim())
|
|
```
|
|
|
|
- [ ] **Step 3: Update `useProducts.ts`**
|
|
|
|
Same change in the `loadProducts` function:
|
|
```ts
|
|
params.set('name', search.trim())
|
|
```
|
|
→
|
|
```ts
|
|
params.set('q', search.trim())
|
|
```
|
|
|
|
- [ ] **Step 4: Run frontend lint**
|
|
|
|
Run: `cd frontend && npm run lint:fix`
|
|
|
|
- [ ] **Step 5: Verify in browser**
|
|
|
|
Open each catalog page and test search:
|
|
- `http://localhost:3001/pieces-catalog` — search by name, then by reference
|
|
- `http://localhost:3001/component-catalog` — search by name, then by reference
|
|
- `http://localhost:3001/product-catalog` — search by name, then by reference
|
|
|
|
Confirm that searching by a reference value returns the correct results.
|
|
|
|
- [ ] **Step 6: Commit frontend changes**
|
|
|
|
```bash
|
|
cd frontend && git add app/composables/usePieces.ts app/composables/useComposants.ts app/composables/useProducts.ts && git commit -m "feat(search) : use q param for OR search on name/reference"
|
|
```
|
|
|
|
- [ ] **Step 7: Update submodule pointer in main repo**
|
|
|
|
```bash
|
|
cd /home/matthieu/dev_malio/Inventory && git add frontend && git commit -m "chore(submodule) : update frontend pointer (OR search + site checkboxes)"
|
|
```
|