feat(balance): account form vehicle field + category rename via custom_label (#203)
- AccountForm: optional vehicle_type dropdown (account mode, 6 fiscal envelopes + none) wired into create/update; custom_label field (category mode) written to custom_label, never i18n_key. - AccountsPage rename: writes custom_label, leaves i18n_key intact (fixes bug I where renaming clobbered the translation key). - New shared renderCategoryLabel helper (+ shape adapters) applied at the 5 sites: AccountsPage, AccountForm, SnapshotEditor, BalanceAccountsTable, BalancePage. - Hide v13-deactivated seeds: useBalanceAccounts passes includeInactive=false (keeps #202 behavior-neutral default + tests green). - BALANCE_VEHICLE_TYPES exported from shared/types as single source of truth (service reuses it). - i18n FR+EN: vehicleType.*, category.form.customLabel*, errors.vehicle_type_invalid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cb58bbb31a
commit
344c27ee6d
12 changed files with 316 additions and 44 deletions
|
|
@ -16,12 +16,15 @@ import type {
|
|||
BalanceAssetType,
|
||||
BalanceCategory,
|
||||
BalanceCategoryKind,
|
||||
BalanceVehicleType,
|
||||
} from "../../shared/types";
|
||||
import { BALANCE_VEHICLE_TYPES } from "../../shared/types";
|
||||
import type {
|
||||
CreateBalanceAccountInput,
|
||||
CreateBalanceCategoryInput,
|
||||
UpdateBalanceAccountInput,
|
||||
} from "../../services/balance.service";
|
||||
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Account variant types
|
||||
|
|
@ -32,6 +35,8 @@ export interface AccountFormValues {
|
|||
name: string;
|
||||
symbol: string;
|
||||
notes: string;
|
||||
/** Fiscal envelope; "" means "no envelope" (→ null in the payload). */
|
||||
vehicle_type: BalanceVehicleType | "";
|
||||
}
|
||||
|
||||
interface AccountVariantProps {
|
||||
|
|
@ -52,7 +57,12 @@ interface AccountVariantProps {
|
|||
|
||||
export interface CategoryFormValues {
|
||||
key: string;
|
||||
i18n_key: string;
|
||||
/**
|
||||
* User-facing label (Issue #203). Written to `custom_label`, never to
|
||||
* `i18n_key` — consistent with the rename flow and the bug-I fix. The
|
||||
* service derives `i18n_key` from the key for user-created categories.
|
||||
*/
|
||||
custom_label: string;
|
||||
kind: BalanceCategoryKind;
|
||||
/** Required when kind === 'priced' (Issue #169). NULL otherwise. */
|
||||
asset_type: BalanceAssetType | null;
|
||||
|
|
@ -77,6 +87,7 @@ function defaultAccountValues(
|
|||
name: initial.name,
|
||||
symbol: initial.symbol ?? "",
|
||||
notes: initial.notes ?? "",
|
||||
vehicle_type: initial.vehicle_type ?? "",
|
||||
};
|
||||
}
|
||||
// First active category as a sane default
|
||||
|
|
@ -86,6 +97,7 @@ function defaultAccountValues(
|
|||
name: "",
|
||||
symbol: "",
|
||||
notes: "",
|
||||
vehicle_type: "",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -136,11 +148,16 @@ function AccountVariant({
|
|||
setTouched(true);
|
||||
if (!trimmedName) return;
|
||||
|
||||
// "" in the select means "no envelope" → null on the wire.
|
||||
const vehicleType: BalanceVehicleType | null =
|
||||
values.vehicle_type === "" ? null : values.vehicle_type;
|
||||
|
||||
const payload: CreateBalanceAccountInput = {
|
||||
balance_category_id: values.balance_category_id,
|
||||
name: trimmedName,
|
||||
symbol: trimmedSymbol || null,
|
||||
notes: values.notes.trim() || null,
|
||||
vehicle_type: vehicleType,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
|
|
@ -149,6 +166,7 @@ function AccountVariant({
|
|||
name: payload.name,
|
||||
symbol: payload.symbol,
|
||||
notes: payload.notes,
|
||||
vehicle_type: vehicleType,
|
||||
};
|
||||
await onSubmit(updatePayload);
|
||||
} else {
|
||||
|
|
@ -156,9 +174,6 @@ function AccountVariant({
|
|||
}
|
||||
};
|
||||
|
||||
const renderCategoryLabel = (cat: BalanceCategory) =>
|
||||
t(cat.i18n_key, { defaultValue: cat.key });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
|
|
@ -181,7 +196,7 @@ function AccountVariant({
|
|||
) : (
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{renderCategoryLabel(cat)}
|
||||
{renderCategoryLabelFromCategory(cat, t)}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
|
|
@ -251,6 +266,36 @@ function AccountVariant({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
htmlFor="account-vehicle-type"
|
||||
>
|
||||
{t("balance.account.form.vehicleType.label")}
|
||||
</label>
|
||||
<select
|
||||
id="account-vehicle-type"
|
||||
value={values.vehicle_type}
|
||||
onChange={(e) =>
|
||||
setValues({
|
||||
...values,
|
||||
vehicle_type: e.target.value as BalanceVehicleType | "",
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
>
|
||||
<option value="">{t("balance.account.form.vehicleType.none")}</option>
|
||||
{BALANCE_VEHICLE_TYPES.map((vt) => (
|
||||
<option key={vt} value={vt}>
|
||||
{t(`balance.account.form.vehicleType.${vt}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{t("balance.account.form.vehicleType.hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{t("balance.account.form.currencyMvpNotice")}
|
||||
</p>
|
||||
|
|
@ -290,14 +335,14 @@ function CategoryVariant({
|
|||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<CategoryFormValues>({
|
||||
key: "",
|
||||
i18n_key: "",
|
||||
custom_label: "",
|
||||
kind: "simple",
|
||||
asset_type: null,
|
||||
});
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const trimmedKey = values.key.trim();
|
||||
const trimmedLabel = values.i18n_key.trim();
|
||||
const trimmedLabel = values.custom_label.trim();
|
||||
const keyInvalid = touched && trimmedKey.length === 0;
|
||||
const assetTypeMissing =
|
||||
touched && values.kind === "priced" && !values.asset_type;
|
||||
|
|
@ -321,14 +366,16 @@ function CategoryVariant({
|
|||
setTouched(true);
|
||||
if (!trimmedKey) return;
|
||||
if (values.kind === "priced" && !values.asset_type) return;
|
||||
// Fall back to the key if no human label was supplied.
|
||||
const i18nKey = trimmedLabel || trimmedKey;
|
||||
// User-created categories have no bundled translation: anchor `i18n_key`
|
||||
// on the key (stable lookup, no free text) and carry the human label in
|
||||
// `custom_label` — same mechanism as renaming (Issue #203 / bug I).
|
||||
await onSubmit({
|
||||
key: trimmedKey,
|
||||
i18n_key: i18nKey,
|
||||
i18n_key: `balance.category.${trimmedKey}`,
|
||||
kind: values.kind,
|
||||
sort_order: 100, // user-created categories sort after seeded ones
|
||||
asset_type: values.kind === "priced" ? values.asset_type : null,
|
||||
custom_label: trimmedLabel || null,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -368,21 +415,24 @@ function CategoryVariant({
|
|||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
htmlFor="category-label"
|
||||
htmlFor="category-custom-label"
|
||||
>
|
||||
{t("balance.category.form.label")}
|
||||
{t("balance.category.form.customLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="category-label"
|
||||
id="category-custom-label"
|
||||
type="text"
|
||||
value={values.i18n_key}
|
||||
value={values.custom_label}
|
||||
onChange={(e) =>
|
||||
setValues({ ...values, i18n_key: e.target.value })
|
||||
setValues({ ...values, custom_label: e.target.value })
|
||||
}
|
||||
placeholder={t("balance.category.form.labelPlaceholder")}
|
||||
placeholder={t("balance.category.form.customLabelPlaceholder")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{t("balance.category.form.customLabelHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
} from "../../services/balance.service";
|
||||
import { computeAccountReturn } from "../../services/balance.service";
|
||||
import type { AccountReturn } from "../../shared/types";
|
||||
import { renderCategoryLabelFromAccount } from "../../utils/renderCategoryLabel";
|
||||
|
||||
const cadFormatter = (locale: string) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
|
|
@ -287,7 +288,7 @@ export default function BalanceAccountsTable({
|
|||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--muted-foreground)]">
|
||||
{t(acc.category_i18n_key, { defaultValue: acc.category_key })}
|
||||
{renderCategoryLabelFromAccount(acc, t)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">
|
||||
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
} from "../../shared/types";
|
||||
import type { PricedEntry } from "../../hooks/useSnapshotEditor";
|
||||
import SnapshotLineRow from "./SnapshotLineRow";
|
||||
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
|
||||
|
||||
interface Props {
|
||||
accounts: BalanceAccountWithCategory[];
|
||||
|
|
@ -81,7 +82,7 @@ export default function SnapshotEditor({
|
|||
>
|
||||
<div className="px-4 py-2 bg-[var(--muted)] border-b border-[var(--border)]">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(category.i18n_key, { defaultValue: category.key })}
|
||||
{renderCategoryLabelFromCategory(category, t)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
|
|
|
|||
|
|
@ -115,7 +115,11 @@ export function useBalanceAccounts() {
|
|||
try {
|
||||
const [accounts, categories] = await Promise.all([
|
||||
listBalanceAccounts({ includeArchived }),
|
||||
listBalanceCategories(),
|
||||
// Exclude v13-deactivated ex-envelope seeds (tfsa/rrsp) from both the
|
||||
// account-form category dropdown AND the category-management list
|
||||
// (Issue #203). Existing accounts were re-linked to `other` by v13, so
|
||||
// nothing points at a deactivated seed — filtering is safe.
|
||||
listBalanceCategories({ includeInactive: false }),
|
||||
]);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_DATA", payload: { accounts, categories } });
|
||||
|
|
|
|||
|
|
@ -1643,7 +1643,18 @@
|
|||
"notes": "Notes",
|
||||
"currencyMvpNotice": "At the MVP, all accounts are in CAD. Multi-currency support will land in a later version.",
|
||||
"save": "Save",
|
||||
"create": "Create account"
|
||||
"create": "Create account",
|
||||
"vehicleType": {
|
||||
"label": "Fiscal envelope",
|
||||
"none": "None",
|
||||
"unregistered": "Non-registered",
|
||||
"tfsa": "TFSA",
|
||||
"rrsp": "RRSP",
|
||||
"rrif": "RRIF",
|
||||
"fhsa": "FHSA",
|
||||
"resp": "RESP",
|
||||
"hint": "Tax shelter for this account (TFSA, RRSP…). Optional — the asset class stays the type."
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
|
|
@ -1680,7 +1691,10 @@
|
|||
"kindHintSimple": "Direct value entry (e.g. checking-account balance).",
|
||||
"kindHintPriced": "Quantity × unit price entry (e.g. stocks, crypto). A symbol is optional for linked accounts (only needed for automatic price fetching).",
|
||||
"simpleOnlyNotice": "Priced types (stocks, crypto) will be available in a future release.",
|
||||
"create": "Create type"
|
||||
"create": "Create type",
|
||||
"customLabel": "Label",
|
||||
"customLabelPlaceholder": "e.g. My RRIF, Travel savings",
|
||||
"customLabelHint": "Display name for this type. Leave blank to use the default label."
|
||||
},
|
||||
"assetType": {
|
||||
"label": "Asset type",
|
||||
|
|
@ -1759,7 +1773,8 @@
|
|||
"snapshot_priced_quantity_required": "Quantity is required for priced accounts.",
|
||||
"snapshot_priced_unit_price_required": "Unit price is required for priced accounts.",
|
||||
"snapshot_priced_value_mismatch": "The entered value does not match quantity × unit price.",
|
||||
"snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price."
|
||||
"snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price.",
|
||||
"vehicle_type_invalid": "Invalid fiscal envelope."
|
||||
},
|
||||
"returns": {
|
||||
"partialTooltip": "Partial return: a snapshot is missing for the selected period.",
|
||||
|
|
|
|||
|
|
@ -1643,7 +1643,18 @@
|
|||
"notes": "Notes",
|
||||
"currencyMvpNotice": "Au MVP, tous les comptes sont en CAD. Le support multi-devises arrivera dans une version ultérieure.",
|
||||
"save": "Enregistrer",
|
||||
"create": "Créer le compte"
|
||||
"create": "Créer le compte",
|
||||
"vehicleType": {
|
||||
"label": "Véhicule fiscal",
|
||||
"none": "Aucun",
|
||||
"unregistered": "Non-enregistré",
|
||||
"tfsa": "CELI",
|
||||
"rrsp": "REER",
|
||||
"rrif": "FERR",
|
||||
"fhsa": "CELIAPP",
|
||||
"resp": "REEE",
|
||||
"hint": "Enveloppe fiscale du compte (CELI, REER…). Optionnel — la classe d'actif reste le type."
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
|
|
@ -1680,7 +1691,10 @@
|
|||
"kindHintSimple": "Saisie d'un montant direct (ex: solde de compte courant).",
|
||||
"kindHintPriced": "Saisie d'une quantité × prix unitaire (ex: actions, cryptomonnaies). Un symbole est optionnel pour les comptes liés (requis seulement pour la récupération automatique des prix).",
|
||||
"simpleOnlyNotice": "Les types cotés (actions, crypto) seront disponibles dans une prochaine version.",
|
||||
"create": "Créer le type"
|
||||
"create": "Créer le type",
|
||||
"customLabel": "Libellé",
|
||||
"customLabelPlaceholder": "ex. Mon FERR, Épargne voyage",
|
||||
"customLabelHint": "Nom affiché pour ce type. Laisser vide pour utiliser le libellé par défaut."
|
||||
},
|
||||
"assetType": {
|
||||
"label": "Type d'actif",
|
||||
|
|
@ -1759,7 +1773,8 @@
|
|||
"snapshot_priced_quantity_required": "La quantité est obligatoire pour les comptes cotés.",
|
||||
"snapshot_priced_unit_price_required": "Le prix unitaire est obligatoire pour les comptes cotés.",
|
||||
"snapshot_priced_value_mismatch": "La valeur saisie ne correspond pas à quantité × prix unitaire.",
|
||||
"snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix."
|
||||
"snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix.",
|
||||
"vehicle_type_invalid": "Véhicule fiscal invalide."
|
||||
},
|
||||
"returns": {
|
||||
"partialTooltip": "Rendement partiel : un snapshot manque pour calculer la performance sur cette période.",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import type {
|
|||
import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
|
||||
import AccountForm from "../components/balance/AccountForm";
|
||||
import type { CreateBalanceCategoryInput } from "../services/balance.service";
|
||||
import {
|
||||
renderCategoryLabelFromAccount,
|
||||
renderCategoryLabelFromCategory,
|
||||
} from "../utils/renderCategoryLabel";
|
||||
|
||||
type Tab = "accounts" | "categories";
|
||||
|
||||
|
|
@ -66,7 +70,7 @@ export default function AccountsPage() {
|
|||
}, [state.accounts]);
|
||||
|
||||
const renderCategoryLabel = (cat: BalanceCategory) =>
|
||||
t(cat.i18n_key, { defaultValue: cat.key });
|
||||
renderCategoryLabelFromCategory(cat, t);
|
||||
|
||||
const closeAccountForm = () => {
|
||||
setShowAccountForm(false);
|
||||
|
|
@ -251,9 +255,7 @@ export default function AccountsPage() {
|
|||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{t(acc.category_i18n_key, {
|
||||
defaultValue: acc.category_key,
|
||||
})}
|
||||
{renderCategoryLabelFromAccount(acc, t)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-[var(--muted-foreground)]">
|
||||
{acc.symbol ?? "—"}
|
||||
|
|
@ -412,8 +414,14 @@ export default function AccountsPage() {
|
|||
t("balance.category.actions.renamePrompt"),
|
||||
renderCategoryLabel(cat)
|
||||
);
|
||||
if (next && next.trim()) {
|
||||
editCategory(cat.id, { i18n_key: next.trim() });
|
||||
// Write the human label to custom_label, never to
|
||||
// i18n_key — preserves the bundled translation
|
||||
// (fixes bug I). A blank/empty answer clears the
|
||||
// override and falls back to t(i18n_key).
|
||||
if (next !== null) {
|
||||
editCategory(cat.id, {
|
||||
custom_label: next.trim() || null,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
|||
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
||||
import StarterAccountsModal from "../components/balance/StarterAccountsModal";
|
||||
import { getPreference, setPreference } from "../services/userPreferenceService";
|
||||
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
|
||||
|
||||
const STARTER_PREF_KEY = "balance_starter_proposed";
|
||||
|
||||
|
|
@ -143,9 +144,7 @@ export default function BalancePage() {
|
|||
const m: Record<string, string> = {};
|
||||
for (const a of state.accountsLatest) {
|
||||
if (!m[a.category_key]) {
|
||||
m[a.category_key] = t(a.category_i18n_key, {
|
||||
defaultValue: a.category_key,
|
||||
});
|
||||
m[a.category_key] = renderCategoryLabelFromAccount(a, t);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import type {
|
|||
BalanceTransferDirection,
|
||||
BalanceVehicleType,
|
||||
} from "../shared/types";
|
||||
import { BALANCE_CURRENCY_CAD } from "../shared/types";
|
||||
import { BALANCE_CURRENCY_CAD, BALANCE_VEHICLE_TYPES } from "../shared/types";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Errors — typed so the UI can show distinct i18n messages.
|
||||
|
|
@ -345,15 +345,12 @@ export interface CreateBalanceAccountInput {
|
|||
vehicle_type?: BalanceVehicleType | null;
|
||||
}
|
||||
|
||||
/** The six recognized fiscal envelopes. Kept in sync with the SQL CHECK. */
|
||||
const VEHICLE_TYPES: readonly BalanceVehicleType[] = [
|
||||
"unregistered",
|
||||
"tfsa",
|
||||
"rrsp",
|
||||
"rrif",
|
||||
"fhsa",
|
||||
"resp",
|
||||
];
|
||||
/**
|
||||
* The six recognized fiscal envelopes. Re-uses the shared/types const so the
|
||||
* service validation and the account-form dropdown (Issue #203) share one
|
||||
* source of truth. Kept in sync with the SQL CHECK.
|
||||
*/
|
||||
const VEHICLE_TYPES = BALANCE_VEHICLE_TYPES;
|
||||
|
||||
/**
|
||||
* Validate an optional `vehicle_type` against the fiscal-envelope enum.
|
||||
|
|
|
|||
|
|
@ -588,6 +588,21 @@ export type BalanceVehicleType =
|
|||
| "fhsa"
|
||||
| "resp";
|
||||
|
||||
/**
|
||||
* The six recognized fiscal envelopes, in display order. Single source of
|
||||
* truth shared by the account form dropdown (Issue #203) and the service-side
|
||||
* `normalizeVehicleType` validation. Kept in sync with the SQL CHECK on
|
||||
* `balance_accounts.vehicle_type`.
|
||||
*/
|
||||
export const BALANCE_VEHICLE_TYPES: readonly BalanceVehicleType[] = [
|
||||
"unregistered",
|
||||
"tfsa",
|
||||
"rrsp",
|
||||
"rrif",
|
||||
"fhsa",
|
||||
"resp",
|
||||
];
|
||||
|
||||
export const BALANCE_CURRENCY_CAD = "CAD";
|
||||
|
||||
export interface BalanceCategory {
|
||||
|
|
|
|||
94
src/utils/renderCategoryLabel.test.ts
Normal file
94
src/utils/renderCategoryLabel.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
renderCategoryLabel,
|
||||
renderCategoryLabelFromCategory,
|
||||
renderCategoryLabelFromAccount,
|
||||
} from "./renderCategoryLabel";
|
||||
|
||||
// Fake `t`: echoes the key prefixed, but honours defaultValue for unknown keys
|
||||
// the way react-i18next does. We model "known" keys explicitly.
|
||||
const known: Record<string, string> = {
|
||||
"balance.category.cash": "Liquidités",
|
||||
};
|
||||
const t = (key: string, options?: { defaultValue?: string }) =>
|
||||
known[key] ?? options?.defaultValue ?? key;
|
||||
|
||||
describe("renderCategoryLabel", () => {
|
||||
it("prefers a non-empty custom_label over the i18n translation", () => {
|
||||
expect(
|
||||
renderCategoryLabel("Mon CELI", "balance.category.cash", "cash", t)
|
||||
).toBe("Mon CELI");
|
||||
});
|
||||
|
||||
it("trims custom_label and treats whitespace-only as absent", () => {
|
||||
expect(
|
||||
renderCategoryLabel(" ", "balance.category.cash", "cash", t)
|
||||
).toBe("Liquidités");
|
||||
});
|
||||
|
||||
it("falls back to the i18n translation when custom_label is null", () => {
|
||||
expect(
|
||||
renderCategoryLabel(null, "balance.category.cash", "cash", t)
|
||||
).toBe("Liquidités");
|
||||
});
|
||||
|
||||
it("falls back to the i18n translation when custom_label is undefined", () => {
|
||||
expect(
|
||||
renderCategoryLabel(undefined, "balance.category.cash", "cash", t)
|
||||
).toBe("Liquidités");
|
||||
});
|
||||
|
||||
it("falls back to the raw key when no translation exists", () => {
|
||||
expect(
|
||||
renderCategoryLabel(null, "balance.category.unknown", "unknown", t)
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns a trimmed custom_label (leading/trailing spaces removed)", () => {
|
||||
expect(
|
||||
renderCategoryLabel(" Épargne ", "balance.category.cash", "cash", t)
|
||||
).toBe("Épargne");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderCategoryLabelFromCategory", () => {
|
||||
it("maps the BalanceCategory shape", () => {
|
||||
expect(
|
||||
renderCategoryLabelFromCategory(
|
||||
{ custom_label: null, i18n_key: "balance.category.cash", key: "cash" },
|
||||
t
|
||||
)
|
||||
).toBe("Liquidités");
|
||||
expect(
|
||||
renderCategoryLabelFromCategory(
|
||||
{ custom_label: "X", i18n_key: "balance.category.cash", key: "cash" },
|
||||
t
|
||||
)
|
||||
).toBe("X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderCategoryLabelFromAccount", () => {
|
||||
it("maps the JOIN-prefixed account shape", () => {
|
||||
expect(
|
||||
renderCategoryLabelFromAccount(
|
||||
{
|
||||
category_custom_label: null,
|
||||
category_i18n_key: "balance.category.cash",
|
||||
category_key: "cash",
|
||||
},
|
||||
t
|
||||
)
|
||||
).toBe("Liquidités");
|
||||
expect(
|
||||
renderCategoryLabelFromAccount(
|
||||
{
|
||||
category_custom_label: "Compte X",
|
||||
category_i18n_key: "balance.category.cash",
|
||||
category_key: "cash",
|
||||
},
|
||||
t
|
||||
)
|
||||
).toBe("Compte X");
|
||||
});
|
||||
});
|
||||
73
src/utils/renderCategoryLabel.ts
Normal file
73
src/utils/renderCategoryLabel.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// renderCategoryLabel — single source of truth for how a balance category's
|
||||
// label is shown in the UI (Bilan axe véhicule, Étape 1 / Issue #203).
|
||||
//
|
||||
// Precedence: an explicit `custom_label` (trimmed, non-empty) always wins;
|
||||
// otherwise fall back to the i18n translation of `i18n_key`, and finally to
|
||||
// the raw `key` if no translation exists. This is what makes category
|
||||
// renaming (which now writes `custom_label`, never `i18n_key`) coexist with
|
||||
// the seeded translations and fixes bug I.
|
||||
//
|
||||
// The 5 call sites carry the same three scalars under DIFFERENT field names:
|
||||
// - BalanceCategory → custom_label / i18n_key / key
|
||||
// - BalanceAccountWithCategory → category_custom_label / category_i18n_key / category_key
|
||||
// - AccountLatestSnapshot → category_custom_label / category_i18n_key / category_key
|
||||
// To avoid a shape mismatch (the "affichage partiel" caveat), the core helper
|
||||
// takes explicit scalar args; thin adapters normalize each object shape.
|
||||
|
||||
/** Minimal i18n translate signature (matches react-i18next's `t`). */
|
||||
type TranslateFn = (
|
||||
key: string,
|
||||
options?: { defaultValue?: string }
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Resolve a category's display label from its three label scalars.
|
||||
*
|
||||
* @param customLabel User-facing override (`custom_label`). NULL/blank ignored.
|
||||
* @param i18nKey Translation key (`i18n_key`), e.g. 'balance.category.cash'.
|
||||
* @param key Stable lookup key (`key`), used as the last-resort label.
|
||||
* @param t Translate function.
|
||||
*/
|
||||
export function renderCategoryLabel(
|
||||
customLabel: string | null | undefined,
|
||||
i18nKey: string,
|
||||
key: string,
|
||||
t: TranslateFn
|
||||
): string {
|
||||
const trimmed = customLabel?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
return t(i18nKey, { defaultValue: key });
|
||||
}
|
||||
|
||||
/** Adapter for a `BalanceCategory`-shaped object. */
|
||||
export function renderCategoryLabelFromCategory(
|
||||
cat: {
|
||||
custom_label?: string | null;
|
||||
i18n_key: string;
|
||||
key: string;
|
||||
},
|
||||
t: TranslateFn
|
||||
): string {
|
||||
return renderCategoryLabel(cat.custom_label, cat.i18n_key, cat.key, t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter for the JOIN-prefixed shape carried by `BalanceAccountWithCategory`
|
||||
* and `AccountLatestSnapshot` (`category_custom_label` / `category_i18n_key` /
|
||||
* `category_key`).
|
||||
*/
|
||||
export function renderCategoryLabelFromAccount(
|
||||
acc: {
|
||||
category_custom_label?: string | null;
|
||||
category_i18n_key: string;
|
||||
category_key: string;
|
||||
},
|
||||
t: TranslateFn
|
||||
): string {
|
||||
return renderCategoryLabel(
|
||||
acc.category_custom_label,
|
||||
acc.category_i18n_key,
|
||||
acc.category_key,
|
||||
t
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue