Corregir sincronización de idioma tras login y reactividad i18n

This commit is contained in:
David Arranz 2026-10-07 18:37:46 +02:00
parent f8d0979059
commit e6001d62a9
5 changed files with 57 additions and 20 deletions

View File

@ -7,15 +7,16 @@ import { useDataTable, useDataTableContext } from "@/lib/hooks";
import { Button, Input } from "@/ui";
import { IListArticles_Response_DTO, MoneyValue } from "@shared/contexts";
import { CellContext, ColumnDef, createColumnHelper, Row, RowData } from "@tanstack/react-table";
import { t } from "i18next";
import { PackagePlusIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
type QuantityValue = number | "";
type CatalogPickerTableMeta = {
quantities: QuantityValue[];
t: (key: string) => string;
setQuantity: (index: number, quantity: QuantityValue) => void;
selectArticle: (article: IListArticles_Response_DTO, index: number) => void;
};
@ -68,7 +69,7 @@ function AddArticleCell({ row, table }: CellContext<IListArticles_Response_DTO,
}}
>
<PackagePlusIcon className='h-3.5 w-3.5' />
<span className='sr-only xl:not-sr-only xl:whitespace-nowrap'>{t("common.add")}</span>
<span className='sr-only xl:not-sr-only xl:whitespace-nowrap'>{catalogPicker?.t("common.add")}</span>
</Button>
);
}
@ -79,6 +80,7 @@ export const CatalogPickerDataTable = ({
onSelect: (data: unknown, quantity: number) => void;
}) => {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const { pagination, globalFilter, isFiltered } = useDataTableContext();
const [quantities, setQuantities] = useState<QuantityValue[]>([]);
@ -111,12 +113,13 @@ export const CatalogPickerDataTable = ({
() => ({
catalogPicker: {
quantities,
t,
setQuantity: handleQuantity,
selectArticle: (article, index) =>
onSelect(article, quantities[index] === "" ? 1 : (quantities[index] ?? 1)),
},
}),
[handleQuantity, onSelect, quantities]
[handleQuantity, onSelect, quantities, t]
);
const columns = useMemo<Array<ColumnDef<IListArticles_Response_DTO, any>>>(
@ -155,7 +158,7 @@ export const CatalogPickerDataTable = ({
cell: AddArticleCell,
}),
],
[]
[t, i18n.resolvedLanguage]
);
/*const columns2 = useMemo<ColumnDef<IListArticles_Response_DTO>[]>(() => {

View File

@ -12,9 +12,9 @@ import { Button, Card, CardContent, Tooltip, TooltipContent, TooltipTrigger } fr
import { useToast } from "@/ui/use-toast";
import { IListQuotes_Response_DTO, UTCDateValue } from "@shared/contexts";
import { ColumnDef, Row } from "@tanstack/react-table";
import { t } from "i18next";
import { ArchiveIcon, CopyIcon, DownloadIcon, EditIcon } from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useQuotes } from "../hooks";
import { DownloadQuoteDialog } from "./DownloadQuoteDialog";
@ -30,6 +30,7 @@ export const QuotesDataTable = ({
}) => {
const navigate = useNavigate();
const { toast } = useToast();
const { t, i18n } = useTranslation();
const tableId = useId();
const previewId = useId();
@ -356,7 +357,7 @@ export const QuotesDataTable = ({
];
return preview ? columns : [...columns, ...actionColumn];
}, [preview]);
}, [preview, t, i18n.resolvedLanguage]);
const { table } = useDataTable({
data: data?.items ?? [],

View File

@ -6,7 +6,7 @@ import { Checkbox } from "@/ui";
import { ColumnDef, Row } from "@tanstack/react-table";
import { useMemo } from "react";
import { t } from "i18next";
import { useTranslation } from "react-i18next";
import { QuoteItemsSortableDragHandleCell } from "../components/QuoteItemsSortableDragHandleCell";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -19,6 +19,7 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
rowActionFn?: DataTablaRowActionFunction<TData>;
} = {}
): ColumnDef<TData, TValue>[] {
const { t, i18n } = useTranslation();
const {
enableDragHandleColumn = false,
enableSelectionColumn = false,
@ -94,5 +95,5 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
}
return columns;
}, [enableActionsColumn, enableDragHandleColumn, enableSelectionColumn]);
}, [enableActionsColumn, enableDragHandleColumn, enableSelectionColumn, t, i18n.resolvedLanguage]);
}

View File

@ -6,6 +6,7 @@ import { ProtectedRoute } from "./ProtectedRoute";
const useIsLoggedIn = jest.fn();
const useGetProfile = jest.fn();
const changeLanguage = jest.fn();
const i18n = { language: "en", resolvedLanguage: "en", changeLanguage };
jest.mock("@/lib/hooks", () => ({
useIsLoggedIn: () => useIsLoggedIn(),
@ -18,12 +19,11 @@ jest.mock("@/components", () => ({
}));
jest.mock("react-i18next", () => ({
useTranslation: () => ({ i18n: { language: "en", changeLanguage } }),
useTranslation: () => ({ i18n }),
}));
const renderProtectedRoute = () => {
const container = document.createElement("div");
document.body.appendChild(container);
const renderProtectedRoute = (container = document.createElement("div")) => {
if (!container.parentElement) document.body.appendChild(container);
act(() => {
renderDom(
@ -54,6 +54,8 @@ describe("ProtectedRoute", () => {
beforeEach(() => {
changeLanguage.mockClear();
i18n.language = "en";
i18n.resolvedLanguage = "en";
useIsLoggedIn.mockReturnValue({
isSuccess: true,
isLoading: false,
@ -75,7 +77,7 @@ describe("ProtectedRoute", () => {
expect(container.querySelector("[data-testid='login']")).toBeNull();
});
it("renders the protected route after resolving a valid profile", () => {
it("waits for the profile language before mounting the protected route", () => {
useGetProfile.mockReturnValue({
isSuccess: true,
isLoading: false,
@ -85,8 +87,29 @@ describe("ProtectedRoute", () => {
const container = renderProtectedRoute();
expect(container.querySelector("[data-testid='quotes']")).not.toBeNull();
expect(container.querySelector("[data-testid='loading']")).not.toBeNull();
expect(container.querySelector("[data-testid='quotes']")).toBeNull();
expect(changeLanguage).toHaveBeenCalledWith("es");
i18n.language = "es";
i18n.resolvedLanguage = "es";
renderProtectedRoute(container);
expect(container.querySelector("[data-testid='quotes']")).not.toBeNull();
});
it("mounts immediately when the active language already matches the profile", () => {
useGetProfile.mockReturnValue({
isSuccess: true,
isLoading: false,
isFetching: false,
data: { id: "current-user", lang_code: "en" },
});
const container = renderProtectedRoute();
expect(container.querySelector("[data-testid='quotes']")).not.toBeNull();
expect(changeLanguage).not.toHaveBeenCalled();
});
it("renders an error instead of treating a profile request failure as a logout", () => {

View File

@ -29,13 +29,22 @@ export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
enabled: isLoggedInSuccess && authenticated,
});
React.useEffect(() => {
if (isProfileSuccess && profile?.lang_code && i18n.language !== profile.lang_code) {
void i18n.changeLanguage(profile.lang_code);
}
}, [isProfileSuccess, profile?.lang_code, i18n]);
const profileLanguage = profile?.lang_code;
const activeLanguage = i18n.resolvedLanguage ?? i18n.language;
const isLanguageSynchronized = !profileLanguage || activeLanguage === profileLanguage;
if (isLoggedInLoading || isProfileLoading || (isProfileSuccess && profile === null && isProfileFetching)) {
React.useEffect(() => {
if (isProfileSuccess && profileLanguage && !isLanguageSynchronized) {
void i18n.changeLanguage(profileLanguage);
}
}, [isProfileSuccess, profileLanguage, isLanguageSynchronized, i18n]);
if (
isLoggedInLoading ||
isProfileLoading ||
(isProfileSuccess && profile === null && isProfileFetching) ||
(isProfileSuccess && profile?.id && !isLanguageSynchronized)
) {
return <LoadingOverlay />;
}