fix: check category parents and repair orphan categories (#393) #472
24 changed files with 2119 additions and 102 deletions
|
|
@ -2,11 +2,20 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- Catégories : un bouton « Vérifier la hiérarchie », entre « Tous les mots-clés » et « Réinitialiser », liste les catégories orphelines : celles prises dans une boucle de parenté, qui les cache avec leurs sous-catégories de l'arbre, des menus de catégories, du budget et des rapports, et celles dont le parent a été supprimé ou est introuvable. Chacune se rattache à la racine, choisie par défaut, ou à un autre parent ; elle revient alors à sa place avec ses transactions, ses mots-clés et ses montants de budget. Le Journal note chaque rattachement, sans nom de catégorie (#393).
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
||||||
- Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus le Journal en bas de la page. L'historique des versions s'ouvre sur les notes de la version installée, les autres versions repliées : cliquez sur un numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (#372).
|
- Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus le Journal en bas de la page. L'historique des versions s'ouvre sur les notes de la version installée, les autres versions repliées : cliquez sur un numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (#372).
|
||||||
- Ajustements : la catégorie d'une ligne d'ajustement accepte désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie et les noms des catégories standard dans votre langue. Elle propose les mêmes catégories que la page Transactions, actives et autorisées à la saisie, comme la fenêtre de répartition ouverte depuis la page Ajustements ; une ligne déjà enregistrée sur une catégorie masquée de la saisie la garde, son champ vide tant que vous n'en choisissez pas une autre. Le filtre de catégorie de la fenêtre « Lier transferts » du Bilan accepte aussi la saisie, avec « Toutes les catégories » en tête. Une liste de catégories ouverte n'est plus coupée par le bord d'une fenêtre ou du tableau des transactions, dans la fenêtre de répartition comme sur les dernières lignes du tableau, et elle s'ouvre vers le haut quand elle ne tient pas en dessous mais tient au-dessus (#429).
|
- Ajustements : la catégorie d'une ligne d'ajustement accepte désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie et les noms des catégories standard dans votre langue. Elle propose les mêmes catégories que la page Transactions, actives et autorisées à la saisie, comme la fenêtre de répartition ouverte depuis la page Ajustements ; une ligne déjà enregistrée sur une catégorie masquée de la saisie la garde, son champ vide tant que vous n'en choisissez pas une autre. Le filtre de catégorie de la fenêtre « Lier transferts » du Bilan accepte aussi la saisie, avec « Toutes les catégories » en tête. Une liste de catégories ouverte n'est plus coupée par le bord d'une fenêtre ou du tableau des transactions, dans la fenêtre de répartition comme sur les dernières lignes du tableau, et elle s'ouvre vers le haut quand elle ne tient pas en dessous mais tient au-dessus (#429).
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- Catégories : un nouveau parent est désormais vérifié à l'enregistrement d'une catégorie. L'une de ses propres sous-catégories (la boucle de parenté qui cachait toute une branche), un parent qui créerait un quatrième niveau et un parent qui a des transactions à son nom (devenu parent, il ne serait plus saisissable) sont refusés avec un message, et rien n'est enregistré. Une catégorie enregistrée sans changement de parent n'est jamais refusée par ces vérifications (#393).
|
||||||
|
- Budget : une catégorie dont le parent a été supprimé ou est introuvable ne disparaît plus de la grille Budget ni du rapport Budget vs réel : elle s'affiche au premier niveau, comme dans l'arbre des catégories (#393).
|
||||||
|
|
||||||
## [0.17.2] - 2026-10-18
|
## [0.17.2] - 2026-10-18
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,20 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Categories: a “Check hierarchy” button, between “All keywords” and “Re-initialize”, lists the orphan categories: those caught in a parent loop, which hides them and their subcategories from the tree, the category menus, the budget and the reports, and those whose parent was deleted or cannot be found. Each one can be reattached to the root, chosen by default, or to another parent; it then comes back in its place with its transactions, keywords and budget amounts. The Journal notes each reattachment, without any category name (#393).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the Journal to the bottom of the page. The version history opens on the notes of the installed version, with every other version folded: click a version number to read its notes, or “Expand all” to open the whole history (#372).
|
- Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the Journal to the bottom of the page. The version history opens on the notes of the installed version, with every other version folded: click a version number to read its notes, or “Expand all” to open the whole history (#372).
|
||||||
- Adjustments: the category of an adjustment line now takes typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy and the names of the standard categories in your language. It offers the same categories as the Transactions page, active and allowed for input, as does the split window opened from the Adjustments page; a line already saved on a category hidden from input keeps it, its field empty until you choose another one. The category filter of the “Link transfers” window of the Balance sheet takes typing too, with “Any category” first. An open category list is no longer cut by the edge of a window or of the Transactions table, in the split window as on the last rows of the table, and it opens upward when it does not fit below but fits above (#429).
|
- Adjustments: the category of an adjustment line now takes typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy and the names of the standard categories in your language. It offers the same categories as the Transactions page, active and allowed for input, as does the split window opened from the Adjustments page; a line already saved on a category hidden from input keeps it, its field empty until you choose another one. The category filter of the “Link transfers” window of the Balance sheet takes typing too, with “Any category” first. An open category list is no longer cut by the edge of a window or of the Transactions table, in the split window as on the last rows of the table, and it opens upward when it does not fit below but fits above (#429).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Categories: a new parent is now checked when a category is saved. One of the category's own subcategories (the parent loop that hid a whole branch), a parent that would make a fourth level, and a parent with transactions of its own (once a parent, it would no longer take input) are refused with a message, and nothing is saved. A category saved with its parent unchanged is never refused by these checks (#393).
|
||||||
|
- Budget: a category whose parent was deleted or cannot be found no longer disappears from the Budget grid and the Budget vs Actual report: it now shows at the top level, as in the category tree (#393).
|
||||||
|
|
||||||
## [0.17.2] - 2026-10-18
|
## [0.17.2] - 2026-10-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ simpl-resultat/
|
||||||
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
||||||
│ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript »)
|
│ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript »)
|
||||||
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
|
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
|
||||||
│ ├── utils/ # 19 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage, historique des versions)
|
│ ├── utils/ # 20 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage, historique des versions, hiérarchie des catégories)
|
||||||
│ ├── i18n/ # Config i18next + locales FR/EN
|
│ ├── i18n/ # Config i18next + locales FR/EN
|
||||||
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
|
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
|
||||||
│ ├── App.tsx # Router principal
|
│ ├── App.tsx # Router principal
|
||||||
|
|
@ -196,7 +196,7 @@ Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `prof
|
||||||
|---------|---------------|
|
|---------|---------------|
|
||||||
| `db.ts` | Wrapper de connexion (tauri-plugin-sql), verrou FIFO de tous les accès et `withTransaction` (voir « Accès DB et transactions »). `connectToProfile` appelle `repair_migrations` puis `prepare_profile_db` avant `Database.load`, et écrit au Journal ce que la préparation a changé (`describePreparation`, noms de fichiers seulement, #390) ; un échec est écrit au Journal et le profil s'ouvre quand même |
|
| `db.ts` | Wrapper de connexion (tauri-plugin-sql), verrou FIFO de tous les accès et `withTransaction` (voir « Accès DB et transactions »). `connectToProfile` appelle `repair_migrations` puis `prepare_profile_db` avant `Database.load`, et écrit au Journal ce que la préparation a changé (`describePreparation`, noms de fichiers seulement, #390) ; un échec est écrit au Journal et le profil s'ouvre quand même |
|
||||||
| `profileService.ts` | Gestion des profils |
|
| `profileService.ts` | Gestion des profils |
|
||||||
| `categoryService.ts` | CRUD catégories hiérarchiques |
|
| `categoryService.ts` | CRUD catégories hiérarchiques. `updateCategory` vérifie un nouveau parent et `reattachCategory` rattache une catégorie orpheline, en une transaction par son handle ; `CategoryHierarchyError` typée ; `findOrphanCategories` (diagnostic, requête dédiée sur toutes les catégories) ; `getCategoryDepth` s'arrête sur un cycle — voir « Hiérarchie des catégories » |
|
||||||
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) ; `getTransactionCount` (compte brut de la table, sans filtre — signal des états vides de Transactions et Budget, #351/#353) |
|
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) ; `getTransactionCount` (compte brut de la table, sans filtre — signal des états vides de Transactions et Budget, #351/#353) |
|
||||||
| `importSourceService.ts` | Configuration des sources d'import — lit et écrit le format complet via le codec `importFormat.ts` ([ADR 0019](adr/0019-format-import-persiste.md)) |
|
| `importSourceService.ts` | Configuration des sources d'import — lit et écrit le format complet via le codec `importFormat.ts` ([ADR 0019](adr/0019-format-import-persiste.md)) |
|
||||||
| `importedFileService.ts` | Suivi des fichiers importés |
|
| `importedFileService.ts` | Suivi des fichiers importés |
|
||||||
|
|
@ -233,7 +233,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
|
|
||||||
| Hook | Domaine |
|
| Hook | Domaine |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `useCategories` | Catégories avec hiérarchie |
|
| `useCategories` | Catégories avec hiérarchie. Erreurs typées `CategoriesError` (`{ key }` d'un refus de la hiérarchie, `{ message }` sinon, `categoriesErrorOf`). Fenêtre « Vérifier la hiérarchie » : `hierarchyCheck`, `checkHierarchy`, `reattachOrphan` (une ligne de Journal, puis rechargement de la liste et de l'arbre), `closeHierarchyCheck` (#393) |
|
||||||
| `useTransactions` | Transactions et filtrage. `useTransactions(initialFilters?)` part de filtres de départ posés sur les filtres par défaut et lus au montage seulement (`initTransactionsState`) : l'entrée guidée de l'étape « Classer une transaction à la main » passe `GUIDED_ENTRY_FILTERS` (non catégorisées, sans borne de date). Si sa première page revient vide, le reducer retire le filtre « non catégorisées » une seule fois (`guidedFallback`) et la page relit (#399) |
|
| `useTransactions` | Transactions et filtrage. `useTransactions(initialFilters?)` part de filtres de départ posés sur les filtres par défaut et lus au montage seulement (`initTransactionsState`) : l'entrée guidée de l'étape « Classer une transaction à la main » passe `GUIDED_ENTRY_FILTERS` (non catégorisées, sans borne de date). Si sa première page revient vide, le reducer retire le filtre « non catégorisées » une seule fois (`guidedFallback`) et la page relit (#399) |
|
||||||
| `useDataImport` | Import de données |
|
| `useDataImport` | Import de données |
|
||||||
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) |
|
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) |
|
||||||
|
|
@ -267,6 +267,18 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
- `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler).
|
- `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler).
|
||||||
- L'historique des versions de Paramètres → Système (`ChangelogContent`, #372) n'utilise pas ce hook : c'est une liste plate dont une seule version s'ouvre au départ, choisie par une règle, alors que le hook n'a qu'un `defaultExpanded` global, sans amorce par clé. Son état est un `Set` local, en mémoire, amorcé à chaque lecture du fichier par `initiallyExpandedVersions` (`src/utils/changelog.ts`, pur, avec `parseChangelog`) : la version installée, ou la première version publiée quand la version installée est inconnue ou absente du fichier.
|
- L'historique des versions de Paramètres → Système (`ChangelogContent`, #372) n'utilise pas ce hook : c'est une liste plate dont une seule version s'ouvre au départ, choisie par une règle, alors que le hook n'a qu'un `defaultExpanded` global, sans amorce par clé. Son état est un `Set` local, en mémoire, amorcé à chaque lecture du fichier par `initiallyExpandedVersions` (`src/utils/changelog.ts`, pur, avec `parseChangelog`) : la version installée, ou la première version publiée quand la version installée est inconnue ou absente du fichier.
|
||||||
|
|
||||||
|
## Hiérarchie des catégories (#393)
|
||||||
|
|
||||||
|
Un `parent_id` en boucle (A sous B, B sous A, ou une catégorie sous elle-même) cache la boucle et tout ce qui pend dessous partout où l'on part des racines : arbre, sélecteurs de catégorie, budget, rapports. Les données restent intactes ; seul `parent_id` est en cause.
|
||||||
|
|
||||||
|
- **Module pur unique** — `src/utils/categoryHierarchy.ts` : `depthsOf` (une catégorie prise dans une boucle, ou dessous, n'a **pas** de profondeur), `subtreeHeight` et `descendantsOf` (une seule descente, `levelsBelow`), `fitsUnder` (trois niveaux au plus, `MAX_CATEGORY_DEPTH = 2`, sous-arbre compris), `parentChangeRefusal`, `findOrphans`, `displaysAsRoot`. Chaque remontée et chaque descente garde l'ensemble des catégories vues : une boucle termine le parcours. Les parcours ont trois consommateurs, sans autre implémentation : `buildParentOptions` (liste des parents du formulaire, #423, et de la fenêtre), le diagnostic et la validation du service ; le budget lit `displaysAsRoot`. `computeDepths` de `CategoryCombobox` ne sert qu'à l'indentation et reste à part.
|
||||||
|
- **Diagnostic** — `findOrphanCategories` lit **toutes** les catégories, actives ou non (`SELECT id, parent_id, is_active FROM categories`) : les autres requêtes ne gardent que les actives, ce qui ferait d'un parent désactivé un parent absent. Motifs : `cycle` (le membre de plus petit `id`, une entrée par boucle), `inactive_parent`, `missing_parent` ; seule la tête de chaque groupe est listée, sa descendance la suit.
|
||||||
|
- **Validation** — `updateCategory` (seulement quand le parent change : un renommage d'une catégorie enregistrée avant ces règles n'est jamais refusé) et `reattachCategory` (toujours) passent par `checkNewParent` : `parentChangeRefusal` sur les catégories actives (`self_parent` ; `cycle` pour un parent pris dans la descendance ou sans profondeur ; `too_deep`), puis `parent_has_transactions` pour un parent qui porte des transactions directes (devenu parent, il ne serait plus saisissable). La racine est toujours acceptée : c'est la réparation de dernier recours d'une boucle. Un refus lève `CategoryHierarchyError` (`code`), que `useCategories` reconnaît par `instanceof` et affiche par la clé `categories.hierarchy.errors.<code>`. Lectures et écritures (la ligne, puis `is_inputable = 0` sur le nouveau parent, comme `createCategory`) se font dans **un** `withTransaction`, par son handle. Un parent inactif ou inexistant lève une erreur simple, hors d'atteinte de l'interface.
|
||||||
|
- **`getCategoryDepth`** garde les `id` vus et rend `Infinity` sur une boucle : `createCategory` refuse alors ce parent.
|
||||||
|
- **Fenêtre** — `HierarchyCheckModal` (présentationnelle) : nombre d'orphelines ou « aucune », une ligne par orpheline (nom, motif traduit, `CategoryCombobox` du nouveau parent alimenté par `buildParentOptions` avec l'orpheline en catégorie éditée, « Racine » présélectionnée en `extraOptions`, bouton « Rattacher »), bouton « Fermer ». La liste du combobox sort de la fenêtre par son portail (#429). Après un rattachement, une ligne `logInfo` donne le motif et le nombre de catégories remises en place, sans nom de catégorie, puis la liste et l'arbre se rechargent.
|
||||||
|
- **Budget** — `buildBudgetYearRows` (`useBudget.ts`) et `getBudgetVsActualData` (`budgetService.ts`) placent au premier niveau une catégorie dont le parent n'est pas dans la liste active (`displaysAsRoot`), comme l'arbre et les sélecteurs ; une boucle y reste cachée, la fenêtre la répare.
|
||||||
|
- **Hors portée** — le glisser-déposer : `updateCategorySortOrders` écrit `parent_id` sans cette validation, gardé par l'interface (`moveCategory` ne peut pas déposer une catégorie dans son propre sous-arbre) ; il ne refuse pas non plus un parent avec transactions et ne touche pas `is_inputable`. La restauration d'une sauvegarde (`restoreCategories`) réinsère les `parent_id` tels quels, boucles comprises : la fenêtre répare après coup.
|
||||||
|
|
||||||
## Import CSV — format persisté et détection
|
## Import CSV — format persisté et détection
|
||||||
|
|
||||||
Le chantier #323-#332 a fait du **format d'import une donnée persistée intégralement, jamais re-devinée** — [ADR 0019](adr/0019-format-import-persiste.md) porte la décision, ses trois voies de perte et les alternatives rejetées. Quatre couches, du schéma à l'écran.
|
Le chantier #323-#332 a fait du **format d'import une donnée persistée intégralement, jamais re-devinée** — [ADR 0019](adr/0019-format-import-persiste.md) porte la décision, ses trois voies de perte et les alternatives rejetées. Quatre couches, du schéma à l'écran.
|
||||||
|
|
@ -565,7 +577,7 @@ Les modules payants sont enveloppés dans des **layout-routes pathless `RequireF
|
||||||
| `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
|
| `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
|
||||||
| `/import` | `ImportPage` | Assistant d'import CSV : source → configuration (détection automatique + score) → sélection des fichiers → **aperçu obligatoire** (récap signé, inversion des signes, panneau de dérive) → doublons → confirmation |
|
| `/import` | `ImportPage` | Assistant d'import CSV : source → configuration (détection automatique + score) → sélection des fichiers → **aperçu obligatoire** (récap signé, inversion des signes, panneau de dérive) → doublons → confirmation |
|
||||||
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
||||||
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
| `/categories` | `CategoriesPage` | Gestion hiérarchique ; le bouton « Vérifier la hiérarchie » ouvre `HierarchyCheckModal` (#393) |
|
||||||
| `/adjustments` | `AdjustmentsPage` | Ajustements manuels |
|
| `/adjustments` | `AdjustmentsPage` | Ajustements manuels |
|
||||||
| `/budget` | `BudgetPage` | Planification budgétaire |
|
| `/budget` | `BudgetPage` | Planification budgétaire |
|
||||||
| `/reports` | `ReportsPage` | Hub des rapports : panneau faits saillants + 4 cartes de navigation |
|
| `/reports` | `ReportsPage` | Hub des rapports : panneau faits saillants + 4 cartes de navigation |
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,7 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
|
||||||
- Glisser-déposer pour réordonner les catégories ou changer leur parent
|
- Glisser-déposer pour réordonner les catégories ou changer leur parent
|
||||||
- Basculer les catégories en saisissable ou non-saisissable
|
- Basculer les catégories en saisissable ou non-saisissable
|
||||||
- Vue « Tous les mots-clés » pour voir l'ensemble des règles
|
- Vue « Tous les mots-clés » pour voir l'ensemble des règles
|
||||||
|
- « Vérifier la hiérarchie » pour retrouver les catégories orphelines (prises dans une boucle de parenté, ou dont le parent a été supprimé ou est introuvable) et les rattacher
|
||||||
- Réinitialiser les catégories aux valeurs par défaut
|
- Réinitialiser les catégories aux valeurs par défaut
|
||||||
|
|
||||||
### Comment faire
|
### Comment faire
|
||||||
|
|
@ -285,6 +286,7 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
|
||||||
4. Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation
|
4. Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation
|
||||||
5. Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent
|
5. Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent
|
||||||
6. Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques
|
6. Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques
|
||||||
|
7. Cliquez sur Vérifier la hiérarchie pour lister les catégories orphelines : pour chacune, choisissez un nouveau parent (la racine par défaut), puis cliquez sur Rattacher
|
||||||
|
|
||||||
### Astuces
|
### Astuces
|
||||||
|
|
||||||
|
|
@ -292,6 +294,8 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
|
||||||
- Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction
|
- Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction
|
||||||
- Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation
|
- Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation
|
||||||
- Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions
|
- Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions
|
||||||
|
- Une catégorie disparue de l'arbre, des menus et du budget est souvent prise dans une boucle de parenté : Vérifier la hiérarchie la retrouve, et ses transactions, ses mots-clés et ses montants de budget reviennent avec elle
|
||||||
|
- Une catégorie qui a des transactions à son nom ne peut pas devenir le parent d'une catégorie existante, puisqu'elle ne serait plus saisissable : reclassez d'abord ses transactions, ou choisissez un autre parent
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
123
src/components/categories/HierarchyCheckModal.render.test.tsx
Normal file
123
src/components/categories/HierarchyCheckModal.render.test.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// HierarchyCheckModal — what the « Vérifier la hiérarchie » window shows (#393).
|
||||||
|
// No jsdom: react-dom/server renders in vitest's node environment. A closed
|
||||||
|
// CategoryCombobox renders its input only, without its portal (#429).
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
|
|
||||||
|
import HierarchyCheckModal from "./HierarchyCheckModal";
|
||||||
|
import type { CategoryTreeNode } from "../../shared/types";
|
||||||
|
import type { OrphanCategory } from "../../utils/categoryHierarchy";
|
||||||
|
|
||||||
|
const node = (id: number, parent_id: number | null, name: string, i18n_key: string | null = null): CategoryTreeNode => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
parent_id,
|
||||||
|
color: null,
|
||||||
|
icon: null,
|
||||||
|
type: "expense",
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: true,
|
||||||
|
sort_order: id,
|
||||||
|
keyword_count: 0,
|
||||||
|
i18n_key,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1 is a root; 3 <-> 5 is a cycle; 20's parent is gone.
|
||||||
|
const CATEGORIES = [
|
||||||
|
node(1, null, "Logement", "categoriesSeed.housing"),
|
||||||
|
node(3, 5, "Épicerie"),
|
||||||
|
node(5, 3, "Alimentation"),
|
||||||
|
node(20, 99, "Cadeaux"),
|
||||||
|
];
|
||||||
|
const ORPHANS: OrphanCategory[] = [
|
||||||
|
{ id: 3, reason: "cycle" },
|
||||||
|
{ id: 20, reason: "missing_parent" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const render = (props: { orphans: OrphanCategory[] | null; error?: string | null; isSaving?: boolean }) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<HierarchyCheckModal
|
||||||
|
orphans={props.orphans}
|
||||||
|
error={props.error ?? null}
|
||||||
|
categories={CATEGORIES}
|
||||||
|
isSaving={props.isSaving ?? false}
|
||||||
|
onReattach={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const comboboxes = (html: string) => html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
|
||||||
|
|
||||||
|
describe("HierarchyCheckModal (#393)", () => {
|
||||||
|
it("is a modal dialog named after the button, with its explanation and a close button", () => {
|
||||||
|
const html = render({ orphans: [] });
|
||||||
|
expect(html).toContain('role="dialog"');
|
||||||
|
expect(html).toContain('aria-modal="true"');
|
||||||
|
expect(html).toContain('aria-label="t(categories.hierarchy.check)"');
|
||||||
|
expect(html).toContain("t(categories.hierarchy.intro)");
|
||||||
|
expect(html).toContain("t(common.close)</button>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the check runs while the orphans are not known", () => {
|
||||||
|
const html = render({ orphans: null });
|
||||||
|
expect(html).toContain("t(categories.hierarchy.checking)");
|
||||||
|
expect(html).not.toContain("t(categories.hierarchy.none)");
|
||||||
|
expect(comboboxes(html)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says « aucune » for a sound hierarchy, with no row", () => {
|
||||||
|
const html = render({ orphans: [] });
|
||||||
|
expect(html).toContain("t(categories.hierarchy.none)");
|
||||||
|
expect(html).not.toContain("categories.hierarchy.count");
|
||||||
|
expect(html).not.toContain("t(categories.hierarchy.reattach)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts the orphans, and gives each row its name, its reason, a new parent and « Rattacher »", () => {
|
||||||
|
const html = render({ orphans: ORPHANS });
|
||||||
|
expect(html).toContain("t(categories.hierarchy.count[count=2])");
|
||||||
|
expect(html).toContain(">Épicerie</p>");
|
||||||
|
expect(html).toContain(">Cadeaux</p>");
|
||||||
|
expect(html).toContain("t(categories.hierarchy.reasons.cycle)");
|
||||||
|
expect(html).toContain("t(categories.hierarchy.reasons.missing_parent)");
|
||||||
|
expect(html.split("t(categories.hierarchy.reattach)</button>")).toHaveLength(3);
|
||||||
|
const inputs = comboboxes(html);
|
||||||
|
expect(inputs).toHaveLength(2);
|
||||||
|
expect(inputs[0]).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Épicerie])"');
|
||||||
|
expect(inputs[1]).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Cadeaux])"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preselects the root on every row", () => {
|
||||||
|
for (const input of comboboxes(render({ orphans: ORPHANS }))) {
|
||||||
|
expect(input).toContain('value="t(categories.hierarchy.root)"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a seed category in the app's language", () => {
|
||||||
|
const html = render({ orphans: [{ id: 1, reason: "inactive_parent" }] });
|
||||||
|
expect(html).toContain(">t(categoriesSeed.housing[defaultValue=Logement])</p>");
|
||||||
|
expect(html).toContain("t(categories.hierarchy.reasons.inactive_parent)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the translated error above the list, as an alert", () => {
|
||||||
|
const html = render({ orphans: ORPHANS, error: "Ce parent a des transactions" });
|
||||||
|
expect(html).toContain('role="alert"');
|
||||||
|
expect(html.indexOf("Ce parent a des transactions")).toBeLessThan(html.indexOf("categories.hierarchy.count"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a failed check alone, without the « checking » line", () => {
|
||||||
|
const html = render({ orphans: null, error: "database is locked" });
|
||||||
|
expect(html).toContain("database is locked");
|
||||||
|
expect(html).not.toContain("t(categories.hierarchy.checking)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables « Rattacher » while a reattachment runs", () => {
|
||||||
|
const html = render({ orphans: ORPHANS, isSaving: true });
|
||||||
|
const buttons = html.match(/<button[^>]*>t\(categories\.hierarchy\.reattach\)<\/button>/g) ?? [];
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
for (const button of buttons) expect(button).toContain('disabled=""');
|
||||||
|
expect(render({ orphans: ORPHANS })).not.toContain('disabled=""');
|
||||||
|
});
|
||||||
|
});
|
||||||
161
src/components/categories/HierarchyCheckModal.tsx
Normal file
161
src/components/categories/HierarchyCheckModal.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ListTree } from "lucide-react";
|
||||||
|
import type { CategoryTreeNode } from "../../shared/types";
|
||||||
|
import type { OrphanCategory } from "../../utils/categoryHierarchy";
|
||||||
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
|
import { buildParentOptions } from "./parentOptions";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The orphan categories, one per group to reattach; `null` while the check runs. */
|
||||||
|
orphans: OrphanCategory[] | null;
|
||||||
|
/** The translated text of a refused reattachment, or of a check that failed. */
|
||||||
|
error: string | null;
|
||||||
|
/** The active categories, as the tree has them: names and parent choices. */
|
||||||
|
categories: CategoryTreeNode[];
|
||||||
|
isSaving: boolean;
|
||||||
|
onReattach: (orphan: OrphanCategory, parentId: number | null) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The value of the « Racine » option of each row's combobox. */
|
||||||
|
const ROOT = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Vérifier la hiérarchie » (#393): the orphan categories of the profile, one
|
||||||
|
* row per group to reattach, each with its reason and a new parent. The root
|
||||||
|
* is preselected; the other choices are those of the category form
|
||||||
|
* (`buildParentOptions`, the row's category treated as the one being edited).
|
||||||
|
* The service applies the rules again and may refuse (a parent that holds
|
||||||
|
* transactions, for one): the translated refusal shows above the list.
|
||||||
|
*
|
||||||
|
* The comboboxes open their list in a portal (#429), so the scrolling list of
|
||||||
|
* rows never cuts it.
|
||||||
|
*/
|
||||||
|
export default function HierarchyCheckModal({
|
||||||
|
orphans,
|
||||||
|
error,
|
||||||
|
categories,
|
||||||
|
isSaving,
|
||||||
|
onReattach,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
/** The parent picked on each row, by orphan id; no entry means the root. */
|
||||||
|
const [choices, setChoices] = useState<Record<number, number>>({});
|
||||||
|
|
||||||
|
const optionsByOrphan = useMemo(() => {
|
||||||
|
const byOrphan = new Map<number, CategoryTreeNode[]>();
|
||||||
|
for (const orphan of orphans ?? []) {
|
||||||
|
byOrphan.set(orphan.id, buildParentOptions(categories, { editingId: orphan.id }));
|
||||||
|
}
|
||||||
|
return byOrphan;
|
||||||
|
}, [orphans, categories]);
|
||||||
|
|
||||||
|
const rootOption = useMemo(() => [{ value: ROOT, label: t("categories.hierarchy.root") }], [t]);
|
||||||
|
|
||||||
|
/** The row's parent: the one picked if it is still offered, else the root. */
|
||||||
|
const choiceOf = (orphanId: number): number | null => {
|
||||||
|
const picked = choices[orphanId];
|
||||||
|
if (picked === undefined) return null;
|
||||||
|
return optionsByOrphan.get(orphanId)?.some((c) => c.id === picked) ? picked : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = (orphanId: number, parentId: number | null) => {
|
||||||
|
setChoices((previous) => {
|
||||||
|
const next = { ...previous };
|
||||||
|
if (parentId === null) delete next[orphanId];
|
||||||
|
else next[orphanId] = parentId;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameOf = (id: number): string => {
|
||||||
|
const category = categories.find((c) => c.id === id);
|
||||||
|
if (!category) return "—";
|
||||||
|
return category.i18n_key ? t(category.i18n_key, { defaultValue: category.name }) : category.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("categories.hierarchy.check")}
|
||||||
|
className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 rounded-full bg-[var(--primary)]/10">
|
||||||
|
<ListTree size={20} className="text-[var(--primary)]" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold">{t("categories.hierarchy.check")}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mb-4">{t("categories.hierarchy.intro")}</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orphans === null ? (
|
||||||
|
error ? null : (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">{t("categories.hierarchy.checking")}</p>
|
||||||
|
)
|
||||||
|
) : orphans.length === 0 ? (
|
||||||
|
<p className="text-sm font-medium">{t("categories.hierarchy.none")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium mb-2">{t("categories.hierarchy.count", { count: orphans.length })}</p>
|
||||||
|
<ul className="flex-1 min-h-0 overflow-y-auto divide-y divide-[var(--border)]">
|
||||||
|
{orphans.map((orphan) => {
|
||||||
|
const name = nameOf(orphan.id);
|
||||||
|
const choice = choiceOf(orphan.id);
|
||||||
|
return (
|
||||||
|
<li key={orphan.id} className="py-3 flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-[10rem]">
|
||||||
|
<p className="text-sm font-medium">{name}</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t(`categories.hierarchy.reasons.${orphan.reason}`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-56">
|
||||||
|
<CategoryCombobox
|
||||||
|
categories={optionsByOrphan.get(orphan.id) ?? []}
|
||||||
|
value={choice}
|
||||||
|
onChange={(id) => choose(orphan.id, id)}
|
||||||
|
extraOptions={rootOption}
|
||||||
|
activeExtra={choice === null ? ROOT : null}
|
||||||
|
onExtraSelect={() => choose(orphan.id, null)}
|
||||||
|
ariaLabel={t("categories.hierarchy.newParentFor", { name })}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onReattach(orphan, choice)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("categories.hierarchy.reattach")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{t("common.close")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/components/categories/HierarchyCheckModal.wiring.test.ts
Normal file
137
src/components/categories/HierarchyCheckModal.wiring.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
// HierarchyCheckModal — what a row hands to « Rattacher » (#393).
|
||||||
|
//
|
||||||
|
// A static render clicks nothing. React's hooks come from the shared harness
|
||||||
|
// (src/test/hookHarness.ts, #409): the window runs as a plain function, its
|
||||||
|
// state survives from one call to the next, the test calls the props it hands
|
||||||
|
// to each CategoryCombobox and to its buttons, then renders again.
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
|
|
||||||
|
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
|
||||||
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
|
// Pass-through spy on the pure module, to pin what each row offers.
|
||||||
|
vi.mock("./parentOptions", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./parentOptions")>();
|
||||||
|
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { hookHarness } from "../../test/hookHarness";
|
||||||
|
import HierarchyCheckModal from "./HierarchyCheckModal";
|
||||||
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
|
import { buildParentOptions } from "./parentOptions";
|
||||||
|
import type { CategoryTreeNode } from "../../shared/types";
|
||||||
|
import type { OrphanCategory } from "../../utils/categoryHierarchy";
|
||||||
|
|
||||||
|
const harness = hookHarness();
|
||||||
|
|
||||||
|
type Props = Record<string, unknown> & { children?: ReactNode };
|
||||||
|
|
||||||
|
function find(node: ReactNode, match: (el: ReactElement<Props>) => boolean): ReactElement<Props>[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, match));
|
||||||
|
if (!isValidElement(node)) return [];
|
||||||
|
const el = node as ReactElement<Props>;
|
||||||
|
return [...(match(el) ? [el] : []), ...find(el.props.children, match)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = (id: number, parent_id: number | null): CategoryTreeNode => ({
|
||||||
|
id,
|
||||||
|
name: `c${id}`,
|
||||||
|
parent_id,
|
||||||
|
color: null,
|
||||||
|
icon: null,
|
||||||
|
type: "expense",
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: true,
|
||||||
|
sort_order: id,
|
||||||
|
keyword_count: 0,
|
||||||
|
i18n_key: null,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Roots 1 and 2 (2 has a child, 21); 3 <-> 5 is a cycle; 20's parent is gone.
|
||||||
|
const CATEGORIES = [node(1, null), node(2, null), node(21, 2), node(3, 5), node(5, 3), node(20, 99)];
|
||||||
|
const CYCLE: OrphanCategory = { id: 3, reason: "cycle" };
|
||||||
|
const GONE: OrphanCategory = { id: 20, reason: "missing_parent" };
|
||||||
|
|
||||||
|
const onReattach = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
const view = (orphans: OrphanCategory[] | null = [CYCLE, GONE], categories = CATEGORIES) => {
|
||||||
|
const tree = harness.render(() =>
|
||||||
|
HierarchyCheckModal({ orphans, error: null, categories, isSaving: false, onReattach, onClose }),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
comboboxes: find(tree, (el) => el.type === CategoryCombobox).map((el) => el.props),
|
||||||
|
reattach: find(tree, (el) => el.type === "button" && el.props.children === "t(categories.hierarchy.reattach)"),
|
||||||
|
close: find(tree, (el) => el.type === "button" && el.props.children === "t(common.close)"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ids = (list: unknown) => (list as CategoryTreeNode[]).map((c) => c.id).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
/** Calls the `onClick` the window hands to a button. */
|
||||||
|
const click = (button: ReactElement<Props>) => (button.props.onClick as () => void)();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness.clear();
|
||||||
|
onReattach.mockReset();
|
||||||
|
onClose.mockReset();
|
||||||
|
vi.mocked(buildParentOptions).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HierarchyCheckModal — a row's new parent (#393)", () => {
|
||||||
|
it("offers the category form's parents, the row's category treated as the one edited, the root first", () => {
|
||||||
|
const { comboboxes } = view();
|
||||||
|
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 3 });
|
||||||
|
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 20 });
|
||||||
|
// The cycle member carries the other member (5): roots only, its own cycle left out.
|
||||||
|
// 20, whose parent is gone, shows as a root in the tree, and is offered as one.
|
||||||
|
expect(ids(comboboxes[0].categories)).toEqual([1, 2, 20]);
|
||||||
|
// A leaf: roots and level-1 categories, never a cycle member nor itself.
|
||||||
|
expect(ids(comboboxes[1].categories)).toEqual([1, 2, 21]);
|
||||||
|
for (const props of comboboxes) {
|
||||||
|
expect(props.extraOptions).toEqual([{ value: "", label: "t(categories.hierarchy.root)" }]);
|
||||||
|
expect(props.value).toBeNull();
|
||||||
|
expect(props.activeExtra).toBe("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("« Rattacher » hands the root by default", () => {
|
||||||
|
click(view().reattach[1]);
|
||||||
|
expect(onReattach).toHaveBeenCalledWith(GONE, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the parent picked on the row, and the root again once it is picked back", () => {
|
||||||
|
(view().comboboxes[1].onChange as (id: number) => void)(21);
|
||||||
|
let current = view();
|
||||||
|
expect(current.comboboxes[1].value).toBe(21);
|
||||||
|
expect(current.comboboxes[1].activeExtra).toBeNull();
|
||||||
|
// The other row keeps the root.
|
||||||
|
expect(current.comboboxes[0].value).toBeNull();
|
||||||
|
click(current.reattach[1]);
|
||||||
|
expect(onReattach).toHaveBeenLastCalledWith(GONE, 21);
|
||||||
|
|
||||||
|
(current.comboboxes[1].onExtraSelect as (value: string) => void)("");
|
||||||
|
current = view();
|
||||||
|
expect(current.comboboxes[1].value).toBeNull();
|
||||||
|
expect(current.comboboxes[1].activeExtra).toBe("");
|
||||||
|
click(current.reattach[1]);
|
||||||
|
expect(onReattach).toHaveBeenLastCalledWith(GONE, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the root when the parent picked is no longer offered", () => {
|
||||||
|
(view().comboboxes[1].onChange as (id: number) => void)(21);
|
||||||
|
// After a reload, 21 is gone from the categories.
|
||||||
|
const current = view([CYCLE, GONE], CATEGORIES.filter((c) => c.id !== 21));
|
||||||
|
expect(current.comboboxes[1].value).toBeNull();
|
||||||
|
click(current.reattach[1]);
|
||||||
|
expect(onReattach).toHaveBeenCalledWith(GONE, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("« Fermer » closes the window", () => {
|
||||||
|
const { close } = view();
|
||||||
|
expect(close).toHaveLength(1);
|
||||||
|
click(close[0]);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -97,6 +97,13 @@ describe("buildParentOptions — editing a category", () => {
|
||||||
expect(offered.every((id) => id === 1 || id === 2)).toBe(true);
|
expect(offered.every((id) => id === 1 || id === 2)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never offers a member of a cycle, nor what hangs under it: a child there would be hidden (#393)", () => {
|
||||||
|
// 1 <-> 2 with 4 under 2; 3 and 5 are sound roots. Creating, then editing 5.
|
||||||
|
const cyclic = [node(1, 2), node(2, 1), node(4, 2), node(3, null), node(5, null)];
|
||||||
|
expect(ids(buildParentOptions(cyclic))).toEqual([3, 5]);
|
||||||
|
expect(ids(buildParentOptions(cyclic, { editingId: 5 }))).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
it("terminates when the edited category is itself a member of a cycle", () => {
|
it("terminates when the edited category is itself a member of a cycle", () => {
|
||||||
// 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not
|
// 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not
|
||||||
// unbounded). Only roots remain, so the root outside the cycle is offered.
|
// unbounded). Only roots remain, so the root outside the cycle is offered.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import type { CategoryTreeNode } from "../../shared/types";
|
import type { CategoryTreeNode } from "../../shared/types";
|
||||||
|
import { depthsOf, fitsUnder, subtreeHeight } from "../../utils/categoryHierarchy";
|
||||||
/** Deepest level a category may sit at: root (0), child (1), grandchild (2). */
|
|
||||||
const MAX_PARENT_DEPTH = 1;
|
|
||||||
|
|
||||||
export interface ParentOptionsContext {
|
export interface ParentOptionsContext {
|
||||||
/** Id of the category being edited; absent or null when creating. */
|
/** Id of the category being edited; absent or null when creating. */
|
||||||
|
|
@ -14,60 +12,9 @@ export interface ParentOptionsContext {
|
||||||
currentParentId?: number | null;
|
currentParentId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function depthsOf(categories: CategoryTreeNode[]): Map<number, number> {
|
|
||||||
const byId = new Map<number, CategoryTreeNode>();
|
|
||||||
for (const c of categories) byId.set(c.id, c);
|
|
||||||
const depths = new Map<number, number>();
|
|
||||||
const depthOf = (id: number, seen: Set<number>): number => {
|
|
||||||
const known = depths.get(id);
|
|
||||||
if (known !== undefined) return known;
|
|
||||||
if (seen.has(id)) return 0; // cycle: treat as a root rather than loop
|
|
||||||
seen.add(id);
|
|
||||||
const cat = byId.get(id);
|
|
||||||
const parentId = cat?.parent_id ?? null;
|
|
||||||
// A missing parent (orphan) counts as a root, like the tree and the combobox do.
|
|
||||||
const d = parentId === null || !byId.has(parentId) ? 0 : depthOf(parentId, seen) + 1;
|
|
||||||
depths.set(id, d);
|
|
||||||
return d;
|
|
||||||
};
|
|
||||||
for (const c of categories) depthOf(c.id, new Set());
|
|
||||||
return depths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with
|
* Categories that may be chosen as the parent in the category form, and in the
|
||||||
* grandchildren. Walks `parent_id` links; a cycle is not walked twice, and the
|
* « Vérifier la hiérarchie » window (#393).
|
||||||
* walk can never take more steps than there are categories.
|
|
||||||
*/
|
|
||||||
function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
|
|
||||||
const childrenOf = new Map<number, number[]>();
|
|
||||||
for (const c of categories) {
|
|
||||||
if (c.parent_id === null) continue;
|
|
||||||
const bucket = childrenOf.get(c.parent_id) ?? [];
|
|
||||||
bucket.push(c.id);
|
|
||||||
childrenOf.set(c.parent_id, bucket);
|
|
||||||
}
|
|
||||||
let height = 0;
|
|
||||||
const seen = new Set<number>([rootId]);
|
|
||||||
let frontier = [rootId];
|
|
||||||
while (frontier.length > 0 && height < categories.length) {
|
|
||||||
const next: number[] = [];
|
|
||||||
for (const id of frontier) {
|
|
||||||
for (const child of childrenOf.get(id) ?? []) {
|
|
||||||
if (seen.has(child)) continue;
|
|
||||||
seen.add(child);
|
|
||||||
next.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (next.length === 0) break;
|
|
||||||
height += 1;
|
|
||||||
frontier = next;
|
|
||||||
}
|
|
||||||
return height;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Categories that may be chosen as the parent in the category form.
|
|
||||||
*
|
*
|
||||||
* - Roots and level-1 categories only: a level-2 parent would create a fourth
|
* - Roots and level-1 categories only: a level-2 parent would create a fourth
|
||||||
* level, which nothing else in the app knows how to display.
|
* level, which nothing else in the app knows how to display.
|
||||||
|
|
@ -78,11 +25,15 @@ function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
|
||||||
* whole descent, which would create a cycle that hides the branch everywhere
|
* whole descent, which would create a cycle that hides the branch everywhere
|
||||||
* (#393): a descendant sits at least one level below the edited category,
|
* (#393): a descendant sits at least one level below the edited category,
|
||||||
* deeper than any depth still allowed.
|
* deeper than any depth still allowed.
|
||||||
|
* - A category caught in a `parent_id` cycle, or under one, has no depth and is
|
||||||
|
* never offered: a child placed under it would be hidden with it.
|
||||||
* - The parent currently set on the form is always kept when it exists.
|
* - The parent currently set on the form is always kept when it exists.
|
||||||
*
|
*
|
||||||
* This list is the only guard: `updateCategory` writes any `parent_id` it is
|
* The depth and height rules are those of `src/utils/categoryHierarchy.ts`,
|
||||||
* given, without a depth or cycle check (#393 will add one). The form therefore
|
* which `updateCategory` and `reattachCategory` apply again before writing
|
||||||
* never offers a new parent that the rules above reject.
|
* (#393): the list only spares a refusal at save time. It does not leave out
|
||||||
|
* a parent that holds transactions; the service refuses it with a translated
|
||||||
|
* message.
|
||||||
*
|
*
|
||||||
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
|
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
|
||||||
*/
|
*/
|
||||||
|
|
@ -95,15 +46,13 @@ export function buildParentOptions(
|
||||||
const depths = depthsOf(categories);
|
const depths = depthsOf(categories);
|
||||||
|
|
||||||
const height = editingId !== null ? subtreeHeight(categories, editingId) : 0;
|
const height = editingId !== null ? subtreeHeight(categories, editingId) : 0;
|
||||||
// Negative when the edited category has grandchildren: only the current parent survives.
|
|
||||||
const maxDepth = MAX_PARENT_DEPTH - height;
|
|
||||||
|
|
||||||
return categories.filter((c) => {
|
return categories.filter((c) => {
|
||||||
if (c.id === editingId) return false;
|
if (c.id === editingId) return false;
|
||||||
// Kept even when the depth rule would drop it: the field must show what is
|
// Kept even when the depth rule would drop it: the field must show what is
|
||||||
// stored. Keeping it changes nothing on save (it is already the parent);
|
// stored. Keeping it changes nothing on save (it is already the parent).
|
||||||
// repairing such data is the job of #393.
|
|
||||||
if (c.id === currentParentId) return true;
|
if (c.id === currentParentId) return true;
|
||||||
return (depths.get(c.id) ?? 0) <= maxDepth;
|
const depth = depths.get(c.id);
|
||||||
|
return depth !== undefined && fitsUnder(depth, height);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,19 @@ describe("sortHierarchical", () => {
|
||||||
expect(ordered).toEqual([1, 10, 500]);
|
expect(ordered).toEqual([1, 10, 500]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves out the members of a parent_id cycle and what hangs under it, and ends (#393)", () => {
|
||||||
|
// 3 <-> 5, 8 under 5: no root leads to them, and their parents are in the
|
||||||
|
// list, so they are no orphans of it either. This is why a cycle hides a
|
||||||
|
// category from every picker; « Vérifier la hiérarchie » repairs it.
|
||||||
|
const input: Category[] = [
|
||||||
|
cat(1, "Revenus", null, 1),
|
||||||
|
cat(3, "Épicerie", 5, 1),
|
||||||
|
cat(5, "Alimentation", 3, 1),
|
||||||
|
cat(8, "Boulangerie", 5, 2),
|
||||||
|
];
|
||||||
|
expect(sortHierarchical(input, displayName).map((c) => c.id)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
it("is stable (does not duplicate) when called with already-ordered input", () => {
|
it("is stable (does not duplicate) when called with already-ordered input", () => {
|
||||||
const input: Category[] = [
|
const input: Category[] = [
|
||||||
cat(1, "Revenus", null, 1),
|
cat(1, "Revenus", null, 1),
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,45 @@ describe("buildBudgetYearRows — depth-first emission (issue #424)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildBudgetYearRows — a category whose parent is deleted or gone (#393)", () => {
|
||||||
|
// The budget reads active categories only: a deleted (inactive) parent is
|
||||||
|
// simply absent from the list, like a parent that no longer exists.
|
||||||
|
it("shows a leaf whose parent is absent as a root, with its budget amounts", () => {
|
||||||
|
const rows = buildBudgetYearRows(
|
||||||
|
[...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 99, sort_order: 5 })],
|
||||||
|
[entry(7, 40, 1), entry(7, 60, 2)],
|
||||||
|
[{ category_id: 7, actual: -80 }],
|
||||||
|
);
|
||||||
|
const gifts = rows.find((r) => r.category_id === 7);
|
||||||
|
expect(gifts).toMatchObject({ parent_id: null, depth: 0, is_parent: false, annual: 100, previousYearTotal: -80 });
|
||||||
|
expectDepthFirst(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a parent whose own parent is absent as a root group, its children under it", () => {
|
||||||
|
const rows = buildBudgetYearRows(
|
||||||
|
[
|
||||||
|
...CATEGORIES,
|
||||||
|
cat(7, "Gifts", "expense", { parent_id: 99, is_inputable: false, sort_order: 5 }),
|
||||||
|
cat(8, "Flowers", "expense", { parent_id: 7, sort_order: 1 }),
|
||||||
|
],
|
||||||
|
[entry(8, 25)],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(rows.find((r) => r.category_id === 7)).toMatchObject({ parent_id: null, depth: 0, is_parent: true, annual: 25 });
|
||||||
|
expect(rows.find((r) => r.category_id === 8)).toMatchObject({ parent_id: 7, depth: 1, annual: 25 });
|
||||||
|
expectDepthFirst(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still hides the members of a parent_id cycle, which « Vérifier la hiérarchie » repairs", () => {
|
||||||
|
const rows = buildBudgetYearRows(
|
||||||
|
[...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 8 }), cat(8, "Flowers", "expense", { parent_id: 7 })],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(rows.some((r) => r.category_id === 7 || r.category_id === 8)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => {
|
describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => {
|
||||||
const rows = buildBudgetYearRows(CATEGORIES, [], []);
|
const rows = buildBudgetYearRows(CATEGORIES, [], []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
deleteTemplate as deleteTemplateSvc,
|
deleteTemplate as deleteTemplateSvc,
|
||||||
} from "../services/budgetService";
|
} from "../services/budgetService";
|
||||||
import { getAllImportSources, getTransactionCount } from "../services/transactionService";
|
import { getAllImportSources, getTransactionCount } from "../services/transactionService";
|
||||||
|
import { displaysAsRoot } from "../utils/categoryHierarchy";
|
||||||
import { useReportsPeriod } from "./useReportsPeriod";
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
export interface BudgetState {
|
export interface BudgetState {
|
||||||
|
|
@ -241,8 +242,11 @@ export function buildBudgetYearRows(
|
||||||
return [subtotal, ...gcRows];
|
return [subtotal, ...gcRows];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identify top-level parents and standalone leaves
|
// Identify top-level parents and standalone leaves. A category whose parent
|
||||||
const topLevel = allCategories.filter((c) => !c.parent_id);
|
// is deactivated or gone (not in the active list) stands at the top level,
|
||||||
|
// as in the tree and the pickers, instead of vanishing from the grid (#393).
|
||||||
|
const activeIds = new Set(allCategories.map((c) => c.id));
|
||||||
|
const topLevel = allCategories.filter((c) => displaysAsRoot(c, activeIds));
|
||||||
|
|
||||||
for (const cat of topLevel) {
|
for (const cat of topLevel) {
|
||||||
const children = childrenByParent.get(cat.id) || [];
|
const children = childrenByParent.get(cat.id) || [];
|
||||||
|
|
|
||||||
231
src/hooks/useCategories.test.ts
Normal file
231
src/hooks/useCategories.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
// useCategories — the hierarchy check and its refusals (#393).
|
||||||
|
//
|
||||||
|
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
|
||||||
|
// the hook runs as a plain function, its reducer is applied, `commit()` runs
|
||||||
|
// its mount effect. The service is mocked, except the refusal class and its
|
||||||
|
// i18n key, which the hook must recognize by `instanceof`.
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
|
||||||
|
vi.mock("../services/categoryService", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../services/categoryService")>();
|
||||||
|
return {
|
||||||
|
CategoryHierarchyError: actual.CategoryHierarchyError,
|
||||||
|
categoryHierarchyErrorKey: actual.categoryHierarchyErrorKey,
|
||||||
|
getAllCategoriesWithCounts: vi.fn(),
|
||||||
|
createCategory: vi.fn(),
|
||||||
|
updateCategory: vi.fn(),
|
||||||
|
deactivateCategory: vi.fn(),
|
||||||
|
getCategoryUsageCount: vi.fn(),
|
||||||
|
getChildrenUsageCount: vi.fn(),
|
||||||
|
getKeywordsByCategoryId: vi.fn(async () => []),
|
||||||
|
createKeyword: vi.fn(),
|
||||||
|
updateKeyword: vi.fn(),
|
||||||
|
deactivateKeyword: vi.fn(),
|
||||||
|
reinitializeCategories: vi.fn(),
|
||||||
|
hasDuplicateSortOrders: vi.fn(async () => false),
|
||||||
|
fixDuplicateSortOrders: vi.fn(),
|
||||||
|
getNextSortOrder: vi.fn(),
|
||||||
|
updateCategorySortOrders: vi.fn(),
|
||||||
|
findOrphanCategories: vi.fn(),
|
||||||
|
reattachCategory: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
|
||||||
|
|
||||||
|
import { hookHarness } from "../test/hookHarness";
|
||||||
|
import {
|
||||||
|
useCategories,
|
||||||
|
categoriesErrorOf,
|
||||||
|
categoriesErrorText,
|
||||||
|
reattachLogLine,
|
||||||
|
} from "./useCategories";
|
||||||
|
import {
|
||||||
|
CategoryHierarchyError,
|
||||||
|
findOrphanCategories,
|
||||||
|
getAllCategoriesWithCounts,
|
||||||
|
reattachCategory,
|
||||||
|
updateCategory,
|
||||||
|
} from "../services/categoryService";
|
||||||
|
import { logInfo } from "../services/logService";
|
||||||
|
import { markedT } from "../test/i18nMock";
|
||||||
|
import type { OrphanCategory } from "../utils/categoryHierarchy";
|
||||||
|
|
||||||
|
const harness = hookHarness();
|
||||||
|
|
||||||
|
const ROWS = [
|
||||||
|
{ id: 1, name: "Logement", parent_id: null, color: null, icon: null, type: "expense", is_active: true, is_inputable: false, sort_order: 1, keyword_count: 0, i18n_key: null },
|
||||||
|
{ id: 10, name: "Loyer", parent_id: 1, color: null, icon: null, type: "expense", is_active: true, is_inputable: true, sort_order: 1, keyword_count: 0, i18n_key: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CYCLE: OrphanCategory = { id: 3, reason: "cycle" };
|
||||||
|
|
||||||
|
/** Lets the hook's promises settle. */
|
||||||
|
const flush = async () => {
|
||||||
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mounts the hook and lets its first load settle. */
|
||||||
|
async function mount() {
|
||||||
|
harness.render(useCategories);
|
||||||
|
harness.commit();
|
||||||
|
await flush();
|
||||||
|
return harness.render(useCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORM = { name: "Logement", type: "expense" as const, color: "#4A90A4", parent_id: 10, is_inputable: false, sort_order: 1 };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness.clear();
|
||||||
|
vi.mocked(getAllCategoriesWithCounts).mockReset();
|
||||||
|
vi.mocked(getAllCategoriesWithCounts).mockResolvedValue(ROWS as never);
|
||||||
|
vi.mocked(updateCategory).mockReset();
|
||||||
|
vi.mocked(findOrphanCategories).mockReset();
|
||||||
|
vi.mocked(reattachCategory).mockReset();
|
||||||
|
vi.mocked(logInfo).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("categoriesErrorOf / categoriesErrorText (#393)", () => {
|
||||||
|
it("names a hierarchy refusal by the i18n key of its code, never by its message", () => {
|
||||||
|
const error = categoriesErrorOf(new CategoryHierarchyError("cycle", "raw text"));
|
||||||
|
expect(error).toEqual({ key: "categories.hierarchy.errors.cycle" });
|
||||||
|
expect(categoriesErrorText(error, markedT)).toBe("t(categories.hierarchy.errors.cycle)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the message of any other failure", () => {
|
||||||
|
const error = categoriesErrorOf(new Error("database is locked"));
|
||||||
|
expect(error).toEqual({ message: "database is locked" });
|
||||||
|
expect(categoriesErrorText(error, markedT)).toBe("database is locked");
|
||||||
|
expect(categoriesErrorOf("boom")).toEqual({ message: "boom" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCategories — saving the form (#393)", () => {
|
||||||
|
it("shows a refused parent by its translated code, and keeps the form open", async () => {
|
||||||
|
vi.mocked(updateCategory).mockRejectedValue(new CategoryHierarchyError("cycle"));
|
||||||
|
let api = await mount();
|
||||||
|
await api.selectCategory(1);
|
||||||
|
api = harness.render(useCategories);
|
||||||
|
api.startEditing();
|
||||||
|
api = harness.render(useCategories);
|
||||||
|
await api.saveCategory(FORM);
|
||||||
|
const { state } = harness.render(useCategories);
|
||||||
|
expect(updateCategory).toHaveBeenCalledWith(1, FORM);
|
||||||
|
expect(state.error).toEqual({ key: "categories.hierarchy.errors.cycle" });
|
||||||
|
expect(state.isSaving).toBe(false);
|
||||||
|
expect(state.editingCategory).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows any other failure by its message", async () => {
|
||||||
|
vi.mocked(updateCategory).mockRejectedValue(new Error("database is locked"));
|
||||||
|
let api = await mount();
|
||||||
|
await api.selectCategory(1);
|
||||||
|
api = harness.render(useCategories);
|
||||||
|
await api.saveCategory(FORM);
|
||||||
|
expect(harness.render(useCategories).state.error).toEqual({ message: "database is locked" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCategories — « Vérifier la hiérarchie » (#393)", () => {
|
||||||
|
it("opens the window while the check runs, then lists the orphans", async () => {
|
||||||
|
let resolve: (orphans: OrphanCategory[]) => void = () => undefined;
|
||||||
|
vi.mocked(findOrphanCategories).mockReturnValue(new Promise((r) => (resolve = r)));
|
||||||
|
const api = await mount();
|
||||||
|
expect(api.state.hierarchyCheck).toBeNull();
|
||||||
|
const done = api.checkHierarchy();
|
||||||
|
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: null, error: null });
|
||||||
|
resolve([CYCLE]);
|
||||||
|
await done;
|
||||||
|
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: [CYCLE], error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a check that failed in the window", async () => {
|
||||||
|
vi.mocked(findOrphanCategories).mockRejectedValue(new Error("database is locked"));
|
||||||
|
const api = await mount();
|
||||||
|
await api.checkHierarchy();
|
||||||
|
expect(harness.render(useCategories).state.hierarchyCheck).toEqual({
|
||||||
|
orphans: null,
|
||||||
|
error: { message: "database is locked" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reattaches, writes one Journal line without any name, then reloads the list and the tree", async () => {
|
||||||
|
vi.mocked(findOrphanCategories).mockResolvedValueOnce([CYCLE]).mockResolvedValueOnce([]);
|
||||||
|
vi.mocked(reattachCategory).mockResolvedValue(3);
|
||||||
|
let api = await mount();
|
||||||
|
await api.checkHierarchy();
|
||||||
|
api = harness.render(useCategories);
|
||||||
|
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await api.reattachOrphan(CYCLE, 1);
|
||||||
|
|
||||||
|
expect(reattachCategory).toHaveBeenCalledWith(3, 1);
|
||||||
|
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||||
|
const line = vi.mocked(logInfo).mock.calls[0][0] as string;
|
||||||
|
expect(line).toBe(reattachLogLine("cycle", 3));
|
||||||
|
expect(line).toContain("3 categories");
|
||||||
|
for (const row of ROWS) expect(line).not.toContain(row.name);
|
||||||
|
expect(findOrphanCategories).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2);
|
||||||
|
const { state } = harness.render(useCategories);
|
||||||
|
expect(state.hierarchyCheck).toEqual({ orphans: [], error: null });
|
||||||
|
expect(state.isSaving).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads the tree even when the check after a committed reattachment fails", async () => {
|
||||||
|
vi.mocked(findOrphanCategories)
|
||||||
|
.mockResolvedValueOnce([CYCLE])
|
||||||
|
.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
vi.mocked(reattachCategory).mockResolvedValue(2);
|
||||||
|
let api = await mount();
|
||||||
|
await api.checkHierarchy();
|
||||||
|
api = harness.render(useCategories);
|
||||||
|
await api.reattachOrphan(CYCLE, null);
|
||||||
|
expect(reattachCategory).toHaveBeenCalledWith(3, null);
|
||||||
|
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2);
|
||||||
|
const { state } = harness.render(useCategories);
|
||||||
|
expect(state.hierarchyCheck?.error).toEqual({ message: "database is locked" });
|
||||||
|
expect(state.isSaving).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a refused reattachment in the window by its translated code, with no Journal line", async () => {
|
||||||
|
vi.mocked(findOrphanCategories).mockResolvedValue([CYCLE]);
|
||||||
|
vi.mocked(reattachCategory).mockRejectedValue(new CategoryHierarchyError("parent_has_transactions"));
|
||||||
|
let api = await mount();
|
||||||
|
await api.checkHierarchy();
|
||||||
|
api = harness.render(useCategories);
|
||||||
|
await api.reattachOrphan(CYCLE, 1);
|
||||||
|
const { state } = harness.render(useCategories);
|
||||||
|
expect(state.hierarchyCheck).toEqual({
|
||||||
|
orphans: [CYCLE],
|
||||||
|
error: { key: "categories.hierarchy.errors.parent_has_transactions" },
|
||||||
|
});
|
||||||
|
expect(state.error).toBeNull();
|
||||||
|
expect(state.isSaving).toBe(false);
|
||||||
|
expect(logInfo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the window; a check that ends after it closed changes nothing", async () => {
|
||||||
|
let resolve: (orphans: OrphanCategory[]) => void = () => undefined;
|
||||||
|
vi.mocked(findOrphanCategories).mockReturnValue(new Promise((r) => (resolve = r)));
|
||||||
|
let api = await mount();
|
||||||
|
const done = api.checkHierarchy();
|
||||||
|
api = harness.render(useCategories);
|
||||||
|
api.closeHierarchyCheck();
|
||||||
|
resolve([CYCLE]);
|
||||||
|
await done;
|
||||||
|
expect(harness.render(useCategories).state.hierarchyCheck).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reattachLogLine (#393)", () => {
|
||||||
|
it("gives the reason code and the count, singular or plural", () => {
|
||||||
|
expect(reattachLogLine("inactive_parent", 1)).toBe(
|
||||||
|
"Categories: orphan category reattached (inactive_parent), 1 category back in the hierarchy",
|
||||||
|
);
|
||||||
|
expect(reattachLogLine("cycle", 4)).toBe(
|
||||||
|
"Categories: orphan category reattached (cycle), 4 categories back in the hierarchy",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -20,7 +20,37 @@ import {
|
||||||
fixDuplicateSortOrders,
|
fixDuplicateSortOrders,
|
||||||
getNextSortOrder,
|
getNextSortOrder,
|
||||||
updateCategorySortOrders,
|
updateCategorySortOrders,
|
||||||
|
findOrphanCategories,
|
||||||
|
reattachCategory,
|
||||||
|
CategoryHierarchyError,
|
||||||
|
categoryHierarchyErrorKey,
|
||||||
} from "../services/categoryService";
|
} from "../services/categoryService";
|
||||||
|
import { logInfo } from "../services/logService";
|
||||||
|
import type { OrphanCategory } from "../utils/categoryHierarchy";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a failed action shows: a refusal of the hierarchy rules by the i18n key
|
||||||
|
* of its code (#393), any other failure by its message.
|
||||||
|
*/
|
||||||
|
export type CategoriesError = { key: string } | { message: string };
|
||||||
|
|
||||||
|
export function categoriesErrorOf(e: unknown): CategoriesError {
|
||||||
|
if (e instanceof CategoryHierarchyError) return { key: categoryHierarchyErrorKey(e.code) };
|
||||||
|
return { message: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text of a categories error: the translation of its key, or its message. */
|
||||||
|
export function categoriesErrorText(error: CategoriesError, t: (key: string) => string): string {
|
||||||
|
return "key" in error ? t(error.key) : error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The « Vérifier la hiérarchie » window (#393), while it is open. */
|
||||||
|
export interface HierarchyCheckState {
|
||||||
|
/** The orphan categories, one per group to reattach; `null` while the check runs. */
|
||||||
|
orphans: OrphanCategory[] | null;
|
||||||
|
/** A refused reattachment, or a check that failed. */
|
||||||
|
error: CategoriesError | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoriesState {
|
interface CategoriesState {
|
||||||
categories: CategoryTreeNode[];
|
categories: CategoryTreeNode[];
|
||||||
|
|
@ -29,22 +59,29 @@ interface CategoriesState {
|
||||||
keywords: Keyword[];
|
keywords: Keyword[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
error: string | null;
|
error: CategoriesError | null;
|
||||||
editingCategory: CategoryFormData | null;
|
editingCategory: CategoryFormData | null;
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
|
/** `null` while the window is closed. */
|
||||||
|
hierarchyCheck: HierarchyCheckState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoriesAction =
|
type CategoriesAction =
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
| { type: "SET_SAVING"; payload: boolean }
|
| { type: "SET_SAVING"; payload: boolean }
|
||||||
| { type: "SET_ERROR"; payload: string | null }
|
| { type: "SET_ERROR"; payload: CategoriesError | null }
|
||||||
| { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } }
|
| { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } }
|
||||||
| { type: "SET_TREE"; payload: CategoryTreeNode[] }
|
| { type: "SET_TREE"; payload: CategoryTreeNode[] }
|
||||||
| { type: "SELECT_CATEGORY"; payload: number | null }
|
| { type: "SELECT_CATEGORY"; payload: number | null }
|
||||||
| { type: "SET_KEYWORDS"; payload: Keyword[] }
|
| { type: "SET_KEYWORDS"; payload: Keyword[] }
|
||||||
| { type: "START_CREATING" }
|
| { type: "START_CREATING" }
|
||||||
| { type: "START_EDITING"; payload: CategoryFormData }
|
| { type: "START_EDITING"; payload: CategoryFormData }
|
||||||
| { type: "CANCEL_EDITING" };
|
| { type: "CANCEL_EDITING" }
|
||||||
|
| { type: "OPEN_HIERARCHY_CHECK" }
|
||||||
|
| { type: "SET_ORPHANS"; payload: OrphanCategory[] }
|
||||||
|
| { type: "START_REATTACH" }
|
||||||
|
| { type: "SET_HIERARCHY_ERROR"; payload: CategoriesError }
|
||||||
|
| { type: "CLOSE_HIERARCHY_CHECK" };
|
||||||
|
|
||||||
const initialState: CategoriesState = {
|
const initialState: CategoriesState = {
|
||||||
categories: [],
|
categories: [],
|
||||||
|
|
@ -56,9 +93,16 @@ const initialState: CategoriesState = {
|
||||||
error: null,
|
error: null,
|
||||||
editingCategory: null,
|
editingCategory: null,
|
||||||
isCreating: false,
|
isCreating: false,
|
||||||
|
hierarchyCheck: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
|
/**
|
||||||
|
* The tree of the Categories page: each category under its parent, a category
|
||||||
|
* whose parent is not in the list at the root. A category caught in a
|
||||||
|
* `parent_id` cycle is under a parent and never at the root, so no root leads
|
||||||
|
* to it: « Vérifier la hiérarchie » finds it (#393).
|
||||||
|
*/
|
||||||
|
export function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
|
||||||
const map = new Map<number, CategoryTreeNode>();
|
const map = new Map<number, CategoryTreeNode>();
|
||||||
const roots: CategoryTreeNode[] = [];
|
const roots: CategoryTreeNode[] = [];
|
||||||
|
|
||||||
|
|
@ -119,11 +163,43 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
|
||||||
return { ...state, isCreating: false, editingCategory: action.payload };
|
return { ...state, isCreating: false, editingCategory: action.payload };
|
||||||
case "CANCEL_EDITING":
|
case "CANCEL_EDITING":
|
||||||
return { ...state, editingCategory: null, isCreating: false };
|
return { ...state, editingCategory: null, isCreating: false };
|
||||||
|
case "OPEN_HIERARCHY_CHECK":
|
||||||
|
return { ...state, hierarchyCheck: { orphans: null, error: null } };
|
||||||
|
case "SET_ORPHANS":
|
||||||
|
// A check that ends after the window closed changes nothing.
|
||||||
|
return state.hierarchyCheck
|
||||||
|
? { ...state, hierarchyCheck: { ...state.hierarchyCheck, orphans: action.payload } }
|
||||||
|
: state;
|
||||||
|
case "START_REATTACH":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isSaving: true,
|
||||||
|
hierarchyCheck: state.hierarchyCheck ? { ...state.hierarchyCheck, error: null } : null,
|
||||||
|
};
|
||||||
|
case "SET_HIERARCHY_ERROR":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isSaving: false,
|
||||||
|
hierarchyCheck: state.hierarchyCheck ? { ...state.hierarchyCheck, error: action.payload } : null,
|
||||||
|
};
|
||||||
|
case "CLOSE_HIERARCHY_CHECK":
|
||||||
|
return { ...state, hierarchyCheck: null };
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Journal line of a reattachment (#393): the reason code and the number of
|
||||||
|
* categories put back in place, never a category name.
|
||||||
|
*/
|
||||||
|
export function reattachLogLine(reason: OrphanCategory["reason"], restored: number): string {
|
||||||
|
return (
|
||||||
|
`Categories: orphan category reattached (${reason}), ` +
|
||||||
|
`${restored} ${restored === 1 ? "category" : "categories"} back in the hierarchy`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useCategories() {
|
export function useCategories() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
@ -150,7 +226,7 @@ export function useCategories() {
|
||||||
dispatch({ type: "SET_CATEGORIES", payload: { flat, tree } });
|
dispatch({ type: "SET_CATEGORIES", payload: { flat, tree } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -165,7 +241,7 @@ export function useCategories() {
|
||||||
const kws = await getKeywordsByCategoryId(id);
|
const kws = await getKeywordsByCategoryId(id);
|
||||||
dispatch({ type: "SET_KEYWORDS", payload: kws });
|
dispatch({ type: "SET_KEYWORDS", payload: kws });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -212,7 +288,7 @@ export function useCategories() {
|
||||||
}
|
}
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.isCreating, state.selectedCategoryId, loadCategories, selectCategory]
|
[state.isCreating, state.selectedCategoryId, loadCategories, selectCategory]
|
||||||
|
|
@ -237,7 +313,7 @@ export function useCategories() {
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
return { blocked: false, count: 0 };
|
return { blocked: false, count: 0 };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
||||||
return { blocked: false, count: 0 };
|
return { blocked: false, count: 0 };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -253,7 +329,7 @@ export function useCategories() {
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
||||||
}
|
}
|
||||||
}, [loadCategories]);
|
}, [loadCategories]);
|
||||||
|
|
||||||
|
|
@ -327,12 +403,53 @@ export function useCategories() {
|
||||||
[state.tree, loadCategories]
|
[state.tree, loadCategories]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393). */
|
||||||
|
const checkHierarchy = useCallback(async () => {
|
||||||
|
dispatch({ type: "OPEN_HIERARCHY_CHECK" });
|
||||||
|
try {
|
||||||
|
const orphans = await findOrphanCategories();
|
||||||
|
dispatch({ type: "SET_ORPHANS", payload: orphans });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reattaches an orphan under `parentId`, or to the root with `null` (#393).
|
||||||
|
* On success: one Journal line (counts, no name), then the orphan list and
|
||||||
|
* the tree reload; the tree reloads even if the new check fails, since the
|
||||||
|
* write is committed. A refusal shows in the window, by its translated code.
|
||||||
|
*/
|
||||||
|
const reattachOrphan = useCallback(
|
||||||
|
async (orphan: OrphanCategory, parentId: number | null) => {
|
||||||
|
dispatch({ type: "START_REATTACH" });
|
||||||
|
try {
|
||||||
|
const restored = await reattachCategory(orphan.id, parentId);
|
||||||
|
logInfo(reattachLogLine(orphan.reason, restored));
|
||||||
|
try {
|
||||||
|
const orphans = await findOrphanCategories();
|
||||||
|
dispatch({ type: "SET_ORPHANS", payload: orphans });
|
||||||
|
} finally {
|
||||||
|
await loadCategories();
|
||||||
|
}
|
||||||
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadCategories]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeHierarchyCheck = useCallback(() => {
|
||||||
|
dispatch({ type: "CLOSE_HIERARCHY_CHECK" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadKeywords = useCallback(async (categoryId: number) => {
|
const loadKeywords = useCallback(async (categoryId: number) => {
|
||||||
try {
|
try {
|
||||||
const kws = await getKeywordsByCategoryId(categoryId);
|
const kws = await getKeywordsByCategoryId(categoryId);
|
||||||
dispatch({ type: "SET_KEYWORDS", payload: kws });
|
dispatch({ type: "SET_KEYWORDS", payload: kws });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -344,7 +461,7 @@ export function useCategories() {
|
||||||
await loadKeywords(state.selectedCategoryId);
|
await loadKeywords(state.selectedCategoryId);
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.selectedCategoryId, loadKeywords, loadCategories]
|
[state.selectedCategoryId, loadKeywords, loadCategories]
|
||||||
|
|
@ -358,7 +475,7 @@ export function useCategories() {
|
||||||
await loadKeywords(state.selectedCategoryId);
|
await loadKeywords(state.selectedCategoryId);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.selectedCategoryId, loadKeywords]
|
[state.selectedCategoryId, loadKeywords]
|
||||||
|
|
@ -373,7 +490,7 @@ export function useCategories() {
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state.selectedCategoryId, loadKeywords, loadCategories]
|
[state.selectedCategoryId, loadKeywords, loadCategories]
|
||||||
|
|
@ -392,5 +509,8 @@ export function useCategories() {
|
||||||
removeKeyword,
|
removeKeyword,
|
||||||
reinitializeCategories,
|
reinitializeCategories,
|
||||||
moveCategory,
|
moveCategory,
|
||||||
|
checkHierarchy,
|
||||||
|
reattachOrphan,
|
||||||
|
closeHierarchyCheck,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,28 @@
|
||||||
"customColor": "Custom color",
|
"customColor": "Custom color",
|
||||||
"allKeywords": "All Keywords",
|
"allKeywords": "All Keywords",
|
||||||
"allKeywordsEmpty": "No keywords yet",
|
"allKeywordsEmpty": "No keywords yet",
|
||||||
|
"hierarchy": {
|
||||||
|
"check": "Check hierarchy",
|
||||||
|
"intro": "An orphan category is no longer in its place in the hierarchy: it is caught in a parent loop (a category placed under its own subcategory), which hides it along with its subcategories, or its parent was deleted or cannot be found. Reattach it to the root or to another parent: it comes back in the tree, the category menus, the budget and the reports, with its transactions, keywords and budget amounts.",
|
||||||
|
"checking": "Checking…",
|
||||||
|
"none": "No orphan categories: every category is in its place in the hierarchy.",
|
||||||
|
"count_one": "{{count}} orphan category",
|
||||||
|
"count_other": "{{count}} orphan categories",
|
||||||
|
"reasons": {
|
||||||
|
"cycle": "Caught in a parent loop",
|
||||||
|
"inactive_parent": "Its parent was deleted",
|
||||||
|
"missing_parent": "Its parent cannot be found"
|
||||||
|
},
|
||||||
|
"newParentFor": "New parent for {{name}}",
|
||||||
|
"root": "Root (top level)",
|
||||||
|
"reattach": "Reattach",
|
||||||
|
"errors": {
|
||||||
|
"self_parent": "A category cannot be its own parent.",
|
||||||
|
"cycle": "This parent would create a parent loop: it sits under this category, or inside a loop. Choose another parent.",
|
||||||
|
"too_deep": "With this parent, the category or its subcategories would go beyond three levels. Choose a parent higher in the hierarchy.",
|
||||||
|
"parent_has_transactions": "This parent has transactions of its own: once a parent, it would no longer take input. Choose another parent, or recategorize its transactions first."
|
||||||
|
}
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "How to manage Categories",
|
"title": "How to manage Categories",
|
||||||
"tips": [
|
"tips": [
|
||||||
|
|
@ -1156,6 +1178,7 @@
|
||||||
"Drag-and-drop to reorder categories or change their parent",
|
"Drag-and-drop to reorder categories or change their parent",
|
||||||
"Toggle categories as inputable or non-inputable",
|
"Toggle categories as inputable or non-inputable",
|
||||||
"All Keywords view to see all rules at a glance",
|
"All Keywords view to see all rules at a glance",
|
||||||
|
"“Check hierarchy” to find the orphan categories (caught in a parent loop, or whose parent was deleted or cannot be found) and reattach them",
|
||||||
"Re-initialize categories to defaults"
|
"Re-initialize categories to defaults"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
|
|
@ -1164,13 +1187,16 @@
|
||||||
"Drag and drop a category in the tree to reorder it or move it under a different parent",
|
"Drag and drop a category in the tree to reorder it or move it under a different parent",
|
||||||
"Add keywords that match transaction descriptions for auto-categorization",
|
"Add keywords that match transaction descriptions for auto-categorization",
|
||||||
"Set keyword priority to resolve conflicts when multiple categories match",
|
"Set keyword priority to resolve conflicts when multiple categories match",
|
||||||
"Use the color picker to assign a custom color for charts"
|
"Use the color picker to assign a custom color for charts",
|
||||||
|
"Click Check hierarchy to list the orphan categories: for each one, choose a new parent (the root by default), then click Reattach"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Non-inputable categories are hidden from budget and transaction dropdowns but still visible in reports",
|
"Non-inputable categories are hidden from budget and transaction dropdowns but still visible in reports",
|
||||||
"Higher priority keywords win when multiple categories match the same transaction",
|
"Higher priority keywords win when multiple categories match the same transaction",
|
||||||
"Use the All Keywords view to get a global overview of your categorization rules",
|
"Use the All Keywords view to get a global overview of your categorization rules",
|
||||||
"Use Re-initialize to reset categories to defaults — this will unlink all transaction categories"
|
"Use Re-initialize to reset categories to defaults — this will unlink all transaction categories",
|
||||||
|
"A category missing from the tree, the menus and the budget is often caught in a parent loop: Check hierarchy finds it, and its transactions, keywords and budget amounts come back with it",
|
||||||
|
"A category with transactions of its own cannot become the parent of an existing category, since it would no longer take input: recategorize its transactions first, or choose another parent"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"adjustments": {
|
"adjustments": {
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,28 @@
|
||||||
"customColor": "Couleur personnalisée",
|
"customColor": "Couleur personnalisée",
|
||||||
"allKeywords": "Tous les mots-clés",
|
"allKeywords": "Tous les mots-clés",
|
||||||
"allKeywordsEmpty": "Aucun mot-clé",
|
"allKeywordsEmpty": "Aucun mot-clé",
|
||||||
|
"hierarchy": {
|
||||||
|
"check": "Vérifier la hiérarchie",
|
||||||
|
"intro": "Une catégorie orpheline n'est plus à sa place dans la hiérarchie : elle est prise dans une boucle de parenté (une catégorie placée sous sa propre sous-catégorie), qui la cache avec ses sous-catégories, ou son parent a été supprimé ou est introuvable. Rattachez-la à la racine ou à un autre parent : elle revient dans l'arbre, les menus de catégories, le budget et les rapports, avec ses transactions, ses mots-clés et ses montants de budget.",
|
||||||
|
"checking": "Vérification en cours…",
|
||||||
|
"none": "Aucune catégorie orpheline : chaque catégorie est à sa place dans la hiérarchie.",
|
||||||
|
"count_one": "{{count}} catégorie orpheline",
|
||||||
|
"count_other": "{{count}} catégories orphelines",
|
||||||
|
"reasons": {
|
||||||
|
"cycle": "Prise dans une boucle de parenté",
|
||||||
|
"inactive_parent": "Son parent a été supprimé",
|
||||||
|
"missing_parent": "Son parent est introuvable"
|
||||||
|
},
|
||||||
|
"newParentFor": "Nouveau parent de {{name}}",
|
||||||
|
"root": "Racine (niveau supérieur)",
|
||||||
|
"reattach": "Rattacher",
|
||||||
|
"errors": {
|
||||||
|
"self_parent": "Une catégorie ne peut pas être son propre parent.",
|
||||||
|
"cycle": "Ce parent créerait une boucle de parenté : il se trouve sous cette catégorie, ou dans une boucle. Choisissez un autre parent.",
|
||||||
|
"too_deep": "Avec ce parent, la catégorie ou ses sous-catégories dépasseraient trois niveaux. Choisissez un parent plus haut dans la hiérarchie.",
|
||||||
|
"parent_has_transactions": "Ce parent a des transactions à son nom : devenu parent, il ne serait plus saisissable. Choisissez un autre parent, ou reclassez d'abord ses transactions."
|
||||||
|
}
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Comment gérer les Catégories",
|
"title": "Comment gérer les Catégories",
|
||||||
"tips": [
|
"tips": [
|
||||||
|
|
@ -1156,6 +1178,7 @@
|
||||||
"Glisser-déposer pour réordonner les catégories ou changer leur parent",
|
"Glisser-déposer pour réordonner les catégories ou changer leur parent",
|
||||||
"Basculer les catégories en saisissable ou non-saisissable",
|
"Basculer les catégories en saisissable ou non-saisissable",
|
||||||
"Vue « Tous les mots-clés » pour voir l'ensemble des règles",
|
"Vue « Tous les mots-clés » pour voir l'ensemble des règles",
|
||||||
|
"« Vérifier la hiérarchie » pour retrouver les catégories orphelines (prises dans une boucle de parenté, ou dont le parent a été supprimé ou est introuvable) et les rattacher",
|
||||||
"Réinitialiser les catégories aux valeurs par défaut"
|
"Réinitialiser les catégories aux valeurs par défaut"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
|
|
@ -1164,13 +1187,16 @@
|
||||||
"Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent",
|
"Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent",
|
||||||
"Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation",
|
"Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation",
|
||||||
"Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent",
|
"Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent",
|
||||||
"Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques"
|
"Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques",
|
||||||
|
"Cliquez sur Vérifier la hiérarchie pour lister les catégories orphelines : pour chacune, choisissez un nouveau parent (la racine par défaut), puis cliquez sur Rattacher"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Les catégories non-saisissables sont masquées du budget et des menus déroulants mais restent visibles dans les rapports",
|
"Les catégories non-saisissables sont masquées du budget et des menus déroulants mais restent visibles dans les rapports",
|
||||||
"Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction",
|
"Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction",
|
||||||
"Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation",
|
"Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation",
|
||||||
"Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions"
|
"Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions",
|
||||||
|
"Une catégorie disparue de l'arbre, des menus et du budget est souvent prise dans une boucle de parenté : Vérifier la hiérarchie la retrouve, et ses transactions, ses mots-clés et ses montants de budget reviennent avec elle",
|
||||||
|
"Une catégorie qui a des transactions à son nom ne peut pas devenir le parent d'une catégorie existante, puisqu'elle ne serait plus saisissable : reclassez d'abord ses transactions, ou choisissez un autre parent"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"adjustments": {
|
"adjustments": {
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,16 @@ vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||||
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||||
|
|
||||||
const harness = vi.hoisted(() => ({ onboarding: null as unknown }));
|
const harness = vi.hoisted(() => ({
|
||||||
|
onboarding: null as unknown,
|
||||||
|
/** Overrides of the categories state, for the tests of #393. */
|
||||||
|
state: {} as Record<string, unknown>,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||||
vi.mock("../hooks/useCategories", () => ({
|
vi.mock("../hooks/useCategories", async (importOriginal) => ({
|
||||||
|
// The real error text helper: the page must translate a refusal's key.
|
||||||
|
categoriesErrorText: (await importOriginal<typeof import("../hooks/useCategories")>()).categoriesErrorText,
|
||||||
useCategories: () => ({
|
useCategories: () => ({
|
||||||
state: {
|
state: {
|
||||||
categories: [],
|
categories: [],
|
||||||
|
|
@ -27,6 +33,8 @@ vi.mock("../hooks/useCategories", () => ({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
hierarchyCheck: null,
|
||||||
|
...harness.state,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
@ -88,6 +96,7 @@ const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1]
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
harness.onboarding = path([]);
|
harness.onboarding = path([]);
|
||||||
|
harness.state = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CategoriesPage — the step card (#432)", () => {
|
describe("CategoriesPage — the step card (#432)", () => {
|
||||||
|
|
@ -150,3 +159,53 @@ describe("CategoriesPage — the step card (#432)", () => {
|
||||||
expect(value.skip).not.toHaveBeenCalled();
|
expect(value.skip).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CategoriesPage — « Vérifier la hiérarchie » (#393)", () => {
|
||||||
|
/** The opening tag of the header button whose label closes right after it. */
|
||||||
|
const buttonTag = (html: string, label: string) => {
|
||||||
|
const end = html.indexOf(`${label}</button>`);
|
||||||
|
expect(end).toBeGreaterThan(-1);
|
||||||
|
const start = html.lastIndexOf("<button", end);
|
||||||
|
return html.slice(start, html.indexOf(">", start) + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("sits between « All keywords » and « Reset », styled as « Reset »", () => {
|
||||||
|
const html = render();
|
||||||
|
const allKeywords = html.indexOf("t(categories.allKeywords)</button>");
|
||||||
|
const check = html.indexOf("t(categories.hierarchy.check)</button>");
|
||||||
|
const reset = html.indexOf("t(categories.reinitialize)</button>");
|
||||||
|
expect(allKeywords).toBeLessThan(check);
|
||||||
|
expect(check).toBeLessThan(reset);
|
||||||
|
const classOf = (tag: string) => tag.match(/class="([^"]*)"/)?.[1];
|
||||||
|
expect(classOf(buttonTag(html, "t(categories.hierarchy.check)"))).toBe(
|
||||||
|
classOf(buttonTag(html, "t(categories.reinitialize)")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is disabled while a save runs, as « Reset »", () => {
|
||||||
|
harness.state = { isSaving: true };
|
||||||
|
expect(buttonTag(render(), "t(categories.hierarchy.check)")).toContain('disabled=""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the window only while the check is open", () => {
|
||||||
|
expect(render()).not.toContain('role="dialog"');
|
||||||
|
harness.state = { hierarchyCheck: { orphans: [], error: null } };
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain('role="dialog"');
|
||||||
|
expect(html).toContain("t(categories.hierarchy.none)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("translates a refusal in the window by its key", () => {
|
||||||
|
harness.state = {
|
||||||
|
hierarchyCheck: { orphans: [], error: { key: "categories.hierarchy.errors.too_deep" } },
|
||||||
|
};
|
||||||
|
expect(render()).toContain("t(categories.hierarchy.errors.too_deep)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("translates a refused save of the form by its key, and shows any other failure as is", () => {
|
||||||
|
harness.state = { error: { key: "categories.hierarchy.errors.parent_has_transactions" } };
|
||||||
|
expect(render()).toContain(">t(categories.hierarchy.errors.parent_has_transactions)</div>");
|
||||||
|
harness.state = { error: { message: "database is locked" } };
|
||||||
|
expect(render()).toContain(">database is locked</div>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react";
|
import { Plus, RotateCcw, List, AlertTriangle, ListTree } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import { useCategories } from "../hooks/useCategories";
|
import { useCategories, categoriesErrorText } from "../hooks/useCategories";
|
||||||
import CategoryTree from "../components/categories/CategoryTree";
|
import CategoryTree from "../components/categories/CategoryTree";
|
||||||
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
|
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
|
||||||
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
|
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
|
||||||
|
import HierarchyCheckModal from "../components/categories/HierarchyCheckModal";
|
||||||
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
||||||
import { useOnboarding } from "../contexts/OnboardingContext";
|
import { useOnboarding } from "../contexts/OnboardingContext";
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,6 +36,9 @@ export default function CategoriesPage() {
|
||||||
removeKeyword,
|
removeKeyword,
|
||||||
reinitializeCategories,
|
reinitializeCategories,
|
||||||
moveCategory,
|
moveCategory,
|
||||||
|
checkHierarchy,
|
||||||
|
reattachOrphan,
|
||||||
|
closeHierarchyCheck,
|
||||||
} = useCategories();
|
} = useCategories();
|
||||||
|
|
||||||
// The getting-started card (#432) points at "All keywords" while it is on
|
// The getting-started card (#432) points at "All keywords" while it is on
|
||||||
|
|
@ -79,6 +83,14 @@ export default function CategoriesPage() {
|
||||||
<List size={16} />
|
<List size={16} />
|
||||||
{t("categories.allKeywords")}
|
{t("categories.allKeywords")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={checkHierarchy}
|
||||||
|
disabled={state.isSaving}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ListTree size={16} />
|
||||||
|
{t("categories.hierarchy.check")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowReinitConfirm(true)}
|
onClick={() => setShowReinitConfirm(true)}
|
||||||
disabled={state.isSaving}
|
disabled={state.isSaving}
|
||||||
|
|
@ -99,7 +111,7 @@ export default function CategoriesPage() {
|
||||||
|
|
||||||
{state.error && (
|
{state.error && (
|
||||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
|
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
|
||||||
{state.error}
|
{categoriesErrorText(state.error, t)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -141,6 +153,17 @@ export default function CategoriesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{state.hierarchyCheck && (
|
||||||
|
<HierarchyCheckModal
|
||||||
|
orphans={state.hierarchyCheck.orphans}
|
||||||
|
error={state.hierarchyCheck.error ? categoriesErrorText(state.hierarchyCheck.error, t) : null}
|
||||||
|
categories={state.categories}
|
||||||
|
isSaving={state.isSaving}
|
||||||
|
onReattach={reattachOrphan}
|
||||||
|
onClose={closeHierarchyCheck}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Reinitialize confirmation modal */}
|
{/* Reinitialize confirmation modal */}
|
||||||
{showReinitConfirm && (
|
{showReinitConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,50 @@ describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The categories query keeps active rows only: a deleted (inactive) parent is
|
||||||
|
// absent from the list, like a parent that no longer exists (#393).
|
||||||
|
describe("getBudgetVsActualData — a category whose parent is deleted or gone (#393)", () => {
|
||||||
|
const answer = (categories: unknown[]) =>
|
||||||
|
mockSelect.mockImplementation((sql: string) => {
|
||||||
|
if (sql.includes("FROM categories")) return Promise.resolve(categories);
|
||||||
|
if (sql.includes("FROM budget_entries")) {
|
||||||
|
return Promise.resolve([{ id: 1, category_id: 7, year: 2026, month: 3, amount: 50 }]);
|
||||||
|
}
|
||||||
|
if (sql.includes("FROM transactions")) return Promise.resolve([{ category_id: 7, actual: -30 }]);
|
||||||
|
return Promise.resolve([]);
|
||||||
|
});
|
||||||
|
const category = (id: number, name: string, parent_id: number | null, is_inputable = 1) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type: "expense",
|
||||||
|
parent_id,
|
||||||
|
is_active: 1,
|
||||||
|
is_inputable,
|
||||||
|
sort_order: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows it as a root row, with its budget and its actuals", async () => {
|
||||||
|
answer([category(1, "Logement", null, 0), category(2, "Loyer", 1), category(7, "Cadeaux", 99)]);
|
||||||
|
const rows = await getBudgetVsActualData(2026, 3);
|
||||||
|
expect(rows.find((r) => r.category_id === 7)).toMatchObject({
|
||||||
|
parent_id: null,
|
||||||
|
depth: 0,
|
||||||
|
is_parent: false,
|
||||||
|
monthBudget: -50,
|
||||||
|
monthActual: -30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a parent whose own parent is absent as a root group", async () => {
|
||||||
|
answer([category(6, "Fêtes", 99, 0), category(7, "Cadeaux", 6)]);
|
||||||
|
const rows = await getBudgetVsActualData(2026, 3);
|
||||||
|
expect(rows.map((r) => [r.category_id, r.parent_id, r.depth, r.is_parent])).toEqual([
|
||||||
|
[6, null, 0, true],
|
||||||
|
[7, 6, 1, false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// getActualTotalsForYear is the previous-year actuals reference column
|
// getActualTotalsForYear is the previous-year actuals reference column
|
||||||
// consumed by useBudget (Issue #276) — a thin wrapper over the same
|
// consumed by useBudget (Issue #276) — a thin wrapper over the same
|
||||||
// getActualsByCategoryRange helper, so it gets the same accountIds pass-through.
|
// getActualsByCategoryRange helper, so it gets the same accountIds pass-through.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type Database from "@tauri-apps/plugin-sql";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { notifyDataChanged } from "./dataEvents";
|
import { notifyDataChanged } from "./dataEvents";
|
||||||
import { inPlaceholders } from "../utils/sqlFilters";
|
import { inPlaceholders } from "../utils/sqlFilters";
|
||||||
|
import { displaysAsRoot } from "../utils/categoryHierarchy";
|
||||||
import type {
|
import type {
|
||||||
Category,
|
Category,
|
||||||
BudgetEntry,
|
BudgetEntry,
|
||||||
|
|
@ -409,7 +410,10 @@ export async function getBudgetVsActualData(
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: BudgetVsActualRow[] = [];
|
const rows: BudgetVsActualRow[] = [];
|
||||||
const topLevel = allCategories.filter((c) => !c.parent_id);
|
// A category whose parent is deactivated or gone (not in the active list)
|
||||||
|
// stands at the top level, as in the tree and the pickers (#393).
|
||||||
|
const activeIds = new Set(allCategories.map((c) => c.id));
|
||||||
|
const topLevel = allCategories.filter((c) => displaysAsRoot(c, activeIds));
|
||||||
|
|
||||||
for (const cat of topLevel) {
|
for (const cat of topLevel) {
|
||||||
const children = childrenByParent.get(cat.id) || [];
|
const children = childrenByParent.get(cat.id) || [];
|
||||||
|
|
|
||||||
397
src/services/categoryService.test.ts
Normal file
397
src/services/categoryService.test.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
// categoryService — the hierarchy rules at the write (#393): a new parent is
|
||||||
|
// checked by `updateCategory` and `reattachCategory`, the reads and the writes
|
||||||
|
// run in one transaction through its handle, the orphan diagnostic reads every
|
||||||
|
// category, and `getCategoryDepth` ends on a cycle.
|
||||||
|
//
|
||||||
|
// The base is a fake: a `categories` table in memory, the direct transaction
|
||||||
|
// count of each category, BEGIN/COMMIT/ROLLBACK. `withTransaction` hands the
|
||||||
|
// callback its own handle; the global connection of `getDb()` throws while a
|
||||||
|
// transaction runs, as the real lock would deadlock (docs/architecture.md,
|
||||||
|
// « Accès DB et transactions »).
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const fake = vi.hoisted(() => ({
|
||||||
|
inTransaction: false,
|
||||||
|
global: null as unknown,
|
||||||
|
handle: null as unknown,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./db", () => ({
|
||||||
|
getDb: vi.fn(async () => fake.global),
|
||||||
|
withTransaction: vi.fn(async (fn: (db: unknown) => Promise<unknown>) => {
|
||||||
|
fake.inTransaction = true;
|
||||||
|
try {
|
||||||
|
return await fn(fake.handle);
|
||||||
|
} finally {
|
||||||
|
fake.inTransaction = false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { withTransaction } from "./db";
|
||||||
|
import {
|
||||||
|
CategoryHierarchyError,
|
||||||
|
categoryHierarchyErrorKey,
|
||||||
|
createCategory,
|
||||||
|
findOrphanCategories,
|
||||||
|
getCategoryDepth,
|
||||||
|
reattachCategory,
|
||||||
|
updateCategory,
|
||||||
|
} from "./categoryService";
|
||||||
|
import { buildTree } from "../hooks/useCategories";
|
||||||
|
import type { CategoryTreeNode } from "../shared/types";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
parent_id: number | null;
|
||||||
|
is_active: number;
|
||||||
|
is_inputable: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = (id: number, parent_id: number | null, extra: Partial<Row> = {}): Row => ({
|
||||||
|
id,
|
||||||
|
name: `c${id}`,
|
||||||
|
parent_id,
|
||||||
|
is_active: 1,
|
||||||
|
is_inputable: 1,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const HIERARCHY_SQL = "SELECT id, parent_id, is_active FROM categories";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The categories table and the direct transaction counts. Every statement is
|
||||||
|
* logged with the connection it went through ("handle" or "global").
|
||||||
|
*/
|
||||||
|
function useFakeDb(initial: Row[], transactions: Record<number, number> = {}) {
|
||||||
|
let rows = initial.map((r) => ({ ...r }));
|
||||||
|
let beforeTransaction: Row[] | null = null;
|
||||||
|
const log: Array<{ via: "handle" | "global"; sql: string; params: unknown[] }> = [];
|
||||||
|
|
||||||
|
const select = async (sql: string, params: unknown[] = []) => {
|
||||||
|
if (sql === HIERARCHY_SQL) {
|
||||||
|
return rows.map(({ id, parent_id, is_active }) => ({ id, parent_id, is_active }));
|
||||||
|
}
|
||||||
|
if (sql.includes("COUNT(*) AS cnt FROM transactions WHERE category_id = $1")) {
|
||||||
|
return [{ cnt: transactions[params[0] as number] ?? 0 }];
|
||||||
|
}
|
||||||
|
if (sql.includes("SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1")) {
|
||||||
|
const found = rows.find((r) => r.id === params[0] && r.is_active === 1);
|
||||||
|
return found ? [{ parent_id: found.parent_id }] : [];
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected SELECT: ${sql}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const execute = async (sql: string, params: unknown[] = []) => {
|
||||||
|
if (sql === "BEGIN") {
|
||||||
|
beforeTransaction = rows.map((r) => ({ ...r }));
|
||||||
|
} else if (sql === "COMMIT") {
|
||||||
|
beforeTransaction = null;
|
||||||
|
} else if (sql === "ROLLBACK") {
|
||||||
|
if (beforeTransaction) rows = beforeTransaction;
|
||||||
|
beforeTransaction = null;
|
||||||
|
} else if (sql.startsWith("UPDATE categories SET name = $1")) {
|
||||||
|
const [name, , , parent_id, is_inputable, , id] = params as [string, string, string, number | null, number, number, number];
|
||||||
|
rows = rows.map((r) => (r.id === id ? { ...r, name, parent_id, is_inputable } : r));
|
||||||
|
} else if (sql.startsWith("UPDATE categories SET parent_id = $1 WHERE id = $2")) {
|
||||||
|
const [parent_id, id] = params as [number | null, number];
|
||||||
|
rows = rows.map((r) => (r.id === id ? { ...r, parent_id } : r));
|
||||||
|
} else if (sql.startsWith("UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1")) {
|
||||||
|
rows = rows.map((r) => (r.id === params[0] && r.is_inputable === 1 ? { ...r, is_inputable: 0 } : r));
|
||||||
|
} else if (sql.startsWith("INSERT INTO categories")) {
|
||||||
|
const [name, , , parent_id, is_inputable] = params as [string, string, string, number | null, number];
|
||||||
|
const id = Math.max(0, ...rows.map((r) => r.id)) + 1;
|
||||||
|
rows = [...rows, { id, name, parent_id, is_active: 1, is_inputable }];
|
||||||
|
return { rowsAffected: 1, lastInsertId: id };
|
||||||
|
} else {
|
||||||
|
throw new Error(`unexpected execute: ${sql}`);
|
||||||
|
}
|
||||||
|
return { rowsAffected: 1 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const connection = (via: "handle" | "global") => ({
|
||||||
|
select: vi.fn(async (sql: string, params?: unknown[]) => {
|
||||||
|
if (via === "global" && fake.inTransaction) throw new Error("getDb() inside a transaction");
|
||||||
|
log.push({ via, sql, params: params ?? [] });
|
||||||
|
return select(sql, params);
|
||||||
|
}),
|
||||||
|
execute: vi.fn(async (sql: string, params?: unknown[]) => {
|
||||||
|
if (via === "global" && fake.inTransaction) throw new Error("getDb() inside a transaction");
|
||||||
|
log.push({ via, sql, params: params ?? [] });
|
||||||
|
return execute(sql, params);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
fake.global = connection("global");
|
||||||
|
fake.handle = connection("handle");
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: () => rows,
|
||||||
|
log,
|
||||||
|
/** The statements that wrote, in order (BEGIN/COMMIT/ROLLBACK included). */
|
||||||
|
writes: () => log.filter((l) => !l.sql.startsWith("SELECT")).map((l) => l.sql.split(" WHERE")[0]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = (parent_id: number | null, extra: { name?: string; is_inputable?: boolean } = {}) => ({
|
||||||
|
name: extra.name ?? "Renamed",
|
||||||
|
type: "expense",
|
||||||
|
color: "#4A90A4",
|
||||||
|
parent_id,
|
||||||
|
is_inputable: extra.is_inputable ?? true,
|
||||||
|
sort_order: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** What `updateCategory` or `reattachCategory` rejected with. */
|
||||||
|
async function refusal(promise: Promise<unknown>): Promise<CategoryHierarchyError> {
|
||||||
|
const error = await promise.then(
|
||||||
|
() => null,
|
||||||
|
(e: unknown) => e,
|
||||||
|
);
|
||||||
|
expect(error).toBeInstanceOf(CategoryHierarchyError);
|
||||||
|
return error as CategoryHierarchyError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tree of the Categories page, built from the active rows as the page loads them. */
|
||||||
|
const visibleIds = (rows: Row[]): number[] => {
|
||||||
|
const flat: CategoryTreeNode[] = rows
|
||||||
|
.filter((r) => r.is_active === 1)
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
parent_id: r.parent_id,
|
||||||
|
color: null,
|
||||||
|
icon: null,
|
||||||
|
type: "expense",
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: r.is_inputable === 1,
|
||||||
|
sort_order: r.id,
|
||||||
|
keyword_count: 0,
|
||||||
|
i18n_key: null,
|
||||||
|
children: [],
|
||||||
|
}));
|
||||||
|
const ids: number[] = [];
|
||||||
|
const walk = (nodes: CategoryTreeNode[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
ids.push(node.id);
|
||||||
|
walk(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(buildTree(flat));
|
||||||
|
return ids.sort((a, b) => a - b);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1 -> 10 -> 100, 1 -> 11; 2 -> 20.
|
||||||
|
const TREE = [row(1, null), row(10, 1), row(100, 10), row(11, 1), row(2, null), row(20, 2)];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fake.inTransaction = false;
|
||||||
|
vi.mocked(withTransaction).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateCategory — regression of #393: A -> B -> A", () => {
|
||||||
|
it("refuses to put a category under its own child, writes nothing, rolls back", async () => {
|
||||||
|
// A (1) is a root, B (2) its child. Editing A with parent B closed the
|
||||||
|
// loop in one save and hid both everywhere.
|
||||||
|
const db = useFakeDb([row(1, null), row(2, 1)]);
|
||||||
|
const error = await refusal(updateCategory(1, form(2)));
|
||||||
|
expect(error.code).toBe("cycle");
|
||||||
|
expect(db.rows().find((r) => r.id === 1)?.parent_id).toBeNull();
|
||||||
|
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
|
||||||
|
expect(visibleIds(db.rows())).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the category itself as its parent", async () => {
|
||||||
|
useFakeDb(TREE);
|
||||||
|
expect((await refusal(updateCategory(11, form(11)))).code).toBe("self_parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a grandchild as the parent", async () => {
|
||||||
|
useFakeDb(TREE);
|
||||||
|
expect((await refusal(updateCategory(1, form(100)))).code).toBe("cycle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a fourth level, counting the sub-tree of the category", async () => {
|
||||||
|
const db = useFakeDb(TREE);
|
||||||
|
// Root 2 carries 20: under 10 (level 1), 20 would land at level 3.
|
||||||
|
expect((await refusal(updateCategory(2, form(10)))).code).toBe("too_deep");
|
||||||
|
// A leaf under 100 (level 2) would be level 3.
|
||||||
|
expect((await refusal(updateCategory(20, form(100)))).code).toBe("too_deep");
|
||||||
|
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK", "BEGIN", "ROLLBACK"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a new parent that holds transactions of its own", async () => {
|
||||||
|
const db = useFakeDb(TREE, { 11: 3 });
|
||||||
|
expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_transactions");
|
||||||
|
expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(2);
|
||||||
|
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(1);
|
||||||
|
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a category under a valid parent, which stops taking input, in one transaction through its handle", async () => {
|
||||||
|
const db = useFakeDb(TREE);
|
||||||
|
await updateCategory(20, form(11, { is_inputable: false }));
|
||||||
|
expect(db.writes()).toEqual([
|
||||||
|
"BEGIN",
|
||||||
|
"UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6",
|
||||||
|
"UPDATE categories SET is_inputable = 0",
|
||||||
|
"COMMIT",
|
||||||
|
]);
|
||||||
|
// Every statement, reads included, went through the transaction's handle.
|
||||||
|
expect(db.log.every((l) => l.via === "handle")).toBe(true);
|
||||||
|
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||||
|
const moved = db.rows().find((r) => r.id === 20);
|
||||||
|
expect(moved?.parent_id).toBe(11);
|
||||||
|
expect(moved?.name).toBe("Renamed");
|
||||||
|
// The flag is bound as the integer 0, never the boolean false.
|
||||||
|
const update = db.log.find((l) => l.sql.startsWith("UPDATE categories SET name"));
|
||||||
|
expect(update?.params[4]).toBe(0);
|
||||||
|
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the root, without touching any other category", async () => {
|
||||||
|
const db = useFakeDb(TREE);
|
||||||
|
await updateCategory(10, form(null));
|
||||||
|
expect(db.rows().find((r) => r.id === 10)?.parent_id).toBeNull();
|
||||||
|
expect(db.writes()).toEqual([
|
||||||
|
"BEGIN",
|
||||||
|
"UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6",
|
||||||
|
"COMMIT",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not check an unchanged parent: a rename under a parent with transactions or at a fourth level is saved", async () => {
|
||||||
|
// Stored before the rules: 2 holds transactions and has a child; 1000 sits at level 3.
|
||||||
|
const db = useFakeDb([...TREE, row(1000, 100)], { 2: 5 });
|
||||||
|
await updateCategory(20, form(2, { name: "Rent" }));
|
||||||
|
await updateCategory(1000, form(100, { name: "Deep" }));
|
||||||
|
expect(db.rows().find((r) => r.id === 20)?.name).toBe("Rent");
|
||||||
|
expect(db.rows().find((r) => r.id === 1000)?.name).toBe("Deep");
|
||||||
|
expect(db.log.some((l) => l.sql.includes("FROM transactions"))).toBe(false);
|
||||||
|
expect(db.writes().filter((w) => w === "UPDATE categories SET is_inputable = 0")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a deactivated parent with a plain error, out of reach of the pickers", async () => {
|
||||||
|
const db = useFakeDb([...TREE, row(3, null, { is_active: 0 })]);
|
||||||
|
const error = await updateCategory(20, form(3)).then(
|
||||||
|
() => null,
|
||||||
|
(e: unknown) => e,
|
||||||
|
);
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(error).not.toBeInstanceOf(CategoryHierarchyError);
|
||||||
|
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CategoryHierarchyError", () => {
|
||||||
|
it("carries its code and names the i18n key of its message", () => {
|
||||||
|
const error = new CategoryHierarchyError("too_deep");
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(error.name).toBe("CategoryHierarchyError");
|
||||||
|
expect(error.code).toBe("too_deep");
|
||||||
|
expect(categoryHierarchyErrorKey("parent_has_transactions")).toBe(
|
||||||
|
"categories.hierarchy.errors.parent_has_transactions",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOrphanCategories (#393)", () => {
|
||||||
|
it("reads every category with the dedicated query, inactive ones included", async () => {
|
||||||
|
const db = useFakeDb([row(1, null), row(2, null, { is_active: 0 }), row(20, 2), row(30, 99)]);
|
||||||
|
expect(await findOrphanCategories()).toEqual([
|
||||||
|
{ id: 20, reason: "inactive_parent" },
|
||||||
|
{ id: 30, reason: "missing_parent" },
|
||||||
|
]);
|
||||||
|
expect(db.log.map((l) => l.sql)).toEqual([HIERARCHY_SQL]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reattachCategory on rows already in a cycle (#393)", () => {
|
||||||
|
it("a two-member cycle: found by the diagnostic, broken at the root, back in the tree", async () => {
|
||||||
|
// 5 <-> 3, 8 under 5: none of them is reachable from a root.
|
||||||
|
const db = useFakeDb([row(1, null), row(5, 3), row(3, 5), row(8, 5)]);
|
||||||
|
expect(visibleIds(db.rows())).toEqual([1]);
|
||||||
|
expect(await findOrphanCategories()).toEqual([{ id: 3, reason: "cycle" }]);
|
||||||
|
|
||||||
|
expect(await reattachCategory(3, null)).toBe(3);
|
||||||
|
|
||||||
|
expect(db.rows().find((r) => r.id === 3)?.parent_id).toBeNull();
|
||||||
|
expect(await findOrphanCategories()).toEqual([]);
|
||||||
|
expect(visibleIds(db.rows())).toEqual([1, 3, 5, 8]);
|
||||||
|
expect(db.log.filter((l) => l.via === "global").map((l) => l.sql)).toEqual([HIERARCHY_SQL, HIERARCHY_SQL]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a three-member cycle: too deep under a root, broken at the root, back in the tree", async () => {
|
||||||
|
// 6 -> 4 -> 9 -> 6, nothing under them. Broken at 4, the branch is 4 -> 6 -> 9:
|
||||||
|
// three levels, so it fits at the root and not under root 1.
|
||||||
|
const db = useFakeDb([row(1, null), row(6, 4), row(4, 9), row(9, 6)]);
|
||||||
|
expect(await findOrphanCategories()).toEqual([{ id: 4, reason: "cycle" }]);
|
||||||
|
// Under root 1 the branch (4, 6, 9) would reach level 3: refused, nothing written.
|
||||||
|
expect((await refusal(reattachCategory(4, 1))).code).toBe("too_deep");
|
||||||
|
expect(await findOrphanCategories()).toEqual([{ id: 4, reason: "cycle" }]);
|
||||||
|
|
||||||
|
expect(await reattachCategory(4, null)).toBe(3);
|
||||||
|
expect(await findOrphanCategories()).toEqual([]);
|
||||||
|
expect(visibleIds(db.rows())).toEqual([1, 4, 6, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a cycle member under a sound parent, which stops taking input", async () => {
|
||||||
|
const db = useFakeDb([row(1, null), row(5, 3), row(3, 5)]);
|
||||||
|
expect(await reattachCategory(3, 1)).toBe(2);
|
||||||
|
expect(db.rows().find((r) => r.id === 3)?.parent_id).toBe(1);
|
||||||
|
expect(db.rows().find((r) => r.id === 1)?.is_inputable).toBe(0);
|
||||||
|
expect(db.writes()).toEqual([
|
||||||
|
"BEGIN",
|
||||||
|
"UPDATE categories SET parent_id = $1",
|
||||||
|
"UPDATE categories SET is_inputable = 0",
|
||||||
|
"COMMIT",
|
||||||
|
]);
|
||||||
|
expect(db.log.every((l) => l.via === "handle")).toBe(true);
|
||||||
|
expect(visibleIds(db.rows())).toEqual([1, 3, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the other member of the cycle and a parent with transactions, writing nothing", async () => {
|
||||||
|
const db = useFakeDb([row(1, null), row(5, 3), row(3, 5)], { 1: 2 });
|
||||||
|
expect((await refusal(reattachCategory(3, 5))).code).toBe("cycle");
|
||||||
|
expect((await refusal(reattachCategory(3, 1))).code).toBe("parent_has_transactions");
|
||||||
|
expect(db.rows().find((r) => r.id === 3)?.parent_id).toBe(5);
|
||||||
|
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK", "BEGIN", "ROLLBACK"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reattaches a category whose parent was deleted, counting its active descendants only", async () => {
|
||||||
|
const db = useFakeDb([
|
||||||
|
row(1, null),
|
||||||
|
row(2, null, { is_active: 0 }),
|
||||||
|
row(20, 2),
|
||||||
|
row(21, 20),
|
||||||
|
row(22, 20, { is_active: 0 }),
|
||||||
|
]);
|
||||||
|
expect(await reattachCategory(20, 1)).toBe(2);
|
||||||
|
expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(1);
|
||||||
|
expect(await findOrphanCategories()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCategoryDepth (#393)", () => {
|
||||||
|
it("counts the active ancestors", async () => {
|
||||||
|
useFakeDb(TREE);
|
||||||
|
expect(await getCategoryDepth(1)).toBe(0);
|
||||||
|
expect(await getCategoryDepth(10)).toBe(1);
|
||||||
|
expect(await getCategoryDepth(100)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends on a cycle and on a category that is its own parent: no depth", async () => {
|
||||||
|
useFakeDb([row(5, 3), row(3, 5), row(8, 5), row(7, 7)]);
|
||||||
|
expect(await getCategoryDepth(5)).toBe(Number.POSITIVE_INFINITY);
|
||||||
|
expect(await getCategoryDepth(8)).toBe(Number.POSITIVE_INFINITY);
|
||||||
|
expect(await getCategoryDepth(7)).toBe(Number.POSITIVE_INFINITY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("so createCategory refuses a cycle member as the parent", async () => {
|
||||||
|
const db = useFakeDb([row(5, 3), row(3, 5)]);
|
||||||
|
await expect(
|
||||||
|
createCategory({ name: "New", type: "expense", color: "#000", parent_id: 5, is_inputable: true, sort_order: 1 }),
|
||||||
|
).rejects.toThrow("maximum depth");
|
||||||
|
expect(db.rows()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,112 @@
|
||||||
import { getDb } from "./db";
|
import type Database from "@tauri-apps/plugin-sql";
|
||||||
|
import { getDb, withTransaction } from "./db";
|
||||||
import type { Keyword } from "../shared/types";
|
import type { Keyword } from "../shared/types";
|
||||||
|
import { toSqlFlag } from "../utils/sqlFlag";
|
||||||
|
import {
|
||||||
|
descendantsOf,
|
||||||
|
findOrphans,
|
||||||
|
isActiveRow,
|
||||||
|
parentChangeRefusal,
|
||||||
|
type HierarchyRow,
|
||||||
|
type OrphanCategory,
|
||||||
|
} from "../utils/categoryHierarchy";
|
||||||
|
|
||||||
|
/** Why a new parent is refused (#393); each code has its message under `categories.hierarchy.errors`. */
|
||||||
|
export type CategoryHierarchyErrorCode =
|
||||||
|
| "self_parent"
|
||||||
|
| "cycle"
|
||||||
|
| "too_deep"
|
||||||
|
| "parent_has_transactions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when `updateCategory` or `reattachCategory` refuses a new parent
|
||||||
|
* (#393), before any write. The UI shows the translation of its code
|
||||||
|
* (`categoryHierarchyErrorKey`), never `message`, which is for the tests and
|
||||||
|
* the developer console.
|
||||||
|
*/
|
||||||
|
export class CategoryHierarchyError extends Error {
|
||||||
|
readonly code: CategoryHierarchyErrorCode;
|
||||||
|
constructor(code: CategoryHierarchyErrorCode, message?: string) {
|
||||||
|
super(message ?? `New parent refused by the category hierarchy rules: ${code}`);
|
||||||
|
this.name = "CategoryHierarchyError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The i18n key of a refusal's message. */
|
||||||
|
export function categoryHierarchyErrorKey(code: CategoryHierarchyErrorCode): string {
|
||||||
|
return `categories.hierarchy.errors.${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every category, active or not, with what the hierarchy rules read (#393).
|
||||||
|
* The other category queries keep active rows only, which would turn a
|
||||||
|
* deactivated parent into a missing one.
|
||||||
|
*/
|
||||||
|
const HIERARCHY_ROWS_SQL = "SELECT id, parent_id, is_active FROM categories";
|
||||||
|
|
||||||
|
async function readHierarchyRows(db: Database): Promise<HierarchyRow[]> {
|
||||||
|
return db.select<HierarchyRow[]>(HIERARCHY_ROWS_SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The orphan categories of the profile, one per group to reattach (`findOrphans`, #393). */
|
||||||
|
export async function findOrphanCategories(): Promise<OrphanCategory[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return findOrphans(await readHierarchyRows(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A category that receives a child stops taking input, as in `createCategory`. */
|
||||||
|
async function markAsParent(db: Database, parentId: number): Promise<void> {
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`,
|
||||||
|
[parentId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks moving category `id` under `parentId` (#393), on rows read in the
|
||||||
|
* caller's transaction and through its handle: the hierarchy rules of
|
||||||
|
* `parentChangeRefusal`, then a parent that holds transactions of its own is
|
||||||
|
* refused (once a parent, it would stop taking input). The root is always
|
||||||
|
* accepted. Throws before any write.
|
||||||
|
*/
|
||||||
|
async function checkNewParent(
|
||||||
|
db: Database,
|
||||||
|
rows: HierarchyRow[],
|
||||||
|
id: number,
|
||||||
|
parentId: number | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (parentId === null) return;
|
||||||
|
const active = rows.filter(isActiveRow);
|
||||||
|
// Unreachable from the UI: both pickers list active categories only.
|
||||||
|
if (parentId !== id && !active.some((r) => r.id === parentId)) {
|
||||||
|
throw new Error("Parent category not found or inactive");
|
||||||
|
}
|
||||||
|
const refusal = parentChangeRefusal(active, id, parentId);
|
||||||
|
if (refusal !== null) throw new CategoryHierarchyError(refusal);
|
||||||
|
const used = await db.select<Array<{ cnt: number }>>(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`,
|
||||||
|
[parentId]
|
||||||
|
);
|
||||||
|
if ((used[0]?.cnt ?? 0) > 0) throw new CategoryHierarchyError("parent_has_transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `body` between BEGIN and COMMIT on the transaction handle, ROLLBACK on failure. */
|
||||||
|
async function inTransaction<T>(db: Database, body: () => Promise<T>): Promise<T> {
|
||||||
|
await db.execute("BEGIN");
|
||||||
|
try {
|
||||||
|
const result = await body();
|
||||||
|
await db.execute("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await db.execute("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// Keep the original error: it is the one that explains the failure.
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoryRow {
|
interface CategoryRow {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -27,11 +134,20 @@ export async function getAllCategoriesWithCounts(): Promise<CategoryRow[]> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of active ancestors of a category: 0 for a root. A category caught in
|
||||||
|
* a `parent_id` cycle, or under one, has no depth: the walk keeps the ids it
|
||||||
|
* has seen and returns `Infinity` on the first one seen twice (#393), which
|
||||||
|
* `createCategory` refuses as a parent.
|
||||||
|
*/
|
||||||
export async function getCategoryDepth(categoryId: number): Promise<number> {
|
export async function getCategoryDepth(categoryId: number): Promise<number> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
let currentId: number | null = categoryId;
|
let currentId: number | null = categoryId;
|
||||||
|
const visited = new Set<number>();
|
||||||
while (currentId !== null) {
|
while (currentId !== null) {
|
||||||
|
if (visited.has(currentId)) return Number.POSITIVE_INFINITY;
|
||||||
|
visited.add(currentId);
|
||||||
const parentRows: Array<{ parent_id: number | null }> = await db.select<Array<{ parent_id: number | null }>>(
|
const parentRows: Array<{ parent_id: number | null }> = await db.select<Array<{ parent_id: number | null }>>(
|
||||||
`SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1`,
|
`SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1`,
|
||||||
[currentId]
|
[currentId]
|
||||||
|
|
@ -68,15 +184,22 @@ export async function createCategory(data: {
|
||||||
|
|
||||||
// Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0
|
// Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0
|
||||||
if (data.parent_id !== null) {
|
if (data.parent_id !== null) {
|
||||||
await db.execute(
|
await markAsParent(db, data.parent_id);
|
||||||
`UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`,
|
|
||||||
[data.parent_id]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.lastInsertId as number;
|
return result.lastInsertId as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the category form. A new parent goes through the hierarchy rules
|
||||||
|
* (#393, `checkNewParent`): `CategoryHierarchyError` on a refusal, nothing
|
||||||
|
* written. The reads and the writes (the row, then the new parent's
|
||||||
|
* `is_inputable`) run in one transaction, through its handle.
|
||||||
|
*
|
||||||
|
* An unchanged parent is not checked: a name or a colour saved on a category
|
||||||
|
* stored before these rules (a fourth level, a parent with transactions) must
|
||||||
|
* not be refused.
|
||||||
|
*/
|
||||||
export async function updateCategory(
|
export async function updateCategory(
|
||||||
id: number,
|
id: number,
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -88,10 +211,43 @@ export async function updateCategory(
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = await getDb();
|
const parentId = data.parent_id ?? null;
|
||||||
|
await withTransaction((db) =>
|
||||||
|
inTransaction(db, async () => {
|
||||||
|
const rows = await readHierarchyRows(db);
|
||||||
|
const current = rows.find((r) => r.id === id);
|
||||||
|
if (current === undefined) throw new Error("Category not found");
|
||||||
|
const parentChanged = (current.parent_id ?? null) !== parentId;
|
||||||
|
if (parentChanged) await checkNewParent(db, rows, id, parentId);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
`UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`,
|
`UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`,
|
||||||
[data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order, id]
|
[data.name, data.type, data.color, parentId, toSqlFlag(data.is_inputable), data.sort_order, id]
|
||||||
|
);
|
||||||
|
if (parentChanged && parentId !== null) await markAsParent(db, parentId);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a category under `parentId`, or to the root with `null`: the repair
|
||||||
|
* of the « Vérifier la hiérarchie » window (#393). Same rules as a parent
|
||||||
|
* change in `updateCategory`, always checked, in one transaction through its
|
||||||
|
* handle; only `parent_id` changes, and the new parent's `is_inputable`.
|
||||||
|
*
|
||||||
|
* Returns the number of categories put back in place: the category and every
|
||||||
|
* active category under it (for a member of a cycle, the whole cycle and what
|
||||||
|
* hangs under it).
|
||||||
|
*/
|
||||||
|
export async function reattachCategory(id: number, parentId: number | null): Promise<number> {
|
||||||
|
return withTransaction((db) =>
|
||||||
|
inTransaction(db, async () => {
|
||||||
|
const rows = await readHierarchyRows(db);
|
||||||
|
if (!rows.some((r) => r.id === id)) throw new Error("Category not found");
|
||||||
|
await checkNewParent(db, rows, id, parentId);
|
||||||
|
await db.execute(`UPDATE categories SET parent_id = $1 WHERE id = $2`, [parentId, id]);
|
||||||
|
if (parentId !== null) await markAsParent(db, parentId);
|
||||||
|
return 1 + descendantsOf(rows.filter(isActiveRow), id).size;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,6 +296,13 @@ export async function fixDuplicateSortOrders(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the order and the parent of every category after a drag-and-drop in
|
||||||
|
* the tree. Unlike `updateCategory`, no hierarchy check here (#393, out of its
|
||||||
|
* scope): its only caller, `moveCategory` of `useCategories`, cannot drop a
|
||||||
|
* category inside its own sub-tree (the drop target is not found, nothing is
|
||||||
|
* written).
|
||||||
|
*/
|
||||||
export async function updateCategorySortOrders(
|
export async function updateCategorySortOrders(
|
||||||
updates: Array<{ id: number; sort_order: number; parent_id: number | null }>
|
updates: Array<{ id: number; sort_order: number; parent_id: number | null }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
|
||||||
220
src/utils/categoryHierarchy.test.ts
Normal file
220
src/utils/categoryHierarchy.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
// categoryHierarchy — the rules of the category hierarchy (#393): depths,
|
||||||
|
// heights and descent, the orphan diagnostic and the validation of a new
|
||||||
|
// parent. Every walk must end on a `parent_id` cycle: a test that loops runs
|
||||||
|
// into vitest's timeout and goes red.
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
MAX_CATEGORY_DEPTH,
|
||||||
|
depthsOf,
|
||||||
|
descendantsOf,
|
||||||
|
displaysAsRoot,
|
||||||
|
findOrphans,
|
||||||
|
fitsUnder,
|
||||||
|
isActiveRow,
|
||||||
|
parentChangeRefusal,
|
||||||
|
subtreeHeight,
|
||||||
|
type HierarchyNode,
|
||||||
|
type HierarchyRow,
|
||||||
|
} from "./categoryHierarchy";
|
||||||
|
|
||||||
|
const n = (id: number, parent_id: number | null): HierarchyNode => ({ id, parent_id });
|
||||||
|
/** An active row, or an inactive one with `active = false`. */
|
||||||
|
const r = (id: number, parent_id: number | null, active = true): HierarchyRow => ({
|
||||||
|
id,
|
||||||
|
parent_id,
|
||||||
|
is_active: active ? 1 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two roots. 1 -> 10 -> 100 and 1 -> 11; 2 -> 20.
|
||||||
|
const TREE = [n(1, null), n(10, 1), n(100, 10), n(11, 1), n(2, null), n(20, 2)];
|
||||||
|
|
||||||
|
const depthEntries = (nodes: HierarchyNode[]) =>
|
||||||
|
[...depthsOf(nodes).entries()].sort(([a], [b]) => a - b);
|
||||||
|
|
||||||
|
describe("depthsOf", () => {
|
||||||
|
it("gives 0 to a root, 1 to its child, 2 to a grandchild", () => {
|
||||||
|
expect(depthEntries(TREE)).toEqual([
|
||||||
|
[1, 0],
|
||||||
|
[2, 0],
|
||||||
|
[10, 1],
|
||||||
|
[11, 1],
|
||||||
|
[20, 1],
|
||||||
|
[100, 2],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not depend on the order of the list", () => {
|
||||||
|
expect(depthEntries([...TREE].reverse())).toEqual(depthEntries(TREE));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a category whose parent is not in the list as a root", () => {
|
||||||
|
expect(depthEntries([n(10, 1), n(100, 10)])).toEqual([
|
||||||
|
[10, 0],
|
||||||
|
[100, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives no depth to the members of a cycle nor to what hangs under it, and ends", () => {
|
||||||
|
// 1 <-> 2, 3 under 1, 4 under 3; 5 is a sound root.
|
||||||
|
const depths = depthsOf([n(3, 1), n(1, 2), n(2, 1), n(4, 3), n(5, null)]);
|
||||||
|
expect([...depths.entries()]).toEqual([[5, 0]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives no depth to a category that is its own parent", () => {
|
||||||
|
expect(depthsOf([n(7, 7), n(8, 7)]).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends on a three-member cycle reached from below", () => {
|
||||||
|
const depths = depthsOf([n(9, 3), n(3, 2), n(2, 1), n(1, 3)]);
|
||||||
|
expect(depths.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subtreeHeight and descendantsOf", () => {
|
||||||
|
it("count the levels and list the categories under a category", () => {
|
||||||
|
expect(subtreeHeight(TREE, 1)).toBe(2);
|
||||||
|
expect(subtreeHeight(TREE, 10)).toBe(1);
|
||||||
|
expect(subtreeHeight(TREE, 100)).toBe(0);
|
||||||
|
expect([...descendantsOf(TREE, 1)].sort((a, b) => a - b)).toEqual([10, 11, 100]);
|
||||||
|
expect(descendantsOf(TREE, 100).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("end on a cycle, each category once, the start never listed", () => {
|
||||||
|
// 1 <-> 2, 3 under 2.
|
||||||
|
const cyclic = [n(1, 2), n(2, 1), n(3, 2)];
|
||||||
|
expect(subtreeHeight(cyclic, 1)).toBe(2);
|
||||||
|
expect([...descendantsOf(cyclic, 1)].sort((a, b) => a - b)).toEqual([2, 3]);
|
||||||
|
expect(subtreeHeight([n(7, 7)], 7)).toBe(0);
|
||||||
|
expect(descendantsOf([n(7, 7)], 7).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fitsUnder", () => {
|
||||||
|
it("allows three levels, 0 to 2, counting the sub-tree", () => {
|
||||||
|
expect(MAX_CATEGORY_DEPTH).toBe(2);
|
||||||
|
expect(fitsUnder(0, 0)).toBe(true);
|
||||||
|
expect(fitsUnder(1, 0)).toBe(true);
|
||||||
|
expect(fitsUnder(0, 1)).toBe(true);
|
||||||
|
expect(fitsUnder(2, 0)).toBe(false);
|
||||||
|
expect(fitsUnder(1, 1)).toBe(false);
|
||||||
|
expect(fitsUnder(0, 2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("displaysAsRoot", () => {
|
||||||
|
const ids = new Set([1, 2, 10]);
|
||||||
|
it("is a root without a parent, or with a parent missing from the list", () => {
|
||||||
|
expect(displaysAsRoot({ id: 1, parent_id: null }, ids)).toBe(true);
|
||||||
|
expect(displaysAsRoot({ id: 1 }, ids)).toBe(true);
|
||||||
|
expect(displaysAsRoot({ id: 30, parent_id: 99 }, ids)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not a root under a parent of the list, a cycle member included", () => {
|
||||||
|
expect(displaysAsRoot({ id: 10, parent_id: 1 }, ids)).toBe(false);
|
||||||
|
expect(displaysAsRoot({ id: 1, parent_id: 2 }, ids)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isActiveRow", () => {
|
||||||
|
it("reads 1 (or true) as active, anything else as inactive, as `is_active = 1` does", () => {
|
||||||
|
expect(isActiveRow(r(1, null))).toBe(true);
|
||||||
|
expect(isActiveRow({ id: 1, parent_id: null, is_active: true })).toBe(true);
|
||||||
|
expect(isActiveRow(r(1, null, false))).toBe(false);
|
||||||
|
expect(isActiveRow({ id: 1, parent_id: null, is_active: false })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOrphans (#393)", () => {
|
||||||
|
it("finds nothing in a sound tree", () => {
|
||||||
|
expect(findOrphans(TREE.map((c) => r(c.id, c.parent_id ?? null)))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a category that is its own parent", () => {
|
||||||
|
expect(findOrphans([r(1, null), r(7, 7), r(8, 7)])).toEqual([{ id: 7, reason: "cycle" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a two-member cycle once, by its smallest id, not what hangs under it", () => {
|
||||||
|
// 5 <-> 3, 8 under 5, 9 under 8. Read from below first.
|
||||||
|
const rows = [r(9, 8), r(8, 5), r(5, 3), r(3, 5), r(1, null)];
|
||||||
|
expect(findOrphans(rows)).toEqual([{ id: 3, reason: "cycle" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a three-member cycle once, by its smallest id, whatever the order of the rows", () => {
|
||||||
|
// 6 -> 4 -> 9 -> 6, 12 under 9.
|
||||||
|
const rows = [r(6, 4), r(4, 9), r(9, 6), r(12, 9), r(1, null)];
|
||||||
|
expect(findOrphans(rows)).toEqual([{ id: 4, reason: "cycle" }]);
|
||||||
|
expect(findOrphans([...rows].reverse())).toEqual([{ id: 4, reason: "cycle" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a deactivated parent apart from a missing one, and only the head of each group", () => {
|
||||||
|
// 20's parent (2) is inactive; 21 under 20 follows it. 30's parent (99) does not exist.
|
||||||
|
const rows = [r(1, null), r(2, null, false), r(21, 20), r(20, 2), r(30, 99), r(31, 30)];
|
||||||
|
expect(findOrphans(rows)).toEqual([
|
||||||
|
{ id: 20, reason: "inactive_parent" },
|
||||||
|
{ id: 30, reason: "missing_parent" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lists an inactive category, nor a cycle among inactive ones", () => {
|
||||||
|
const rows = [r(1, null), r(2, 3, false), r(3, 2, false), r(4, 4, false)];
|
||||||
|
expect(findOrphans(rows)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a loop that runs through a deactivated category as the deactivated parent", () => {
|
||||||
|
// 5 -> 6 (inactive) -> 5: the tree shows 5 at the root, its parent being inactive.
|
||||||
|
expect(findOrphans([r(5, 6), r(6, 5, false)])).toEqual([{ id: 5, reason: "inactive_parent" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists several groups, sorted by id", () => {
|
||||||
|
const rows = [r(40, 41), r(41, 40), r(15, 15), r(2, 77), r(1, null)];
|
||||||
|
expect(findOrphans(rows)).toEqual([
|
||||||
|
{ id: 2, reason: "missing_parent" },
|
||||||
|
{ id: 15, reason: "cycle" },
|
||||||
|
{ id: 40, reason: "cycle" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parentChangeRefusal (#393)", () => {
|
||||||
|
it("accepts the root, always, even for a branch three levels deep or a cycle member", () => {
|
||||||
|
expect(parentChangeRefusal(TREE, 1, null)).toBeNull();
|
||||||
|
expect(parentChangeRefusal([n(1, 2), n(2, 1)], 1, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the category itself", () => {
|
||||||
|
expect(parentChangeRefusal(TREE, 11, 11)).toBe("self_parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a child and a grandchild: the move would close a loop", () => {
|
||||||
|
expect(parentChangeRefusal(TREE, 1, 10)).toBe("cycle");
|
||||||
|
expect(parentChangeRefusal(TREE, 1, 100)).toBe("cycle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a parent caught in another cycle: the category would be hidden with it", () => {
|
||||||
|
const nodes = [...TREE, n(50, 51), n(51, 50)];
|
||||||
|
expect(parentChangeRefusal(nodes, 20, 50)).toBe("cycle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a fourth level, counting the sub-tree of the category", () => {
|
||||||
|
// 100 is level 2: a child under it would be level 3.
|
||||||
|
expect(parentChangeRefusal(TREE, 20, 100)).toBe("too_deep");
|
||||||
|
// Root 2 has a child (20): under 10 (level 1), 20 would land at level 3.
|
||||||
|
expect(parentChangeRefusal(TREE, 2, 10)).toBe("too_deep");
|
||||||
|
// Root 1 has grandchildren: under any parent, 100 would land at level 3.
|
||||||
|
expect(parentChangeRefusal(TREE, 1, 2)).toBe("too_deep");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid move", () => {
|
||||||
|
expect(parentChangeRefusal(TREE, 20, 10)).toBeNull(); // a leaf under a level-1 parent
|
||||||
|
expect(parentChangeRefusal(TREE, 2, 1)).toBeNull(); // a root with one level under a root
|
||||||
|
expect(parentChangeRefusal(TREE, 11, 2)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a member of a cycle under a sound parent when the branch fits, and ends", () => {
|
||||||
|
// 1 <-> 2 with nothing under them; 3 is a sound root.
|
||||||
|
const nodes = [n(1, 2), n(2, 1), n(3, null)];
|
||||||
|
expect(parentChangeRefusal(nodes, 1, 3)).toBeNull();
|
||||||
|
// ... but never under the other member.
|
||||||
|
expect(parentChangeRefusal(nodes, 1, 2)).toBe("cycle");
|
||||||
|
});
|
||||||
|
});
|
||||||
237
src/utils/categoryHierarchy.ts
Normal file
237
src/utils/categoryHierarchy.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
// categoryHierarchy — the rules of the category hierarchy, in one place (#393).
|
||||||
|
//
|
||||||
|
// Three consumers read them: the parent list of the category form
|
||||||
|
// (`buildParentOptions`, #423), the orphan diagnostic of the Categories page
|
||||||
|
// (`findOrphans`) and the validation of a parent change in `updateCategory`
|
||||||
|
// and `reattachCategory` (`parentChangeRefusal`). Every walk, up a parent chain
|
||||||
|
// or down the children, keeps a set of the categories it has seen: a
|
||||||
|
// `parent_id` cycle stored in the profile ends the walk instead of looping.
|
||||||
|
//
|
||||||
|
// Levels: a root sits at depth 0, its children at 1, their children at 2.
|
||||||
|
// Three levels at most: the budget grid stops at the grandchildren, so a
|
||||||
|
// fourth level would drop out of it.
|
||||||
|
|
||||||
|
/** What the hierarchy rules read of a category. */
|
||||||
|
export interface HierarchyNode {
|
||||||
|
id: number;
|
||||||
|
/** `null` (or absent) for a root. */
|
||||||
|
parent_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of `SELECT id, parent_id, is_active FROM categories`, active or not. */
|
||||||
|
export interface HierarchyRow extends HierarchyNode {
|
||||||
|
/** `1` for an active category (SQLite's INTEGER flag). */
|
||||||
|
is_active: number | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deepest depth a category may sit at: three levels, 0 to 2. */
|
||||||
|
export const MAX_CATEGORY_DEPTH = 2;
|
||||||
|
|
||||||
|
/** Whether the row is active, as the queries' `WHERE is_active = 1` reads it. */
|
||||||
|
export function isActiveRow(row: HierarchyRow): boolean {
|
||||||
|
return row.is_active === 1 || row.is_active === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the category shows as a root among the categories `ids`: it has no
|
||||||
|
* parent, or a parent that is not in the list (deactivated, or gone). The
|
||||||
|
* tree, the category pickers and the budget all follow this rule (#393).
|
||||||
|
*/
|
||||||
|
export function displaysAsRoot(category: HierarchyNode, ids: ReadonlySet<number>): boolean {
|
||||||
|
return category.parent_id == null || !ids.has(category.parent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depth of each category among `categories`: 0 for a root, 1 for its child, 2
|
||||||
|
* for a grandchild. A category whose parent is not in the list counts as a
|
||||||
|
* root, as in the tree. A category caught in a `parent_id` cycle, or under
|
||||||
|
* one, has no depth: no root leads to it, and it is left out of the map.
|
||||||
|
*/
|
||||||
|
export function depthsOf(categories: readonly HierarchyNode[]): Map<number, number> {
|
||||||
|
const byId = new Map<number, HierarchyNode>();
|
||||||
|
for (const c of categories) byId.set(c.id, c);
|
||||||
|
const depths = new Map<number, number>();
|
||||||
|
/** Categories whose depth is known, or known to be none. */
|
||||||
|
const settled = new Set<number>();
|
||||||
|
|
||||||
|
for (const start of categories) {
|
||||||
|
if (settled.has(start.id)) continue;
|
||||||
|
// Walk up until a root, a settled category, or a category already seen on
|
||||||
|
// this walk: a cycle.
|
||||||
|
const path: number[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
let node = start;
|
||||||
|
let topDepth: number | undefined;
|
||||||
|
for (;;) {
|
||||||
|
path.push(node.id);
|
||||||
|
seen.add(node.id);
|
||||||
|
const parent = node.parent_id == null ? undefined : byId.get(node.parent_id);
|
||||||
|
if (parent === undefined) {
|
||||||
|
topDepth = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (settled.has(parent.id)) {
|
||||||
|
const above = depths.get(parent.id);
|
||||||
|
topDepth = above === undefined ? undefined : above + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (seen.has(parent.id)) {
|
||||||
|
topDepth = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
node = parent;
|
||||||
|
}
|
||||||
|
// The last category of the path sits at `topDepth`, each one before it a
|
||||||
|
// level deeper; none has a depth when the walk ended in a cycle.
|
||||||
|
for (let i = path.length - 1, below = 0; i >= 0; i -= 1, below += 1) {
|
||||||
|
settled.add(path[i]);
|
||||||
|
if (topDepth !== undefined) depths.set(path[i], topDepth + below);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return depths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The categories under `rootId`, level by level. Each category is taken once,
|
||||||
|
* so a cycle ends the walk, and `rootId` itself is never listed.
|
||||||
|
*/
|
||||||
|
function levelsBelow(categories: readonly HierarchyNode[], rootId: number): number[][] {
|
||||||
|
const childrenOf = new Map<number, number[]>();
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parent_id == null) continue;
|
||||||
|
const bucket = childrenOf.get(c.parent_id) ?? [];
|
||||||
|
bucket.push(c.id);
|
||||||
|
childrenOf.set(c.parent_id, bucket);
|
||||||
|
}
|
||||||
|
const levels: number[][] = [];
|
||||||
|
const seen = new Set<number>([rootId]);
|
||||||
|
let frontier = [rootId];
|
||||||
|
for (;;) {
|
||||||
|
const next: number[] = [];
|
||||||
|
for (const id of frontier) {
|
||||||
|
for (const child of childrenOf.get(id) ?? []) {
|
||||||
|
if (seen.has(child)) continue;
|
||||||
|
seen.add(child);
|
||||||
|
next.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next.length === 0) return levels;
|
||||||
|
levels.push(next);
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with grandchildren. */
|
||||||
|
export function subtreeHeight(categories: readonly HierarchyNode[], rootId: number): number {
|
||||||
|
return levelsBelow(categories, rootId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every category under `rootId`, at any depth; never `rootId` itself, even inside a cycle. */
|
||||||
|
export function descendantsOf(categories: readonly HierarchyNode[], rootId: number): Set<number> {
|
||||||
|
return new Set(levelsBelow(categories, rootId).flat());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a category whose sub-tree is `height` levels deep may sit under a
|
||||||
|
* parent at `parentDepth`: its deepest descendant must stay within the three
|
||||||
|
* levels.
|
||||||
|
*/
|
||||||
|
export function fitsUnder(parentDepth: number, height: number): boolean {
|
||||||
|
return parentDepth + 1 + height <= MAX_CATEGORY_DEPTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a parent change is refused (the service adds `parent_has_transactions`). */
|
||||||
|
export type HierarchyRefusal = "self_parent" | "cycle" | "too_deep";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why category `id` may not move under `newParentId`, or `null` when it may.
|
||||||
|
* `active` are the active categories: depth and descent are counted on them,
|
||||||
|
* as the tree shows them. The caller has checked that `newParentId` is one of
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* - The root (`null`) is always accepted: no place is shallower, and it is the
|
||||||
|
* repair of last resort for a category caught in a cycle.
|
||||||
|
* - `self_parent`: the category itself.
|
||||||
|
* - `cycle`: a category under it (the move would close a loop), or a category
|
||||||
|
* with no depth, caught in a cycle or under one (the move would hide it).
|
||||||
|
* - `too_deep`: its deepest descendant would land beyond the third level.
|
||||||
|
*/
|
||||||
|
export function parentChangeRefusal(
|
||||||
|
active: readonly HierarchyNode[],
|
||||||
|
id: number,
|
||||||
|
newParentId: number | null,
|
||||||
|
): HierarchyRefusal | null {
|
||||||
|
if (newParentId === null) return null;
|
||||||
|
if (newParentId === id) return "self_parent";
|
||||||
|
if (descendantsOf(active, id).has(newParentId)) return "cycle";
|
||||||
|
const parentDepth = depthsOf(active).get(newParentId);
|
||||||
|
if (parentDepth === undefined) return "cycle";
|
||||||
|
if (!fitsUnder(parentDepth, subtreeHeight(active, id))) return "too_deep";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why an active category is out of the hierarchy (#393). */
|
||||||
|
export type OrphanReason = "cycle" | "inactive_parent" | "missing_parent";
|
||||||
|
|
||||||
|
export interface OrphanCategory {
|
||||||
|
/** The category to reattach; the categories under it follow it. */
|
||||||
|
id: number;
|
||||||
|
reason: OrphanReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active categories the tree cannot place, one per group to reattach
|
||||||
|
* (#393), sorted by id:
|
||||||
|
* - `inactive_parent`, `missing_parent`: an active category whose parent is
|
||||||
|
* deactivated, or gone. The tree shows it as a root, out of its place.
|
||||||
|
* - `cycle`: for each `parent_id` cycle among active categories, its member
|
||||||
|
* with the smallest id. The cycle and everything under it are out of reach
|
||||||
|
* from the roots: hidden from the tree, the pickers, the budget and the
|
||||||
|
* reports. Reattaching that member breaks the cycle; the other members and
|
||||||
|
* their descendants come back under it.
|
||||||
|
* Only the head of each group is listed: a category under it follows it.
|
||||||
|
*
|
||||||
|
* `rows` are all the categories, inactive ones included: without them, a
|
||||||
|
* deactivated parent could not be told from a missing one.
|
||||||
|
*/
|
||||||
|
export function findOrphans(rows: readonly HierarchyRow[]): OrphanCategory[] {
|
||||||
|
const byId = new Map<number, HierarchyRow>();
|
||||||
|
for (const r of rows) byId.set(r.id, r);
|
||||||
|
const orphans: OrphanCategory[] = [];
|
||||||
|
/** Active categories whose group is known: visible, an orphan, or under one. */
|
||||||
|
const settled = new Set<number>();
|
||||||
|
|
||||||
|
for (const start of rows) {
|
||||||
|
if (!isActiveRow(start) || settled.has(start.id)) continue;
|
||||||
|
// Walk up the active parents until a root, a parent that is inactive or
|
||||||
|
// gone, a settled category, or a category already seen on this walk.
|
||||||
|
const path: number[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
let node = start;
|
||||||
|
for (;;) {
|
||||||
|
path.push(node.id);
|
||||||
|
seen.add(node.id);
|
||||||
|
if (node.parent_id == null) break;
|
||||||
|
const parent = byId.get(node.parent_id);
|
||||||
|
if (parent === undefined) {
|
||||||
|
orphans.push({ id: node.id, reason: "missing_parent" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!isActiveRow(parent)) {
|
||||||
|
orphans.push({ id: node.id, reason: "inactive_parent" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (settled.has(parent.id)) break;
|
||||||
|
if (seen.has(parent.id)) {
|
||||||
|
// The cycle runs from the parent to the end of the path; what comes
|
||||||
|
// before it on the path hangs under the cycle.
|
||||||
|
const members = path.slice(path.indexOf(parent.id));
|
||||||
|
orphans.push({ id: Math.min(...members), reason: "cycle" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
node = parent;
|
||||||
|
}
|
||||||
|
for (const id of path) settled.add(id);
|
||||||
|
}
|
||||||
|
return orphans.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue