- Implement tests for UpdateProfile use case to validate language persistence and behavior when lang_code is omitted.
- Create a new client asset file for future use.
This commit is contained in:
parent
839e1a5222
commit
988790f904
@ -19,6 +19,11 @@ import {
|
||||
Form,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
} from "@/ui";
|
||||
|
||||
@ -27,6 +32,7 @@ import { AlertCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { Trans } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useUnsavedChangesNotifier } from "@/lib/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -40,11 +46,13 @@ export const SettingsEditor = () => {
|
||||
const [activeSection, setActiveSection] = useState("profile");
|
||||
const { useOne, useUpdate, useUploadLogo } = useSettings();
|
||||
const { toast } = useToast();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const { data, status, error: queryError } = useOne();
|
||||
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
lang_code: "es",
|
||||
contact_information: "",
|
||||
default_payment_method: "",
|
||||
default_notes: "",
|
||||
@ -65,7 +73,7 @@ export const SettingsEditor = () => {
|
||||
|
||||
const form = useForm<SettingsDataForm>({
|
||||
mode: "onBlur",
|
||||
values: data?.dealer,
|
||||
values: data ? { ...data.dealer, lang_code: data.lang_code } : undefined,
|
||||
defaultValues,
|
||||
/*resolver: joiResolver(
|
||||
Joi.object({
|
||||
@ -130,6 +138,9 @@ export const SettingsEditor = () => {
|
||||
},
|
||||
//onSettled: () => {},
|
||||
onSuccess: () => {
|
||||
if (i18n.language !== data.lang_code) {
|
||||
void i18n.changeLanguage(data.lang_code ?? "es");
|
||||
}
|
||||
reset(getValues());
|
||||
toast({ description: "Ajustes guardados", variant: "success" });
|
||||
},
|
||||
@ -189,6 +200,38 @@ export const SettingsEditor = () => {
|
||||
</a>
|
||||
</nav>
|
||||
<div className={cn("grid gap-6", activeSection === "profile" ? "visible" : "hidden")}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey='settings.form_fields.language.label' />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans i18nKey='settings.form_fields.language.desc' />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name='lang_code'
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger aria-label={t("settings.form_fields.language.label")}>
|
||||
<SelectValue placeholder={t("settings.form_fields.language.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='es'>Español</SelectItem>
|
||||
<SelectItem value='en'>English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className='px-6 py-4 border-t'>
|
||||
<Button>
|
||||
<Trans i18nKey='common.save' />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
|
||||
@ -17,11 +17,13 @@ export type UseSettingsGetParamsType = {
|
||||
export const useSettings = (params?: UseSettingsGetParamsType) => {
|
||||
const dataSource = useDataSource();
|
||||
const keys = useQueryKey();
|
||||
const settingsQueryKey = keys().data().resource("settings").action("one").id("me").params().get();
|
||||
const profileQueryKey = keys().auth().action("profile").get();
|
||||
|
||||
return {
|
||||
useOne: () =>
|
||||
useOne<IGetProfileResponse_DTO>({
|
||||
queryKey: keys().data().resource("settings").action("one").id("me").params().get(),
|
||||
queryKey: settingsQueryKey,
|
||||
queryFn: () =>
|
||||
dataSource.getOne({
|
||||
resource: "profile",
|
||||
@ -34,7 +36,7 @@ export const useSettings = (params?: UseSettingsGetParamsType) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IUpdateProfileResponse_DTO, TDataSourceError, IUpdateProfile_Request_DTO>({
|
||||
mutationKey: keys().data().resource("settings").action("one").id("me").params().get(),
|
||||
mutationKey: settingsQueryKey,
|
||||
mutationFn: (data) => {
|
||||
return dataSource.updateOne({
|
||||
resource: "profile",
|
||||
@ -42,10 +44,23 @@ export const useSettings = (params?: UseSettingsGetParamsType) => {
|
||||
id: "",
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["data", "default", "settings"],
|
||||
});
|
||||
onSuccess: (updatedDealer, variables) => {
|
||||
const updateCachedProfile = (profile: IGetProfileResponse_DTO | undefined) =>
|
||||
profile
|
||||
? {
|
||||
...profile,
|
||||
lang_code: variables.lang_code ?? profile.lang_code,
|
||||
dealer: { ...profile.dealer, ...updatedDealer },
|
||||
}
|
||||
: profile;
|
||||
|
||||
queryClient.setQueryData<IGetProfileResponse_DTO>(settingsQueryKey, updateCachedProfile);
|
||||
queryClient.setQueryData<IGetProfileResponse_DTO>(profileQueryKey, updateCachedProfile);
|
||||
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: settingsQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: profileQueryKey }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@ -5,6 +5,7 @@ import { ProtectedRoute } from "./ProtectedRoute";
|
||||
|
||||
const useIsLoggedIn = jest.fn();
|
||||
const useGetProfile = jest.fn();
|
||||
const changeLanguage = jest.fn();
|
||||
|
||||
jest.mock("@/lib/hooks", () => ({
|
||||
useIsLoggedIn: () => useIsLoggedIn(),
|
||||
@ -16,6 +17,10 @@ jest.mock("@/components", () => ({
|
||||
LoadingOverlay: () => <div data-testid='loading' />,
|
||||
}));
|
||||
|
||||
jest.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ i18n: { language: "en", changeLanguage } }),
|
||||
}));
|
||||
|
||||
const renderProtectedRoute = () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@ -48,6 +53,7 @@ describe("ProtectedRoute", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
changeLanguage.mockClear();
|
||||
useIsLoggedIn.mockReturnValue({
|
||||
isSuccess: true,
|
||||
isLoading: false,
|
||||
@ -80,6 +86,7 @@ describe("ProtectedRoute", () => {
|
||||
const container = renderProtectedRoute();
|
||||
|
||||
expect(container.querySelector("[data-testid='quotes']")).not.toBeNull();
|
||||
expect(changeLanguage).toHaveBeenCalledWith("es");
|
||||
});
|
||||
|
||||
it("renders an error instead of treating a profile request failure as a logout", () => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ErrorOverlay, LoadingOverlay } from "@/components";
|
||||
import { useGetProfile, useIsLoggedIn } from "@/lib/hooks";
|
||||
import React, { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
type ProctectRouteProps = {
|
||||
@ -9,7 +10,7 @@ type ProctectRouteProps = {
|
||||
|
||||
export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
|
||||
const location = useLocation();
|
||||
//const { i18n } = useTranslation();
|
||||
const { i18n } = useTranslation();
|
||||
const id = useId();
|
||||
|
||||
const {
|
||||
@ -28,12 +29,11 @@ export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
|
||||
enabled: isLoggedInSuccess && authenticated,
|
||||
});
|
||||
|
||||
// Cambiamos el idioma de la aplicación si es necesario
|
||||
/*React.useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (isProfileSuccess && profile?.lang_code && i18n.language !== profile.lang_code) {
|
||||
i18n.changeLanguage(profile.lang_code);
|
||||
void i18n.changeLanguage(profile.lang_code);
|
||||
}
|
||||
}, [isProfileSuccess, profile?.lang_code, i18n]);*/
|
||||
}, [isProfileSuccess, profile?.lang_code, i18n]);
|
||||
|
||||
if (isLoggedInLoading || isProfileLoading || (isProfileSuccess && profile === null && isProfileFetching)) {
|
||||
return <LoadingOverlay />;
|
||||
|
||||
@ -438,6 +438,11 @@
|
||||
}
|
||||
},
|
||||
"form_fields": {
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"placeholder": "Select a language",
|
||||
"desc": "Preferred language for the interface"
|
||||
},
|
||||
"image": {
|
||||
"label": "Logotype",
|
||||
"placeholder": "",
|
||||
@ -485,4 +490,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -440,6 +440,11 @@
|
||||
}
|
||||
},
|
||||
"form_fields": {
|
||||
"language": {
|
||||
"label": "Idioma",
|
||||
"placeholder": "Selecciona un idioma",
|
||||
"desc": "Idioma preferido para la interfaz"
|
||||
},
|
||||
"logo": {
|
||||
"label": "Logotipo",
|
||||
"placeholder": "",
|
||||
@ -498,4 +503,4 @@
|
||||
"form_fields": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
254
dist/client/assets/index-BVZyG6kc.js
vendored
Normal file
254
dist/client/assets/index-BVZyG6kc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
254
dist/client/assets/index-CCfX-UYJ.js
vendored
254
dist/client/assets/index-CCfX-UYJ.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
dist/client/index.html
vendored
2
dist/client/index.html
vendored
@ -8,7 +8,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" />
|
||||
<title>Presupuestador Uecko</title>
|
||||
<script type="module" crossorigin src="/assets/index-CJmQk4ZY.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-QDyDWtz5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CBtV2-Es.css">
|
||||
</head>
|
||||
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
import { Language, UniqueID } from "@shared/contexts";
|
||||
import { createProfileMapper } from "../infrastructure/mappers/profile.mapper";
|
||||
import { createUserMapper } from "@/contexts/users/infrastructure/mappers/user.mapper";
|
||||
import { UpdateProfileUseCase } from "./UpdateProfile.useCase";
|
||||
|
||||
const userId = "770672b9-5a87-4e8e-a8af-8b44a7432e12";
|
||||
|
||||
const profileSource = {
|
||||
default_show_line_discount_column: true,
|
||||
dataValues: {
|
||||
id: "9b3cb7f7-3747-4eb4-bf9e-82f2f1ad03d6",
|
||||
name: "Dealer",
|
||||
status: "actived",
|
||||
lang_code: "es",
|
||||
currency_code: "EUR",
|
||||
logo: "",
|
||||
contact_information: "",
|
||||
default_payment_method: "",
|
||||
default_notes: "",
|
||||
default_legal_terms: "",
|
||||
default_quote_validity: "",
|
||||
default_tax: 2100,
|
||||
default_tax_label: "% IVA",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const userSource = {
|
||||
roles: ["ROLE_USER"],
|
||||
dataValues: {
|
||||
id: userId,
|
||||
name: "User",
|
||||
email: "user@example.test",
|
||||
password: "$2b$10$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu",
|
||||
lang_code: "es",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const profileDTO = {
|
||||
contact_information: "Contact",
|
||||
default_payment_method: "Transfer",
|
||||
default_notes: "Notes",
|
||||
default_legal_terms: "Terms",
|
||||
default_quote_validity: "30 days",
|
||||
default_tax: { amount: 2100, scale: 2 },
|
||||
default_tax_label: "% IVA",
|
||||
default_show_line_discount_column: true,
|
||||
logo: "",
|
||||
};
|
||||
|
||||
describe("UpdateProfileUseCase language", () => {
|
||||
const profile = createProfileMapper({} as any).mapToDomain(profileSource);
|
||||
const user = createUserMapper({} as any).mapToDomain(userSource);
|
||||
|
||||
const profileRepo = {
|
||||
getByUserId: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const userRepo = {
|
||||
getById: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const adapter = {
|
||||
startTransaction: () => ({ complete: async (callback: (transaction: any) => Promise<void>) => callback({}) }),
|
||||
};
|
||||
const repositoryManager = {
|
||||
getRepository: (name: string) => () => (name === "Profile" ? profileRepo : userRepo),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
profileRepo.getByUserId.mockResolvedValue(profile);
|
||||
userRepo.getById.mockResolvedValue(user);
|
||||
profileRepo.update.mockResolvedValue(undefined);
|
||||
userRepo.update.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("persists the selected language on the user", async () => {
|
||||
const useCase = new UpdateProfileUseCase({ adapter: adapter as any, repositoryManager: repositoryManager as any });
|
||||
|
||||
const result = await useCase.execute({
|
||||
userId: UniqueID.create(userId).object,
|
||||
profileDTO: { ...profileDTO, lang_code: "en" },
|
||||
});
|
||||
|
||||
expect(result.isSuccess).toBe(true);
|
||||
expect(user.language.code).toBe("en");
|
||||
expect(userRepo.update).toHaveBeenCalledWith(user);
|
||||
});
|
||||
|
||||
it("does not change the language when lang_code is omitted", async () => {
|
||||
user.language = Language.createFromCode("en").object;
|
||||
const useCase = new UpdateProfileUseCase({ adapter: adapter as any, repositoryManager: repositoryManager as any });
|
||||
|
||||
const result = await useCase.execute({
|
||||
userId: UniqueID.create(userId).object,
|
||||
profileDTO,
|
||||
});
|
||||
|
||||
expect(result.isSuccess).toBe(true);
|
||||
expect(user.language.code).toBe("en");
|
||||
expect(userRepo.getById).not.toHaveBeenCalled();
|
||||
expect(userRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -9,12 +9,14 @@ import { IInfrastructureError } from "@/contexts/common/infrastructure";
|
||||
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
|
||||
import {
|
||||
IUpdateProfile_Request_DTO,
|
||||
Language,
|
||||
Percentage,
|
||||
Result,
|
||||
TextValueObject,
|
||||
UniqueID,
|
||||
} from "@shared/contexts";
|
||||
import { IProfileRepository, Profile } from "../domain";
|
||||
import { IUserRepository, User } from "@/contexts/users/domain";
|
||||
|
||||
export interface IUpdateProfileUseCaseRequest extends IUseCaseRequest {
|
||||
userId: UniqueID;
|
||||
@ -52,6 +54,22 @@ export class UpdateProfileUseCase
|
||||
|
||||
const profile = exitsOrError.object;
|
||||
|
||||
let user: User | null = null;
|
||||
if (profileDTO.lang_code !== undefined) {
|
||||
const languageOrError = Language.createFromCode(profileDTO.lang_code);
|
||||
if (languageOrError.isFailure) {
|
||||
return Result.fail(languageOrError.error);
|
||||
}
|
||||
|
||||
const userOrError = await this._getUser(userId);
|
||||
if (userOrError.isFailure) {
|
||||
return Result.fail(userOrError.error);
|
||||
}
|
||||
|
||||
user = userOrError.object;
|
||||
user.language = languageOrError.object;
|
||||
}
|
||||
|
||||
// Actualizar el perfil con datos actualizados
|
||||
profile.contactInformation = TextValueObject.create(profileDTO.contact_information).object;
|
||||
profile.defaultPaymentMethod = TextValueObject.create(profileDTO.default_payment_method).object;
|
||||
@ -78,18 +96,24 @@ export class UpdateProfileUseCase
|
||||
profile.defaultTax = taxOrError.object;
|
||||
|
||||
// Guardar los cambios
|
||||
return this._saveProfile(profile);
|
||||
return this._saveProfile(profile, user);
|
||||
}
|
||||
|
||||
private async _saveProfile(updatedProfile: Profile) {
|
||||
private async _saveProfile(updatedProfile: Profile, updatedUser: User | null) {
|
||||
// Guardar el contacto
|
||||
const transaction = this._adapter.startTransaction();
|
||||
const profileRepository = this._getProfileRepository();
|
||||
const userRepository = this._getUserRepository();
|
||||
|
||||
try {
|
||||
await transaction.complete(async (t) => {
|
||||
const profileRepo = profileRepository({ transaction: t });
|
||||
await profileRepo.update(updatedProfile);
|
||||
|
||||
if (updatedUser) {
|
||||
const userRepo = userRepository({ transaction: t });
|
||||
await userRepo.update(updatedUser);
|
||||
}
|
||||
});
|
||||
|
||||
return Result.ok<Profile>(updatedProfile);
|
||||
@ -127,4 +151,26 @@ export class UpdateProfileUseCase
|
||||
private _getProfileRepository() {
|
||||
return this._repositoryManager.getRepository<IProfileRepository>("Profile");
|
||||
}
|
||||
|
||||
private async _getUser(userId: UniqueID) {
|
||||
const userRepository = this._getUserRepository();
|
||||
|
||||
try {
|
||||
const user = await userRepository().getById(userId);
|
||||
if (!user) {
|
||||
return Result.fail(UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, "User not found"));
|
||||
}
|
||||
|
||||
return Result.ok<User>(user);
|
||||
} catch (error: unknown) {
|
||||
const infrastructureError = error as IInfrastructureError;
|
||||
return Result.fail(
|
||||
UseCaseError.create(UseCaseError.REPOSITORY_ERROR, infrastructureError.message)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _getUserRepository() {
|
||||
return this._repositoryManager.getRepository<IUserRepository>("User");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { UpdateProfileUseCase } from "@/contexts/profile/application";
|
||||
import { IProfileContext } from "../../../Profile.context";
|
||||
import { registerProfileRepository } from "../../../Profile.repository";
|
||||
import { registerUserRepository } from "@/contexts/users/infrastructure/User.repository";
|
||||
import { UpdateProfileController } from "./UpdateProfile.controller";
|
||||
import { UpdateProfilePresenter } from "./presenter";
|
||||
|
||||
export const createUpdateProfileController = (context: IProfileContext) => {
|
||||
registerProfileRepository(context);
|
||||
registerUserRepository(context);
|
||||
|
||||
return new UpdateProfileController(
|
||||
{
|
||||
|
||||
@ -76,6 +76,10 @@ export class User extends AggregateRoot<IUserProps> implements IUser {
|
||||
return this.props.language;
|
||||
}
|
||||
|
||||
set language(language: Language) {
|
||||
this.props.language = language;
|
||||
}
|
||||
|
||||
get isUser(): boolean {
|
||||
return this.hasRole(UserRole.ROLE_USER);
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ class UserMapper
|
||||
email: this.mapsValue(source, "email", Email.create),
|
||||
password: this.mapsValue(source, "password", Password.createFromHashedTextPassword),
|
||||
roles: source.roles.map((rol) => UserRole.create(rol).object),
|
||||
language: this.mapsValue(source, "language", Language.createFromCode),
|
||||
language: this.mapsValue(source, "lang_code", Language.createFromCode),
|
||||
};
|
||||
|
||||
const id = this.mapsValue(source, "id", UniqueID.create);
|
||||
|
||||
@ -25,4 +25,16 @@ describe("ensureUpdateProfile_Request_DTOIsValid default_tax_label", () => {
|
||||
ensureUpdateProfile_Request_DTOIsValid({ ...validProfile, default_tax_label } as any)
|
||||
).toHaveProperty("isFailure", true);
|
||||
});
|
||||
|
||||
it.each([undefined, "es", "en"])('accepts interface language %p', (lang_code) => {
|
||||
expect(
|
||||
ensureUpdateProfile_Request_DTOIsValid({ ...validProfile, lang_code })
|
||||
).toHaveProperty("isSuccess", true);
|
||||
});
|
||||
|
||||
it.each(["ca", "fr", "zz"])('rejects unsupported interface language %p', (lang_code) => {
|
||||
expect(
|
||||
ensureUpdateProfile_Request_DTOIsValid({ ...validProfile, lang_code })
|
||||
).toHaveProperty("isFailure", true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import Joi from "joi";
|
||||
import { IPercentage_DTO, Result, RuleValidator } from "../../../../common";
|
||||
|
||||
export const SUPPORTED_INTERFACE_LANGUAGE_CODES = ["es", "en"] as const;
|
||||
|
||||
export interface IUpdateProfile_Request_DTO {
|
||||
lang_code?: string;
|
||||
contact_information: string;
|
||||
default_payment_method: string;
|
||||
default_notes: string;
|
||||
@ -15,6 +18,7 @@ export interface IUpdateProfile_Request_DTO {
|
||||
|
||||
export function ensureUpdateProfile_Request_DTOIsValid(userDTO: IUpdateProfile_Request_DTO) {
|
||||
const schema = Joi.object({
|
||||
lang_code: Joi.string().trim().lowercase().valid(...SUPPORTED_INTERFACE_LANGUAGE_CODES).optional(),
|
||||
contact_information: Joi.string().optional().allow(null).allow("").default(""),
|
||||
default_payment_method: Joi.string().optional().allow(null).allow("").default(""),
|
||||
default_notes: Joi.string().optional().allow(null).allow("").default(""),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user