feat(onboarding): Transactions page step card and guided entry #405
15 changed files with 731 additions and 30 deletions
|
|
@ -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).
|
- 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 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é
|
### Modifié
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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 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
|
### Changed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,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 |
|
||||||
| `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 |
|
| `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) |
|
| `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 |
|
| `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`)
|
- **Langue de fallback** : Anglais (`en`)
|
||||||
- **Fichiers** : `src/i18n/locales/fr.json`, `src/i18n/locales/en.json`
|
- **Fichiers** : `src/i18n/locales/fr.json`, `src/i18n/locales/en.json`
|
||||||
- **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.)
|
- **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
|
## CI/CD
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,10 +150,7 @@ describe("AppShell — the getting-started banner (#397)", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([["/budget", BUDGET_LEFT]] as const)(
|
||||||
["/transactions", IMPORTED],
|
|
||||||
["/budget", BUDGET_LEFT],
|
|
||||||
] as const)(
|
|
||||||
"keeps the banner on %s, the page of the next step, while no step card is delivered",
|
"keeps the banner on %s, the page of the next step, while no step card is delivered",
|
||||||
(pathname, snapshot) => {
|
(pathname, snapshot) => {
|
||||||
expect(CARD_PAGES.has(pathname)).toBe(false);
|
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"');
|
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", () => {
|
it("shows nothing on a page that carries a step card", () => {
|
||||||
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
|
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
|
||||||
expect(renderWith("/import", value)).not.toContain(BANNER);
|
expect(renderWith("/import", value)).not.toContain(BANNER);
|
||||||
|
|
|
||||||
108
src/components/transactions/TransactionTable.render.test.tsx
Normal file
108
src/components/transactions/TransactionTable.render.test.tsx
Normal file
|
|
@ -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<string, unknown>) =>
|
||||||
|
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(
|
||||||
|
<TransactionTable
|
||||||
|
rows={rows}
|
||||||
|
sort={{ column: "date", direction: "desc" }}
|
||||||
|
categories={[GROCERIES]}
|
||||||
|
onSort={vi.fn()}
|
||||||
|
onCategoryChange={vi.fn()}
|
||||||
|
onNotesChange={vi.fn()}
|
||||||
|
onAddKeyword={vi.fn(async () => 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("<tbody")[1].split("<tr").slice(1);
|
||||||
|
|
||||||
|
/** The highlighted wrapper around a closed combobox, whose input shows `value`. */
|
||||||
|
const ringedCategory = (value: string) =>
|
||||||
|
new RegExp(
|
||||||
|
`<div class="rounded-lg" data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}>` +
|
||||||
|
`<div class="relative"><input [^>]*role="combobox"[^>]*value="${value}"[^>]*/></div></div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
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('<div class="rounded-lg"><div class="relative"><input');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rings the category only: a categorized first row keeps its keyword and split buttons outside", () => {
|
||||||
|
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}<button`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights nothing without the property", () => {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
SplitChild,
|
SplitChild,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
|
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
|
||||||
|
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
||||||
|
|
||||||
|
|
@ -31,8 +32,16 @@ interface TransactionTableProps {
|
||||||
* to avoid an N+1 hit on the table render.
|
* to avoid an N+1 hit on the table render.
|
||||||
*/
|
*/
|
||||||
linkedTransfersByTxId?: Map<number, LinkedTransferTooltipRow[]>;
|
linkedTransfersByTxId?: Map<number, LinkedTransferTooltipRow[]>;
|
||||||
|
/**
|
||||||
|
* 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({
|
function SortIcon({
|
||||||
column,
|
column,
|
||||||
sort,
|
sort,
|
||||||
|
|
@ -62,6 +71,7 @@ export default function TransactionTable({
|
||||||
onDeleteSplit,
|
onDeleteSplit,
|
||||||
onRowContextMenu,
|
onRowContextMenu,
|
||||||
linkedTransfersByTxId,
|
linkedTransfersByTxId,
|
||||||
|
onboardingTarget,
|
||||||
}: TransactionTableProps) {
|
}: TransactionTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
|
@ -144,7 +154,7 @@ export default function TransactionTable({
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--border)]">
|
<tbody className="divide-y divide-[var(--border)]">
|
||||||
{rows.map((row) => (
|
{rows.map((row, index) => (
|
||||||
<Fragment key={row.id}>
|
<Fragment key={row.id}>
|
||||||
<tr
|
<tr
|
||||||
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
|
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
|
||||||
|
|
@ -189,6 +199,18 @@ export default function TransactionTable({
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{/*
|
||||||
|
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.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
className="rounded-lg"
|
||||||
|
{...onboardingTargetAttributes(
|
||||||
|
TRANSACTION_CATEGORY_TARGET,
|
||||||
|
onboardingTarget && index === 0
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CategoryCombobox
|
<CategoryCombobox
|
||||||
categories={categories}
|
categories={categories}
|
||||||
value={row.category_id}
|
value={row.category_id}
|
||||||
|
|
@ -199,6 +221,7 @@ export default function TransactionTable({
|
||||||
activeExtra={row.category_id === null ? "" : null}
|
activeExtra={row.category_id === null ? "" : null}
|
||||||
onExtraSelect={() => onCategoryChange(row.id, null)}
|
onExtraSelect={() => onCategoryChange(row.id, null)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{row.category_id !== null && (
|
{row.category_id !== null && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// useTransactions — reducer unit tests (issue #353).
|
// useTransactions — reducer unit tests (issues #353, #399).
|
||||||
//
|
//
|
||||||
// The project has no jsdom / renderHook harness, so the EXPORTED reducer and
|
// The project has no jsdom / renderHook harness, so the EXPORTED reducer and
|
||||||
// its initial state are exercised directly (same approach as
|
// its initial state are exercised directly (same approach as
|
||||||
|
|
@ -6,14 +6,19 @@
|
||||||
// profile-wide transaction count (`totalInDb`) is orthogonal to the paging
|
// profile-wide transaction count (`totalInDb`) is orthogonal to the paging
|
||||||
// state — setting it never touches `filters`, `sort` or `page` — and the
|
// state — setting it never touches `filters`, `sort` or `page` — and the
|
||||||
// 0 → N transition takes the page from the empty state back to the table.
|
// 0 → N transition takes the page from the empty state back to the table.
|
||||||
|
// Then the start filters (#399): the guided entry, its single fallback when
|
||||||
|
// no transaction is left uncategorized, and the ordinary visit.
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
transactionsReducer,
|
transactionsReducer,
|
||||||
initialTransactionsState,
|
initialTransactionsState,
|
||||||
|
initTransactionsState,
|
||||||
showsEmptyState,
|
showsEmptyState,
|
||||||
|
GUIDED_ENTRY_FILTERS,
|
||||||
type TransactionsState,
|
type TransactionsState,
|
||||||
} from "./useTransactions";
|
} from "./useTransactions";
|
||||||
|
import type { TransactionPageResult } from "../shared/types";
|
||||||
|
|
||||||
const populated = (): TransactionsState =>
|
const populated = (): TransactionsState =>
|
||||||
transactionsReducer(
|
transactionsReducer(
|
||||||
|
|
@ -92,3 +97,118 @@ describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => {
|
||||||
expect(after.totalInDb).toBe(5);
|
expect(after.totalInDb).toBe(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A page of `totalCount` transactions, as `getTransactionPage` returns it. */
|
||||||
|
const page = (totalCount: number): TransactionPageResult => ({
|
||||||
|
rows: [],
|
||||||
|
totalCount,
|
||||||
|
totalAmount: 0,
|
||||||
|
incomeTotal: 0,
|
||||||
|
expenseTotal: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (state: TransactionsState, totalCount: number) =>
|
||||||
|
transactionsReducer(state, { type: "SET_PAGE_RESULT", payload: page(totalCount) });
|
||||||
|
|
||||||
|
describe("initTransactionsState — the start filters (#399)", () => {
|
||||||
|
const JANUARY_FIRST = `${new Date().getFullYear()}-01-01`;
|
||||||
|
|
||||||
|
it("an ordinary visit keeps the default filters: since January 1st, every category", () => {
|
||||||
|
for (const state of [initTransactionsState(), initTransactionsState(undefined)]) {
|
||||||
|
expect(state).toEqual(initialTransactionsState);
|
||||||
|
expect(state.filters).toEqual({
|
||||||
|
search: "",
|
||||||
|
categoryId: null,
|
||||||
|
sourceId: null,
|
||||||
|
dateFrom: JANUARY_FIRST,
|
||||||
|
dateTo: null,
|
||||||
|
uncategorizedOnly: false,
|
||||||
|
});
|
||||||
|
expect(state.guidedFallback).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the guided entry starts on the uncategorized transactions, without a date bound", () => {
|
||||||
|
expect(GUIDED_ENTRY_FILTERS).toEqual({ dateFrom: null, uncategorizedOnly: true });
|
||||||
|
const guided = initTransactionsState(GUIDED_ENTRY_FILTERS);
|
||||||
|
expect(guided.filters).toEqual({
|
||||||
|
search: "",
|
||||||
|
categoryId: null,
|
||||||
|
sourceId: null,
|
||||||
|
dateFrom: null,
|
||||||
|
dateTo: null,
|
||||||
|
uncategorizedOnly: true,
|
||||||
|
});
|
||||||
|
expect(guided.guidedFallback).toBe(true);
|
||||||
|
// Nothing else moves: same sort, first page, nothing loaded yet.
|
||||||
|
expect({ ...guided, filters: initialTransactionsState.filters, guidedFallback: false }).toEqual(
|
||||||
|
initialTransactionsState,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies any start filter over the defaults; only 'uncategorized only' arms the fallback", () => {
|
||||||
|
const searched = initTransactionsState({ search: "epicerie" });
|
||||||
|
expect(searched.filters).toEqual({ ...initialTransactionsState.filters, search: "epicerie" });
|
||||||
|
expect(searched.guidedFallback).toBe(false);
|
||||||
|
expect(initTransactionsState({ uncategorizedOnly: false }).guidedFallback).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transactionsReducer — the guided entry's fallback (#399)", () => {
|
||||||
|
const guided = () => initTransactionsState(GUIDED_ENTRY_FILTERS);
|
||||||
|
|
||||||
|
it("an empty first page drops 'uncategorized only', keeps the other start filters, and reads again", () => {
|
||||||
|
const before = guided();
|
||||||
|
const after = result(before, 0);
|
||||||
|
expect(after.filters).toEqual({ ...before.filters, uncategorizedOnly: false });
|
||||||
|
expect(after.filters.dateFrom).toBeNull();
|
||||||
|
// A new filters object is what makes the hook read again.
|
||||||
|
expect(after.filters).not.toBe(before.filters);
|
||||||
|
expect(after.page).toBe(1);
|
||||||
|
// The loading placeholder, not an empty table, until that read.
|
||||||
|
expect(after.isLoading).toBe(true);
|
||||||
|
expect(after.guidedFallback).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back once: an empty page after the fallback keeps the filters, so nothing reads again", () => {
|
||||||
|
const fellBack = result(guided(), 0);
|
||||||
|
const again = result(fellBack, 0);
|
||||||
|
expect(again.filters).toBe(fellBack.filters);
|
||||||
|
expect(again.isLoading).toBe(false);
|
||||||
|
expect(again.guidedFallback).toBe(false);
|
||||||
|
expect(result(again, 0).filters).toBe(fellBack.filters);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a first page with uncategorized transactions keeps the filter, and a later empty page too", () => {
|
||||||
|
const before = guided();
|
||||||
|
const listed = result(before, 3);
|
||||||
|
expect(listed.filters).toBe(before.filters);
|
||||||
|
expect(listed.isLoading).toBe(false);
|
||||||
|
expect(listed.guidedFallback).toBe(false);
|
||||||
|
// Every one categorized, then a read comes back empty: the filter stays.
|
||||||
|
const emptied = result(listed, 0);
|
||||||
|
expect(emptied.filters).toBe(before.filters);
|
||||||
|
expect(emptied.filters.uncategorizedOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a filter changed by the user before the first page is never dropped", () => {
|
||||||
|
const touched = transactionsReducer(guided(), {
|
||||||
|
type: "SET_FILTER",
|
||||||
|
payload: { key: "search", value: "epicerie" },
|
||||||
|
});
|
||||||
|
expect(touched.guidedFallback).toBe(false);
|
||||||
|
const after = result(touched, 0);
|
||||||
|
expect(after.filters).toBe(touched.filters);
|
||||||
|
expect(after.filters.uncategorizedOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ordinary visit never drops 'uncategorized only', even picked by hand and empty", () => {
|
||||||
|
const picked = transactionsReducer(initTransactionsState(), {
|
||||||
|
type: "SET_FILTER",
|
||||||
|
payload: { key: "uncategorizedOnly", value: true },
|
||||||
|
});
|
||||||
|
const after = result(picked, 0);
|
||||||
|
expect(after.filters).toBe(picked.filters);
|
||||||
|
expect(after.filters.uncategorizedOnly).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,13 @@ export interface TransactionsState {
|
||||||
* profile without any transaction from a year without any.
|
* profile without any transaction from a year without any.
|
||||||
*/
|
*/
|
||||||
totalInDb: number | null;
|
totalInDb: number | null;
|
||||||
|
/**
|
||||||
|
* The start filters asked for the uncategorized transactions only (the
|
||||||
|
* guided entry, #399) and their first page has not come back yet. If it
|
||||||
|
* comes back empty, `SET_PAGE_RESULT` drops that filter, once. Off after
|
||||||
|
* that first page, after any filter change, and for an ordinary visit.
|
||||||
|
*/
|
||||||
|
guidedFallback: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TransactionsAction =
|
export type TransactionsAction =
|
||||||
|
|
@ -60,7 +67,7 @@ export type TransactionsAction =
|
||||||
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } }
|
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } }
|
||||||
| { type: "SET_AUTO_CATEGORIZING"; payload: boolean };
|
| { type: "SET_AUTO_CATEGORIZING"; payload: boolean };
|
||||||
|
|
||||||
const initialFilters: TransactionFilters = {
|
const defaultFilters: TransactionFilters = {
|
||||||
search: "",
|
search: "",
|
||||||
categoryId: null,
|
categoryId: null,
|
||||||
sourceId: null,
|
sourceId: null,
|
||||||
|
|
@ -69,13 +76,24 @@ const initialFilters: TransactionFilters = {
|
||||||
uncategorizedOnly: false,
|
uncategorizedOnly: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guided entry of the "categorize by hand" step (#399): the uncategorized
|
||||||
|
* transactions, whatever their date. No date bound, because the default
|
||||||
|
* filter starts on January 1st and, in January, the sample statements fall in
|
||||||
|
* the previous year.
|
||||||
|
*/
|
||||||
|
export const GUIDED_ENTRY_FILTERS: Partial<TransactionFilters> = {
|
||||||
|
dateFrom: null,
|
||||||
|
uncategorizedOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
export const initialTransactionsState: TransactionsState = {
|
export const initialTransactionsState: TransactionsState = {
|
||||||
rows: [],
|
rows: [],
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
incomeTotal: 0,
|
incomeTotal: 0,
|
||||||
expenseTotal: 0,
|
expenseTotal: 0,
|
||||||
filters: initialFilters,
|
filters: defaultFilters,
|
||||||
sort: { column: "date", direction: "desc" },
|
sort: { column: "date", direction: "desc" },
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
|
|
@ -85,8 +103,25 @@ export const initialTransactionsState: TransactionsState = {
|
||||||
isAutoCategorizing: false,
|
isAutoCategorizing: false,
|
||||||
error: null,
|
error: null,
|
||||||
totalInDb: null,
|
totalInDb: null,
|
||||||
|
guidedFallback: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hook's initial state (#399): the default filters with `initialFilters`
|
||||||
|
* over them. Start filters that ask for the uncategorized transactions only
|
||||||
|
* arm the fallback of their first page (`guidedFallback`). Without
|
||||||
|
* `initialFilters`, an ordinary visit: the default state.
|
||||||
|
*/
|
||||||
|
export function initTransactionsState(
|
||||||
|
initialFilters: Partial<TransactionFilters> = {}
|
||||||
|
): TransactionsState {
|
||||||
|
return {
|
||||||
|
...initialTransactionsState,
|
||||||
|
filters: { ...initialTransactionsState.filters, ...initialFilters },
|
||||||
|
guidedFallback: initialFilters.uncategorizedOnly === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function transactionsReducer(
|
export function transactionsReducer(
|
||||||
state: TransactionsState,
|
state: TransactionsState,
|
||||||
action: TransactionsAction
|
action: TransactionsAction
|
||||||
|
|
@ -96,8 +131,8 @@ export function transactionsReducer(
|
||||||
return { ...state, isLoading: action.payload };
|
return { ...state, isLoading: action.payload };
|
||||||
case "SET_ERROR":
|
case "SET_ERROR":
|
||||||
return { ...state, error: action.payload, isLoading: false };
|
return { ...state, error: action.payload, isLoading: false };
|
||||||
case "SET_PAGE_RESULT":
|
case "SET_PAGE_RESULT": {
|
||||||
return {
|
const next: TransactionsState = {
|
||||||
...state,
|
...state,
|
||||||
rows: action.payload.rows,
|
rows: action.payload.rows,
|
||||||
totalCount: action.payload.totalCount,
|
totalCount: action.payload.totalCount,
|
||||||
|
|
@ -105,12 +140,30 @@ export function transactionsReducer(
|
||||||
incomeTotal: action.payload.incomeTotal,
|
incomeTotal: action.payload.incomeTotal,
|
||||||
expenseTotal: action.payload.expenseTotal,
|
expenseTotal: action.payload.expenseTotal,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
guidedFallback: false,
|
||||||
};
|
};
|
||||||
|
// The guided entry's first page is empty: no transaction is left
|
||||||
|
// uncategorized (#399). Drop that filter, once, so the page lists every
|
||||||
|
// transaction instead of an empty table. The new filters make the hook
|
||||||
|
// read again (the filters' 300 ms debounce); until then, the loading
|
||||||
|
// placeholder takes the place of the empty table.
|
||||||
|
if (state.guidedFallback && action.payload.totalCount === 0) {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
filters: { ...state.filters, uncategorizedOnly: false },
|
||||||
|
page: 1,
|
||||||
|
isLoading: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
case "SET_FILTER":
|
case "SET_FILTER":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
filters: { ...state.filters, [action.payload.key]: action.payload.value },
|
filters: { ...state.filters, [action.payload.key]: action.payload.value },
|
||||||
page: 1,
|
page: 1,
|
||||||
|
// The user's own filters: never dropped behind their back.
|
||||||
|
guidedFallback: false,
|
||||||
};
|
};
|
||||||
case "SET_SORT":
|
case "SET_SORT":
|
||||||
return { ...state, sort: action.payload };
|
return { ...state, sort: action.payload };
|
||||||
|
|
@ -161,8 +214,13 @@ export function showsEmptyState(state: Pick<TransactionsState, "totalInDb" | "is
|
||||||
return state.totalInDb === 0 && !state.isLoading;
|
return state.totalInDb === 0 && !state.isLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTransactions() {
|
/**
|
||||||
const [state, dispatch] = useReducer(transactionsReducer, initialTransactionsState);
|
* The Transactions page's state. `initialFilters` go over the default filters
|
||||||
|
* (the guided entry passes `GUIDED_ENTRY_FILTERS`, #399); like any initial
|
||||||
|
* state, they are read once, at mount, and a later value changes nothing.
|
||||||
|
*/
|
||||||
|
export function useTransactions(initialFilters?: Partial<TransactionFilters>) {
|
||||||
|
const [state, dispatch] = useReducer(transactionsReducer, initialFilters, initTransactionsState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const debouncedFiltersRef = useRef(state.filters);
|
const debouncedFiltersRef = useRef(state.filters);
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,9 @@ i18n.use(initReactI18next).init({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `{{value, lowercase}}`: a label named inside a sentence (the "Uncategorized"
|
||||||
|
// filter of the Transactions step card, #399) keeps its own key and loses its
|
||||||
|
// capital, in each language's own way. The sentence decides, not the code.
|
||||||
|
i18n.services.formatter?.add("lowercase", (value, lng) => String(value).toLocaleLowerCase(lng));
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,10 @@
|
||||||
"confirm": "Click {{button}}: the source is saved and your transactions come in.",
|
"confirm": "Click {{button}}: the source is saved and your transactions come in.",
|
||||||
"importing": "Importing…",
|
"importing": "Importing…",
|
||||||
"failed": "The import added no transaction; the report below says why."
|
"failed": "The import added no transaction; the report below says why."
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"uncategorized": "Only {{filter, lowercase}} transactions are shown. Click the category of one of them and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it.",
|
||||||
|
"all": "Click a transaction's category and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,10 @@
|
||||||
"confirm": "Cliquez {{button}} : la source est enregistrée et vos transactions arrivent.",
|
"confirm": "Cliquez {{button}} : la source est enregistrée et vos transactions arrivent.",
|
||||||
"importing": "Import en cours…",
|
"importing": "Import en cours…",
|
||||||
"failed": "L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi."
|
"failed": "L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi."
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"uncategorized": "Seules les transactions {{filter, lowercase}} sont affichées. Cliquez sur la catégorie de l'une d'elles et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas.",
|
||||||
|
"all": "Cliquez sur la catégorie d'une transaction et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
|
|
|
||||||
215
src/pages/TransactionsPage.render.test.tsx
Normal file
215
src/pages/TransactionsPage.render.test.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
// TransactionsPage and its getting-started card (#399). No jsdom:
|
||||||
|
// react-dom/server renders in vitest's node environment. The page runs the
|
||||||
|
// real `useTransactions` (its reducer and initial state; no effect runs, so
|
||||||
|
// nothing is read) with the rows a first read would have brought, and the path
|
||||||
|
// is a fake `useOnboarding()` built from the real checklist. What is pinned is
|
||||||
|
// the page's wiring: the guided entry's start filters, where the card sits,
|
||||||
|
// what it says, and which control carries the one highlight.
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import type { TransactionRow } from "../shared/types";
|
||||||
|
|
||||||
|
// The page's services pull the database: stub the connection.
|
||||||
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, o?: Record<string, unknown>) =>
|
||||||
|
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
||||||
|
i18n: { language: "fr" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||||
|
|
||||||
|
const harness = vi.hoisted(() => ({ onboarding: null as unknown, rows: [] as TransactionRow[] }));
|
||||||
|
|
||||||
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||||
|
vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../hooks/useTransactions")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
|
||||||
|
const hook = actual.useTransactions(...args);
|
||||||
|
return { ...hook, state: { ...hook.state, rows: harness.rows } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import TransactionsPage from "./TransactionsPage";
|
||||||
|
import { TRANSACTION_CATEGORY_TARGET } from "../components/transactions/TransactionTable";
|
||||||
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
||||||
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||||
|
import { nextStep, stepNavState } from "../utils/onboardingPath";
|
||||||
|
import type { OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
|
/** Folder, source and import done: the next step is the manual category. */
|
||||||
|
const IMPORTED: OnboardingCounters = {
|
||||||
|
sources: 1,
|
||||||
|
imports: 1,
|
||||||
|
manualCategorized: 0,
|
||||||
|
budgetEntries: 0,
|
||||||
|
transactions: 41,
|
||||||
|
};
|
||||||
|
/** A transaction categorized by hand: the step is checked. */
|
||||||
|
const CATEGORIZED: OnboardingCounters = { ...IMPORTED, manualCategorized: 1 };
|
||||||
|
|
||||||
|
/** The path latched on /transactions (card mode), as the provider shares it. */
|
||||||
|
function path(
|
||||||
|
counters: OnboardingCounters,
|
||||||
|
overrides: Partial<OnboardingValue> = {},
|
||||||
|
budgetAllowed = true,
|
||||||
|
): OnboardingValue {
|
||||||
|
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed });
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
checklist,
|
||||||
|
counters,
|
||||||
|
hidden: false,
|
||||||
|
budgetReady: true,
|
||||||
|
nextStep: nextStep(checklist),
|
||||||
|
mode: "card",
|
||||||
|
cardPage: "/transactions",
|
||||||
|
hide: vi.fn(async () => undefined),
|
||||||
|
show: vi.fn(async () => undefined),
|
||||||
|
skip: vi.fn(async () => undefined),
|
||||||
|
resume: vi.fn(async () => undefined),
|
||||||
|
refresh: vi.fn(async () => undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = (id: number, description: string): TransactionRow => ({
|
||||||
|
id,
|
||||||
|
date: "2025-12-18",
|
||||||
|
description,
|
||||||
|
amount: -42.5,
|
||||||
|
category_id: null,
|
||||||
|
category_name: null,
|
||||||
|
category_color: null,
|
||||||
|
source_name: "Exemple Desjardins",
|
||||||
|
notes: null,
|
||||||
|
is_manually_categorized: false,
|
||||||
|
is_split: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** What the link of the "categorize by hand" step carries. */
|
||||||
|
const GUIDED = stepNavState("manualCategory");
|
||||||
|
|
||||||
|
const render = (state: unknown = null) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter initialEntries={[{ pathname: "/transactions", state }]}>
|
||||||
|
<TransactionsPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const CARD = 'aria-label="t(onboarding.banner.label)"';
|
||||||
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||||
|
const JANUARY_FIRST = `${new Date().getFullYear()}-01-01`;
|
||||||
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card's sentence: the paragraph that follows its title. The card always
|
||||||
|
* hands `t` its interpolations, so a sentence without any reads `t(key[])`.
|
||||||
|
*/
|
||||||
|
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
|
||||||
|
/** The filter bar's start date, "" without a bound (React writes `value` last). */
|
||||||
|
const dateFrom = (html: string) =>
|
||||||
|
html.match(/<input type="date" placeholder="t\(transactions\.filters\.dateFrom\)"[^>]* value="([^"]*)"/)?.[1];
|
||||||
|
/** The period button shown as active. */
|
||||||
|
const activePeriod = (html: string) =>
|
||||||
|
html.match(/<button class="[^"]*bg-\[var\(--primary\)\] text-white">t\(dashboard\.period\.(\w+)\)<\/button>/)?.[1];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness.onboarding = path(IMPORTED);
|
||||||
|
harness.rows = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TransactionsPage — the guided entry (#399)", () => {
|
||||||
|
it("arriving by the step's link: the uncategorized transactions, without a date bound", () => {
|
||||||
|
const html = render(GUIDED);
|
||||||
|
expect(dateFrom(html)).toBe("");
|
||||||
|
expect(activePeriod(html)).toBe("all");
|
||||||
|
expect(html).toContain('value="t(transactions.filters.uncategorized)"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ordinary visit keeps the default filters: since January 1st, every category", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(dateFrom(html)).toBe(JANUARY_FIRST);
|
||||||
|
expect(html).toContain('value="t(transactions.filters.allCategories)"');
|
||||||
|
expect(html).not.toContain('value="t(transactions.filters.uncategorized)"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a malformed navigation state as an ordinary visit (CWE-476)", () => {
|
||||||
|
for (const state of ["guided", { guided: true }, { onboarding: null }, { onboarding: { guided: "yes" } }]) {
|
||||||
|
expect(dateFrom(render(state)), JSON.stringify(state)).toBe(JANUARY_FIRST);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TransactionsPage — the step card (#399)", () => {
|
||||||
|
it("renders the card first, before the page's heading, at its place in the path", () => {
|
||||||
|
const html = render(GUIDED);
|
||||||
|
expect(count(html, CARD)).toBe(1);
|
||||||
|
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||||
|
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=6])");
|
||||||
|
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the guided entry: only the uncategorized transactions, the filter named by its own label", () => {
|
||||||
|
expect(sentence(render(GUIDED))).toBe(
|
||||||
|
"t(onboarding.card.transactions.uncategorized[filter=t(transactions.filters.uncategorized)])",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ordinary visit: the sentence for every transaction", () => {
|
||||||
|
expect(sentence(render())).toBe("t(onboarding.card.transactions.all[])");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the first row's category is the one target", () => {
|
||||||
|
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
|
||||||
|
const html = render(GUIDED);
|
||||||
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||||
|
expect(html).toContain(`data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}`);
|
||||||
|
const ring = html.indexOf(HIGHLIGHT);
|
||||||
|
expect(ring).toBeGreaterThan(html.indexOf("IGA 123"));
|
||||||
|
expect(ring).toBeLessThan(html.indexOf("METRO 456"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without the card on screen, no control is highlighted", () => {
|
||||||
|
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
|
||||||
|
for (const overrides of [
|
||||||
|
{ mode: "banner" as const, cardPage: null },
|
||||||
|
{ mode: "none" as const, cardPage: null },
|
||||||
|
{ mode: "card" as const, cardPage: "/import" },
|
||||||
|
]) {
|
||||||
|
harness.onboarding = path(IMPORTED, overrides);
|
||||||
|
const html = render(GUIDED);
|
||||||
|
expect(html).not.toContain(CARD);
|
||||||
|
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||||
|
expect(html).toContain("IGA 123");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a transaction categorized by hand: the card stays, says 'Done.' and offers the Budget step", () => {
|
||||||
|
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
|
||||||
|
harness.onboarding = path(CATEGORIZED);
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, CARD)).toBe(1);
|
||||||
|
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
|
||||||
|
expect(html).toMatch(
|
||||||
|
/<a [^>]*href="\/budget"[^>]*>t\(onboarding\.card\.next\[step=t\(onboarding\.checklist\.items\.budget\)\]\)/,
|
||||||
|
);
|
||||||
|
expect(sentence(html)).toBeNull();
|
||||||
|
expect(html).not.toContain("t(onboarding.card.skip)");
|
||||||
|
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("in the Free edition, the step checked ends the path", () => {
|
||||||
|
harness.onboarding = path(CATEGORIZED, {}, false);
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=5])");
|
||||||
|
expect(html).toContain("t(onboarding.card.done)");
|
||||||
|
expect(html).toContain("t(onboarding.banner.complete)");
|
||||||
|
expect(html).not.toContain('href="/budget"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { Wand2, Tag, Receipt } from "lucide-react";
|
import { Wand2, Tag, Receipt } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import EmptyState from "../components/shared/EmptyState";
|
import EmptyState from "../components/shared/EmptyState";
|
||||||
import { useTransactions, showsEmptyState } from "../hooks/useTransactions";
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
||||||
|
import { useOnboarding } from "../contexts/OnboardingContext";
|
||||||
|
import {
|
||||||
|
readOnboardingNavState,
|
||||||
|
showsStepCard,
|
||||||
|
transactionsCardModel,
|
||||||
|
} from "../utils/onboardingPath";
|
||||||
|
import {
|
||||||
|
useTransactions,
|
||||||
|
showsEmptyState,
|
||||||
|
GUIDED_ENTRY_FILTERS,
|
||||||
|
} from "../hooks/useTransactions";
|
||||||
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
|
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
|
||||||
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
|
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
|
||||||
import TransactionTable from "../components/transactions/TransactionTable";
|
import TransactionTable from "../components/transactions/TransactionTable";
|
||||||
|
|
@ -18,8 +30,16 @@ import type { TransactionRow } from "../shared/types";
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
export default function TransactionsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
// The link of the "categorize by hand" step opens the guided entry (#399),
|
||||||
|
// read like any navigation state through `readOnboardingNavState` (CWE-476).
|
||||||
|
// The hook takes its start filters at mount only: the card consuming the
|
||||||
|
// navigation state afterwards changes nothing.
|
||||||
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
|
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
|
||||||
useTransactions();
|
useTransactions(
|
||||||
|
readOnboardingNavState(location.state).guided ? GUIDED_ENTRY_FILTERS : undefined
|
||||||
|
);
|
||||||
|
const onboarding = useOnboarding();
|
||||||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
|
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
|
||||||
const [pending, setPending] = useState<TransactionRow | null>(null);
|
const [pending, setPending] = useState<TransactionRow | null>(null);
|
||||||
|
|
@ -58,8 +78,18 @@ export default function TransactionsPage() {
|
||||||
// can still sit on a populated profile.
|
// can still sit on a populated profile.
|
||||||
const isEmptyProfile = showsEmptyState(state);
|
const isEmptyProfile = showsEmptyState(state);
|
||||||
|
|
||||||
|
// The getting-started card follows the list's filter (#399). Its target is
|
||||||
|
// highlighted only while the card is on screen: a ring needs the card that
|
||||||
|
// explains it.
|
||||||
|
const cardModel = transactionsCardModel(state.filters, onboarding.checklist);
|
||||||
|
const target = showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname)
|
||||||
|
? cardModel.target
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<OnboardingStepCard model={cardModel} />
|
||||||
|
|
||||||
<div className="relative flex items-center gap-3 mb-6">
|
<div className="relative flex items-center gap-3 mb-6">
|
||||||
<h1 className="text-2xl font-bold">{t("transactions.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("transactions.title")}</h1>
|
||||||
<PageHelp helpKey="transactions" />
|
<PageHelp helpKey="transactions" />
|
||||||
|
|
@ -128,6 +158,7 @@ export default function TransactionsPage() {
|
||||||
onDeleteSplit={deleteSplit}
|
onDeleteSplit={deleteSplit}
|
||||||
onRowContextMenu={handleRowContextMenu}
|
onRowContextMenu={handleRowContextMenu}
|
||||||
linkedTransfersByTxId={linkedTransfersByTxId}
|
linkedTransfersByTxId={linkedTransfersByTxId}
|
||||||
|
onboardingTarget={target === "firstRowCategory"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionPagination
|
<TransactionPagination
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
import i18n from "../i18n/config";
|
||||||
import { computeChecklist, type Checklist } from "./onboardingChecklist";
|
import { computeChecklist, type Checklist } from "./onboardingChecklist";
|
||||||
import {
|
import {
|
||||||
CARD_PAGES,
|
CARD_PAGES,
|
||||||
|
|
@ -13,8 +14,11 @@ import {
|
||||||
showsStepCard,
|
showsStepCard,
|
||||||
stepNavState,
|
stepNavState,
|
||||||
stepsOfPage,
|
stepsOfPage,
|
||||||
|
transactionsCardModel,
|
||||||
type BannerModeInput,
|
type BannerModeInput,
|
||||||
type ImportCardModel,
|
type ImportCardModel,
|
||||||
|
type StepCardText,
|
||||||
|
type TransactionsCardModel,
|
||||||
} from "./onboardingPath";
|
} from "./onboardingPath";
|
||||||
import {
|
import {
|
||||||
CHECKLIST_ITEM_KEYS,
|
CHECKLIST_ITEM_KEYS,
|
||||||
|
|
@ -342,10 +346,14 @@ describe("stepNavState (#397)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CARD_PAGES (#398)", () => {
|
describe("CARD_PAGES (#398, #399)", () => {
|
||||||
it("carries the Import page", () => {
|
it("carries the Import page", () => {
|
||||||
expect(CARD_PAGES.has("/import")).toBe(true);
|
expect(CARD_PAGES.has("/import")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the Transactions page", () => {
|
||||||
|
expect(CARD_PAGES.has("/transactions")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("showsStepCard (#398)", () => {
|
describe("showsStepCard (#398)", () => {
|
||||||
|
|
@ -569,6 +577,84 @@ describe("importCardModel (#398)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("transactionsCardModel (#399)", () => {
|
||||||
|
/** Folder, source and import done: the next step is the manual category. */
|
||||||
|
const imported = checklistOf({ ...zero, sources: 1, imports: 1, transactions: 41 }, { importFolderSet: true });
|
||||||
|
const UNCATEGORIZED: TransactionsCardModel = {
|
||||||
|
text: {
|
||||||
|
key: "onboarding.card.transactions.uncategorized",
|
||||||
|
labels: { filter: "transactions.filters.uncategorized" },
|
||||||
|
},
|
||||||
|
done: false,
|
||||||
|
target: "firstRowCategory",
|
||||||
|
};
|
||||||
|
const ALL: TransactionsCardModel = {
|
||||||
|
text: { key: "onboarding.card.transactions.all" },
|
||||||
|
done: false,
|
||||||
|
target: "firstRowCategory",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A sentence of the card as StepCard renders it, through the app's real i18n. */
|
||||||
|
const render = (text: StepCardText | null, lng: "fr" | "en") =>
|
||||||
|
text &&
|
||||||
|
i18n.t(text.key, {
|
||||||
|
lng,
|
||||||
|
...text.values,
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(text.labels ?? {}).map(([name, key]) => [name, i18n.t(key, { lng })]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only the uncategorized transactions listed: says so, the filter named by its own label", () => {
|
||||||
|
expect(transactionsCardModel({ uncategorizedOnly: true }, imported)).toEqual(UNCATEGORIZED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every transaction listed: the sentence without the filter", () => {
|
||||||
|
expect(transactionsCardModel({ uncategorizedOnly: false }, imported)).toEqual(ALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points at the first row's category until the step is checked", () => {
|
||||||
|
expect(transactionsCardModel({ uncategorizedOnly: true }, null).target).toBe("firstRowCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the step checked: 'Done.', nothing to point at, whatever the filter", () => {
|
||||||
|
const categorized = checklistOf(upToManual, { importFolderSet: true });
|
||||||
|
for (const uncategorizedOnly of [true, false]) {
|
||||||
|
expect(transactionsCardModel({ uncategorizedOnly }, categorized)).toEqual({
|
||||||
|
text: null,
|
||||||
|
done: true,
|
||||||
|
target: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped step is not a checked one", () => {
|
||||||
|
const skipped = checklistOf(
|
||||||
|
{ ...zero, sources: 1, imports: 1, transactions: 41 },
|
||||||
|
{ importFolderSet: true, skipped: ["manualCategory"] },
|
||||||
|
);
|
||||||
|
expect(transactionsCardModel({ uncategorizedOnly: false }, skipped)).toEqual(ALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the two sentences in French, the filter's label in lowercase", () => {
|
||||||
|
expect(render(UNCATEGORIZED.text, "fr")).toBe(
|
||||||
|
"Seules les transactions non catégorisées sont affichées. Cliquez sur la catégorie de l'une d'elles et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas.",
|
||||||
|
);
|
||||||
|
expect(render(ALL.text, "fr")).toBe(
|
||||||
|
"Cliquez sur la catégorie d'une transaction et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the two sentences in English, the filter's label in lowercase", () => {
|
||||||
|
expect(render(UNCATEGORIZED.text, "en")).toBe(
|
||||||
|
"Only uncategorized transactions are shown. Click the category of one of them and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it.",
|
||||||
|
);
|
||||||
|
expect(render(ALL.text, "en")).toBe(
|
||||||
|
"Click a transaction's category and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("onboardingTargetAttributes (#398)", () => {
|
describe("onboardingTargetAttributes (#398)", () => {
|
||||||
it("marks the target and its highlight when on, nothing otherwise", () => {
|
it("marks the target and its highlight when on, nothing otherwise", () => {
|
||||||
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({
|
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import type {
|
||||||
ChecklistItemKey,
|
ChecklistItemKey,
|
||||||
ImportCardInput,
|
ImportCardInput,
|
||||||
OnboardingStatus,
|
OnboardingStatus,
|
||||||
|
TransactionFilters,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure rules of the guided getting-started path (#396, #398).
|
* Pure rules of the guided getting-started path (#396, #398, #399).
|
||||||
*
|
*
|
||||||
* The provider (`useOnboardingChecklist`) feeds them its state; the banner,
|
* The provider (`useOnboardingChecklist`) feeds them its state; the banner,
|
||||||
* the step cards and the pages that carry a card only read what they decide.
|
* the step cards and the pages that carry a card only read what they decide.
|
||||||
|
|
@ -24,10 +25,10 @@ export function nextStep(checklist: Checklist): ChecklistItem | null {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes whose page carries a step card. Each card issue adds its page (#398:
|
* Routes whose page carries a step card. Each card issue adds its page (#398:
|
||||||
* Import), and a page without a delivered card keeps the banner, so a partly
|
* Import, #399: Transactions), and a page without a delivered card keeps the
|
||||||
* merged stack never removes any guidance.
|
* banner, so a partly merged stack never removes any guidance.
|
||||||
*/
|
*/
|
||||||
export const CARD_PAGES: ReadonlySet<string> = new Set<string>(["/import"]);
|
export const CARD_PAGES: ReadonlySet<string> = new Set<string>(["/import", "/transactions"]);
|
||||||
|
|
||||||
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
|
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
|
||||||
"/import": ["importFolder", "source", "import"],
|
"/import": ["importFolder", "source", "import"],
|
||||||
|
|
@ -332,6 +333,41 @@ export function importCardModel(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The control of the Transactions page that the card points at. */
|
||||||
|
export type TransactionsCardTarget = "firstRowCategory";
|
||||||
|
|
||||||
|
export interface TransactionsCardModel extends StepCardModel {
|
||||||
|
/** The control to highlight, null for none. */
|
||||||
|
target: TransactionsCardTarget | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Transactions page's card (#399), read from the page's filters and the
|
||||||
|
* checklist:
|
||||||
|
* - the "categorize by hand" step checked: "Done.", nothing to point at;
|
||||||
|
* - otherwise, the sentence that matches the list: the uncategorized
|
||||||
|
* transactions only (the guided entry, or the filter picked by hand), the
|
||||||
|
* filter named by its own label, or every transaction (an ordinary visit,
|
||||||
|
* or a guided entry that found none uncategorized); and the category of the
|
||||||
|
* first row shown.
|
||||||
|
*/
|
||||||
|
export function transactionsCardModel(
|
||||||
|
filters: Pick<TransactionFilters, "uncategorizedOnly">,
|
||||||
|
checklist: Checklist | null,
|
||||||
|
): TransactionsCardModel {
|
||||||
|
if (checked(checklist, "manualCategory")) return { text: null, done: true, target: null };
|
||||||
|
return {
|
||||||
|
text: filters.uncategorizedOnly
|
||||||
|
? {
|
||||||
|
key: "onboarding.card.transactions.uncategorized",
|
||||||
|
labels: { filter: "transactions.filters.uncategorized" },
|
||||||
|
}
|
||||||
|
: { key: "onboarding.card.transactions.all" },
|
||||||
|
done: false,
|
||||||
|
target: "firstRowCategory",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
|
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
|
||||||
export interface OnboardingTargetAttributes {
|
export interface OnboardingTargetAttributes {
|
||||||
"data-onboarding-target"?: string;
|
"data-onboarding-target"?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue