diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 11952bd..c8f8c3a 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -6,6 +6,7 @@ - Un bandeau de démarrage en haut de chaque page tant que le parcours de démarrage est affiché : « Démarrage 3/6 », l'étape suivante avec un lien vers la page où la faire, « Voir la liste », qui déplie les six étapes en place, et « Masquer », qui masque le bandeau et la liste pour le profil courant. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste. Une fois toutes les étapes faites, il l'annonce et propose « Ouvrir le guide », qui mène à la section « Premiers pas » du guide d'utilisation, et « Fermer », qui le retire jusqu'à un réaffichage depuis Paramètres → Utilisateurs (#397). - Une carte de démarrage sur la page Import, à la place du bandeau, tant que l'étape suivante du parcours s'y fait (choisir un dossier d'import, configurer une source d'import, importer un premier fichier). Elle indique « Étape n sur N », dit quoi faire à chaque écran de l'assistant d'import et désigne le contrôle à utiliser par un contour qui pulse trois fois, et pas du tout quand le système demande de réduire les animations. Elle suit l'assistant pas à pas, annonce « Fait. » une fois le fichier importé et propose « Étape suivante », qui mène à la page de l'étape d'après ; elle n'annonce pas « Fait. » pour un import qui n'a ajouté aucune transaction. Arriver sur la page Import par un lien « Étape suivante » place le focus du clavier sur le titre de la carte. « Passer cette étape » marque l'étape comme passée dans la liste de démarrage, où « Reprendre » l'annule (#398). +- Une carte de démarrage sur la page Transactions, à la place du bandeau, tant que l'étape suivante du parcours est de classer une transaction à la main. Le lien de cette étape ouvre la page sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. La carte dit quoi faire et désigne la catégorie de la première transaction affichée. Choisir une catégorie coche l'étape : la carte annonce « Fait. » et propose « Saisir un premier budget », ou la fin du parcours en édition Gratuite (#399). ### Modifié diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7b449..ec24ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - A getting-started banner at the top of every page while the getting-started path is shown: "Getting started 3/6", the next step with a link to the page where it is done, "Show the list", which unfolds the six steps in place, and "Hide", which hides the banner and the list for the current profile. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list. Once every step is done, it says so and offers "Open the guide", which leads to the "Getting Started" section of the user guide, and "Close", which removes it until it is shown again from Settings → Users (#397). - A getting-started card on the Import page, in place of the banner, while the next step of the path is done there (choose an import folder, set up an import source, import a first file). It shows "Step n of N", says what to do on each screen of the import wizard and points at the control to use with an outline that pulses three times, not at all when the system asks for reduced motion. It follows the wizard as you go, says "Done." once the file is imported and offers "Next step", which leads to the page of the following step; it does not say "Done." for an import that added no transaction. Arriving on the Import page by a "Next step" link puts the keyboard focus on the card's title. "Skip this step" marks the step as skipped in the getting-started list, where "Resume" takes it back (#398). +- A getting-started card on the Transactions page, in place of the banner, while the next step of the path is to categorize a transaction by hand. The link of that step opens the page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized. The card says what to do and points at the category of the first transaction shown. Choosing a category ticks the step: the card says "Done." and offers "Enter a first budget", or the end of the path in the Free edition (#399). ### Changed diff --git a/docs/architecture.md b/docs/architecture.md index d2d0ef2..fe19751 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -221,7 +221,7 @@ Chaque hook encapsule la logique d'état via `useReducer` : | Hook | Domaine | |------|---------| | `useCategories` | Catégories avec hiérarchie | -| `useTransactions` | Transactions et filtrage | +| `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 | | `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) | | `useImportHistory` | Historique des imports | @@ -534,6 +534,7 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti - **Langue de fallback** : Anglais (`en`) - **Fichiers** : `src/i18n/locales/fr.json`, `src/i18n/locales/en.json` - **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.) +- **Formateur `lowercase`** (`src/i18n/config.ts`) : `{{valeur, lowercase}}` met en minuscules, selon la langue, un libellé interpolé dans une phrase depuis sa propre clé (le filtre « Non catégorisées » dans la carte de démarrage de la page Transactions, #399). La phrase décide de la casse, pas le code. ## CI/CD diff --git a/src/components/layout/AppShell.render.test.tsx b/src/components/layout/AppShell.render.test.tsx index e486f3f..c458582 100644 --- a/src/components/layout/AppShell.render.test.tsx +++ b/src/components/layout/AppShell.render.test.tsx @@ -150,10 +150,7 @@ describe("AppShell — the getting-started banner (#397)", () => { } }); - it.each([ - ["/transactions", IMPORTED], - ["/budget", BUDGET_LEFT], - ] as const)( + it.each([["/budget", BUDGET_LEFT]] as const)( "keeps the banner on %s, the page of the next step, while no step card is delivered", (pathname, snapshot) => { expect(CARD_PAGES.has(pathname)).toBe(false); @@ -176,6 +173,17 @@ describe("AppShell — the getting-started banner (#397)", () => { expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain('href="/import"'); }); + it("gives /transactions, the page of the next step, its step card instead of the banner (#399)", () => { + expect(CARD_PAGES.has("/transactions")).toBe(true); + const value = pathAt("/transactions", loaded(IMPORTED)); + expect(value.nextStep?.to).toBe("/transactions"); + expect(value.mode).toBe("card"); + expect(value.cardPage).toBe("/transactions"); + expect(renderWith("/transactions", value)).not.toContain(BANNER); + // Anywhere else, the same profile keeps the banner and its link to /transactions. + expect(renderAt("/reports", loaded(IMPORTED))).toContain('href="/transactions"'); + }); + it("shows nothing on a page that carries a step card", () => { const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" }; expect(renderWith("/import", value)).not.toContain(BANNER); diff --git a/src/components/transactions/TransactionTable.render.test.tsx b/src/components/transactions/TransactionTable.render.test.tsx new file mode 100644 index 0000000..d36696b --- /dev/null +++ b/src/components/transactions/TransactionTable.render.test.tsx @@ -0,0 +1,108 @@ +// TransactionTable — the getting-started target (#399). No jsdom: +// react-dom/server renders in vitest's node environment, and the markup says +// which element carries the highlight: a wrapper around the category of the +// first row shown, CategoryCombobox itself left as it is. +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: Record) => + o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k, + i18n: { language: "fr" }, + }), +})); + +import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable"; +import type { Category, TransactionRow } from "../../shared/types"; + +const HIGHLIGHT = 'data-onboarding-highlight="true"'; +const count = (html: string, needle: string) => html.split(needle).length - 1; + +const GROCERIES: Category = { + id: 7, + name: "Épicerie", + type: "expense", + is_active: true, + is_inputable: true, + sort_order: 1, + created_at: "", +}; + +const row = (id: number, description: string, category: Category | null = null): TransactionRow => ({ + id, + date: "2026-09-12", + description, + amount: -42.5, + category_id: category?.id ?? null, + category_name: category?.name ?? null, + category_color: null, + source_name: "Exemple Desjardins", + notes: null, + is_manually_categorized: false, + is_split: false, +}); + +/** The guided entry: uncategorized rows only. */ +const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")]; + +const markup = (rows: TransactionRow[], onboardingTarget?: boolean) => + renderToStaticMarkup( + undefined)} + onLoadSplitChildren={vi.fn(async () => [])} + onSaveSplit={vi.fn(async () => undefined)} + onDeleteSplit={vi.fn(async () => undefined)} + onboardingTarget={onboardingTarget} + />, + ); + +/** The rows of the table's body, in order. */ +const bodyRows = (html: string) => html.split(" + new RegExp( + `
` + + `
]*role="combobox"[^>]*value="${value}"[^>]*/>
`, + ); + +describe("TransactionTable — getting-started target (#399)", () => { + it("highlights exactly one element: a wrapper around the first row's category", () => { + const html = markup(UNCATEGORIZED, true); + expect(count(html, HIGHLIGHT)).toBe(1); + const [first, ...others] = bodyRows(html); + expect(first).toContain("IGA 123"); + expect(first).toMatch(ringedCategory("transactions.table.noCategory")); + expect(others).toHaveLength(2); + for (const other of others) { + expect(other).not.toContain("data-onboarding"); + // Every row has the same wrapper, without the ring. + expect(other).toContain('
{ + const html = markup([row(1, "IGA 123", GROCERIES), row(2, "METRO 456")], true); + expect(count(html, HIGHLIGHT)).toBe(1); + const [first] = bodyRows(html); + expect(first).toMatch(new RegExp(`${ringedCategory("Épicerie").source} { + expect(markup(UNCATEGORIZED)).not.toContain("data-onboarding"); + expect(markup(UNCATEGORIZED, false)).not.toContain("data-onboarding"); + }); + + it("highlights nothing without a row: the card stays, without a ring", () => { + const html = markup([], true); + expect(html).toContain("transactions.noTransactions"); + expect(html).not.toContain("data-onboarding"); + }); +}); diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx index 74b1263..a51d965 100644 --- a/src/components/transactions/TransactionTable.tsx +++ b/src/components/transactions/TransactionTable.tsx @@ -8,6 +8,7 @@ import type { SplitChild, } from "../../shared/types"; import type { LinkedTransferTooltipRow } from "../../services/balance.service"; +import { onboardingTargetAttributes } from "../../utils/onboardingPath"; import CategoryCombobox from "../shared/CategoryCombobox"; import SplitAdjustmentModal from "./SplitAdjustmentModal"; @@ -31,8 +32,16 @@ interface TransactionTableProps { * to avoid an N+1 hit on the table render. */ linkedTransfersByTxId?: Map; + /** + * Highlight the category of the first row for the getting-started card + * (#399). The ring sits on a wrapper: `CategoryCombobox` stays as it is. + */ + onboardingTarget?: boolean; } +/** `data-onboarding-target` of the highlighted category. */ +export const TRANSACTION_CATEGORY_TARGET = "transaction-category"; + function SortIcon({ column, sort, @@ -62,6 +71,7 @@ export default function TransactionTable({ onDeleteSplit, onRowContextMenu, linkedTransfersByTxId, + onboardingTarget, }: TransactionTableProps) { const { t } = useTranslation(); const [expandedId, setExpandedId] = useState(null); @@ -144,7 +154,7 @@ export default function TransactionTable({ - {rows.map((row) => ( + {rows.map((row, index) => ( onRowContextMenu(e, row) : undefined} @@ -189,16 +199,29 @@ export default function TransactionTable({
- onCategoryChange(row.id, id)} - placeholder={t("transactions.table.noCategory")} - compact - extraOptions={noCategoryExtra} - activeExtra={row.category_id === null ? "" : null} - onExtraSelect={() => onCategoryChange(row.id, null)} - /> + {/* + Every row has the wrapper and only the first one's carries + the ring (#399): the ring coming or going never remounts + the combobox, so it never drops the focus. + */} +
+ onCategoryChange(row.id, id)} + placeholder={t("transactions.table.noCategory")} + compact + extraOptions={noCategoryExtra} + activeExtra={row.category_id === null ? "" : null} + onExtraSelect={() => onCategoryChange(row.id, null)} + /> +
{row.category_id !== null && ( <>