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 { Button, Input } from "@/ui";
import { IListArticles_Response_DTO, MoneyValue } from "@shared/contexts"; import { IListArticles_Response_DTO, MoneyValue } from "@shared/contexts";
import { CellContext, ColumnDef, createColumnHelper, Row, RowData } from "@tanstack/react-table"; import { CellContext, ColumnDef, createColumnHelper, Row, RowData } from "@tanstack/react-table";
import { t } from "i18next";
import { PackagePlusIcon } from "lucide-react"; import { PackagePlusIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
type QuantityValue = number | ""; type QuantityValue = number | "";
type CatalogPickerTableMeta = { type CatalogPickerTableMeta = {
quantities: QuantityValue[]; quantities: QuantityValue[];
t: (key: string) => string;
setQuantity: (index: number, quantity: QuantityValue) => void; setQuantity: (index: number, quantity: QuantityValue) => void;
selectArticle: (article: IListArticles_Response_DTO, index: number) => 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' /> <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> </Button>
); );
} }
@ -79,6 +80,7 @@ export const CatalogPickerDataTable = ({
onSelect: (data: unknown, quantity: number) => void; onSelect: (data: unknown, quantity: number) => void;
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t, i18n } = useTranslation();
const { pagination, globalFilter, isFiltered } = useDataTableContext(); const { pagination, globalFilter, isFiltered } = useDataTableContext();
const [quantities, setQuantities] = useState<QuantityValue[]>([]); const [quantities, setQuantities] = useState<QuantityValue[]>([]);
@ -111,12 +113,13 @@ export const CatalogPickerDataTable = ({
() => ({ () => ({
catalogPicker: { catalogPicker: {
quantities, quantities,
t,
setQuantity: handleQuantity, setQuantity: handleQuantity,
selectArticle: (article, index) => selectArticle: (article, index) =>
onSelect(article, quantities[index] === "" ? 1 : (quantities[index] ?? 1)), onSelect(article, quantities[index] === "" ? 1 : (quantities[index] ?? 1)),
}, },
}), }),
[handleQuantity, onSelect, quantities] [handleQuantity, onSelect, quantities, t]
); );
const columns = useMemo<Array<ColumnDef<IListArticles_Response_DTO, any>>>( const columns = useMemo<Array<ColumnDef<IListArticles_Response_DTO, any>>>(
@ -155,7 +158,7 @@ export const CatalogPickerDataTable = ({
cell: AddArticleCell, cell: AddArticleCell,
}), }),
], ],
[] [t, i18n.resolvedLanguage]
); );
/*const columns2 = useMemo<ColumnDef<IListArticles_Response_DTO>[]>(() => { /*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 { useToast } from "@/ui/use-toast";
import { IListQuotes_Response_DTO, UTCDateValue } from "@shared/contexts"; import { IListQuotes_Response_DTO, UTCDateValue } from "@shared/contexts";
import { ColumnDef, Row } from "@tanstack/react-table"; import { ColumnDef, Row } from "@tanstack/react-table";
import { t } from "i18next";
import { ArchiveIcon, CopyIcon, DownloadIcon, EditIcon } from "lucide-react"; import { ArchiveIcon, CopyIcon, DownloadIcon, EditIcon } from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuotes } from "../hooks"; import { useQuotes } from "../hooks";
import { DownloadQuoteDialog } from "./DownloadQuoteDialog"; import { DownloadQuoteDialog } from "./DownloadQuoteDialog";
@ -30,6 +30,7 @@ export const QuotesDataTable = ({
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { t, i18n } = useTranslation();
const tableId = useId(); const tableId = useId();
const previewId = useId(); const previewId = useId();
@ -356,7 +357,7 @@ export const QuotesDataTable = ({
]; ];
return preview ? columns : [...columns, ...actionColumn]; return preview ? columns : [...columns, ...actionColumn];
}, [preview]); }, [preview, t, i18n.resolvedLanguage]);
const { table } = useDataTable({ const { table } = useDataTable({
data: data?.items ?? [], data: data?.items ?? [],

View File

@ -6,7 +6,7 @@ import { Checkbox } from "@/ui";
import { ColumnDef, Row } from "@tanstack/react-table"; import { ColumnDef, Row } from "@tanstack/react-table";
import { useMemo } from "react"; import { useMemo } from "react";
import { t } from "i18next"; import { useTranslation } from "react-i18next";
import { QuoteItemsSortableDragHandleCell } from "../components/QuoteItemsSortableDragHandleCell"; import { QuoteItemsSortableDragHandleCell } from "../components/QuoteItemsSortableDragHandleCell";
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -19,6 +19,7 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
rowActionFn?: DataTablaRowActionFunction<TData>; rowActionFn?: DataTablaRowActionFunction<TData>;
} = {} } = {}
): ColumnDef<TData, TValue>[] { ): ColumnDef<TData, TValue>[] {
const { t, i18n } = useTranslation();
const { const {
enableDragHandleColumn = false, enableDragHandleColumn = false,
enableSelectionColumn = false, enableSelectionColumn = false,
@ -94,5 +95,5 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
} }
return columns; 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 useIsLoggedIn = jest.fn();
const useGetProfile = jest.fn(); const useGetProfile = jest.fn();
const changeLanguage = jest.fn(); const changeLanguage = jest.fn();
const i18n = { language: "en", resolvedLanguage: "en", changeLanguage };
jest.mock("@/lib/hooks", () => ({ jest.mock("@/lib/hooks", () => ({
useIsLoggedIn: () => useIsLoggedIn(), useIsLoggedIn: () => useIsLoggedIn(),
@ -18,12 +19,11 @@ jest.mock("@/components", () => ({
})); }));
jest.mock("react-i18next", () => ({ jest.mock("react-i18next", () => ({
useTranslation: () => ({ i18n: { language: "en", changeLanguage } }), useTranslation: () => ({ i18n }),
})); }));
const renderProtectedRoute = () => { const renderProtectedRoute = (container = document.createElement("div")) => {
const container = document.createElement("div"); if (!container.parentElement) document.body.appendChild(container);
document.body.appendChild(container);
act(() => { act(() => {
renderDom( renderDom(
@ -54,6 +54,8 @@ describe("ProtectedRoute", () => {
beforeEach(() => { beforeEach(() => {
changeLanguage.mockClear(); changeLanguage.mockClear();
i18n.language = "en";
i18n.resolvedLanguage = "en";
useIsLoggedIn.mockReturnValue({ useIsLoggedIn.mockReturnValue({
isSuccess: true, isSuccess: true,
isLoading: false, isLoading: false,
@ -75,7 +77,7 @@ describe("ProtectedRoute", () => {
expect(container.querySelector("[data-testid='login']")).toBeNull(); 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({ useGetProfile.mockReturnValue({
isSuccess: true, isSuccess: true,
isLoading: false, isLoading: false,
@ -85,8 +87,29 @@ describe("ProtectedRoute", () => {
const container = renderProtectedRoute(); 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"); 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", () => { 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, enabled: isLoggedInSuccess && authenticated,
}); });
React.useEffect(() => { const profileLanguage = profile?.lang_code;
if (isProfileSuccess && profile?.lang_code && i18n.language !== profile.lang_code) { const activeLanguage = i18n.resolvedLanguage ?? i18n.language;
void i18n.changeLanguage(profile.lang_code); const isLanguageSynchronized = !profileLanguage || activeLanguage === profileLanguage;
}
}, [isProfileSuccess, profile?.lang_code, i18n]);
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 />; return <LoadingOverlay />;
} }