From a065c1800f2d387228a6a59b6a7d650180b0d2e0 Mon Sep 17 00:00:00 2001 From: CY <19247537+cyappdev@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:51:12 +0800 Subject: [PATCH 1/4] feat: Move language option to profile setting (#387) --- backend/config/locale.js | 1 + ...0204151833-add-column-locale-user-table.js | 10 ++ backend/models/users.js | 4 + backend/routes/users/updateAvatar.js | 2 +- backend/routes/users/updateLocale.js | 46 ++++++++ backend/routes/users/updateLocale.test.js | 92 +++++++++++++++ backend/routes/users/updateUsername.js | 2 +- backend/server.js | 2 + frontend/messages/de.json | 4 + frontend/messages/en.json | 4 + frontend/messages/ja.json | 4 + frontend/messages/pt-BR.json | 4 + frontend/messages/zh-CN.json | 4 + .../src/app/[locale]/DropdownLanguage.tsx | 2 +- .../src/app/[locale]/HeaderNavbarMenu.tsx | 88 +++++++------- .../src/app/[locale]/account/authControl.ts | 1 + .../src/app/[locale]/account/authPage.tsx | 5 +- .../account/settings/ProfileSettingsPage.tsx | 107 +++++++++++++++++- .../app/[locale]/account/settings/page.tsx | 4 + .../[projectId]/settings/SettingsPage.tsx | 1 + frontend/types/user.ts | 1 + frontend/utils/usersControl.ts | 31 +++++ 22 files changed, 368 insertions(+), 51 deletions(-) create mode 100644 backend/config/locale.js create mode 100644 backend/migrations/20260204151833-add-column-locale-user-table.js create mode 100644 backend/routes/users/updateLocale.js create mode 100644 backend/routes/users/updateLocale.test.js diff --git a/backend/config/locale.js b/backend/config/locale.js new file mode 100644 index 0000000..e661d34 --- /dev/null +++ b/backend/config/locale.js @@ -0,0 +1 @@ +export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'ja']; diff --git a/backend/migrations/20260204151833-add-column-locale-user-table.js b/backend/migrations/20260204151833-add-column-locale-user-table.js new file mode 100644 index 0000000..ea1b7b0 --- /dev/null +++ b/backend/migrations/20260204151833-add-column-locale-user-table.js @@ -0,0 +1,10 @@ +export async function up(queryInterface, Sequelize) { + await queryInterface.addColumn('users', 'locale', { + type: Sequelize.STRING(20), + allowNull: true, + }); +} + +export async function down(queryInterface) { + await queryInterface.removeColumn('users', 'locale'); +} diff --git a/backend/models/users.js b/backend/models/users.js index ebd0df4..6968e81 100644 --- a/backend/models/users.js +++ b/backend/models/users.js @@ -22,6 +22,10 @@ function defineUser(sequelize, DataTypes) { avatarPath: { type: DataTypes.STRING, }, + locale: { + type: DataTypes.STRING(20), + allowNull: true, + }, }, { underscored: true } ); diff --git a/backend/routes/users/updateAvatar.js b/backend/routes/users/updateAvatar.js index a2bb389..001e652 100644 --- a/backend/routes/users/updateAvatar.js +++ b/backend/routes/users/updateAvatar.js @@ -126,7 +126,7 @@ export default function (sequelize) { // Return updated user without password const updatedUser = await User.findByPk(userId, { - attributes: ['id', 'email', 'username', 'role', 'avatarPath'], + attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'], }); res.json({ user: updatedUser }); diff --git a/backend/routes/users/updateLocale.js b/backend/routes/users/updateLocale.js new file mode 100644 index 0000000..da17479 --- /dev/null +++ b/backend/routes/users/updateLocale.js @@ -0,0 +1,46 @@ +import express from 'express'; +import { DataTypes } from 'sequelize'; +import defineUser from '../../models/users.js'; +import authMiddleware from '../../middleware/auth.js'; +import { SUPPORTED_LOCALES } from '../../config/locale.js'; +const router = express.Router(); + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const User = defineUser(sequelize, DataTypes); + + router.put('/locale', verifySignedIn, async (req, res) => { + try { + const userId = req.userId; + + const user = await User.findByPk(userId); + if (!user) { + return res.status(404).send('User not found'); + } + + const { locale } = req.body; + + const normalizedLocale = typeof locale === 'string' ? locale.trim() : ''; + if (!normalizedLocale || normalizedLocale.length === 0) { + return res.status(400).send('Locale is required'); + } + + if (!SUPPORTED_LOCALES.includes(normalizedLocale)) { + return res.status(400).send('Invalid locale'); + } + + await user.update({ locale: normalizedLocale }); + + const updatedUser = await User.findByPk(userId, { + attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'], + }); + + res.json({ user: updatedUser }); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/routes/users/updateLocale.test.js b/backend/routes/users/updateLocale.test.js new file mode 100644 index 0000000..736635a --- /dev/null +++ b/backend/routes/users/updateLocale.test.js @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import { Sequelize } from 'sequelize'; +import { SUPPORTED_LOCALES } from '../../config/locale.js'; +import updateLocaleRoute from './updateLocale.js'; + +// mock of authentication middleware +let mockUserId = 1; +vi.mock('../../middleware/auth.js', () => ({ + default: () => ({ + verifySignedIn: vi.fn((req, res, next) => { + req.userId = mockUserId; // Mock user ID + next(); + }), + }), +})); + +// mock defineUser +const mockUsers = new Map(); +const mockUser = { + findByPk: vi.fn((id) => { + const user = mockUsers.get(id); + if (!user) return null; + return { + ...user, + update: vi.fn(async (data) => { + Object.assign(user, data); + return user; + }), + }; + }), +}; + +vi.mock('../../models/users.js', () => ({ + default: () => mockUser, +})); + +describe('User Locale Routes', () => { + let app; + const sequelize = new Sequelize({ + dialect: 'sqlite', + logging: false, + }); + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/users', updateLocaleRoute(sequelize)); + + // Reset mock users + mockUsers.clear(); + mockUserId = 1; + + // Create a test user + mockUsers.set(1, { + id: 1, + email: 'test@example.com', + username: 'testuser', + password: '', + role: 1, + avatarPath: null, + locale: null, + }); + + vi.clearAllMocks(); + }); + + it('should update locale', async () => { + const newLocale = SUPPORTED_LOCALES[0]; + const response = await request(app).put('/users/locale').send({ locale: newLocale }); + + expect(response.status).toBe(200); + expect(response.body.user.locale).toBe(newLocale); + }); + + it('should replace existing locale', async () => { + mockUsers.set(1, { + locale: SUPPORTED_LOCALES[0], + }); + const response = await request(app).put('/users/locale').send({ locale: SUPPORTED_LOCALES[1] }); + + expect(response.status).toBe(200); + expect(response.body.user.locale).toBe(SUPPORTED_LOCALES[1]); + }); + + it.each([' ', 'chinese', 'english'])('should reject not supported locale: %s', async (locale) => { + const response = await request(app).put('/users/locale').send({ locale }); + + expect(response.status).toBe(400); + }); +}); diff --git a/backend/routes/users/updateUsername.js b/backend/routes/users/updateUsername.js index 9780df9..6a247e1 100644 --- a/backend/routes/users/updateUsername.js +++ b/backend/routes/users/updateUsername.js @@ -26,7 +26,7 @@ export default function (sequelize) { // Return updated user without password const updatedUser = await User.findByPk(userId, { - attributes: ['id', 'email', 'username', 'role', 'avatarPath'], + attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'], }); res.json({ user: updatedUser }); diff --git a/backend/server.js b/backend/server.js index 54d96bb..83b790f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -53,6 +53,7 @@ import usersSearchRoute from './routes/users/search.js'; import usersUpdateUsernameRoute from './routes/users/updateUsername.js'; import usersUpdatePasswordRoute from './routes/users/updatePassword.js'; import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js'; +import usersUpdateLocaleRoute from './routes/users/updateLocale.js'; import usersUpdateAvatarRoute from './routes/users/updateAvatar.js'; import usersUpdateRoleRoute from './routes/users/updateRole.js'; import signUpRoute from './routes/users/signup.js'; @@ -63,6 +64,7 @@ app.use('/users', usersSearchRoute(sequelize)); app.use('/users', usersUpdateUsernameRoute(sequelize)); app.use('/users', usersUpdatePasswordRoute(sequelize)); app.use('/users', usersAdminResetPasswordRoute(sequelize)); +app.use('/users', usersUpdateLocaleRoute(sequelize)); app.use('/users', usersUpdateAvatarRoute(sequelize)); app.use('/users', usersUpdateRoleRoute(sequelize)); app.use('/users', signUpRoute(sequelize)); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 4002646..d92a8aa 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -101,6 +101,7 @@ "confirm_password": "Passwort (Bestätigung)", "invalid_email": "Ungültige E-Mail", "invalid_password": "Passwort muss mindestens 8 Zeichen haben", + "invalid_locale": "Ungültige Sprache", "username_empty": "Benutzername ist leer", "password_empty": "Passwort ist leer", "password_not_match": "Passwörter stimmen nicht überein", @@ -125,6 +126,9 @@ "confirm_new_password": "Neues Passwort bestätigen", "update_password": "Passwort aktualisieren", "password_updated": "Passwort erfolgreich geändert", + "change_locale": "Sprache ändern", + "update_locale": "Sprache aktualisieren", + "locale_updated": "Sprache erfolgreich geändert", "change_avatar": "Avatar ändern", "upload_avatar": "Avatar hochladen", "remove_avatar": "Avatar entfernen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d046ef1..c3f244e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -101,6 +101,7 @@ "confirm_password": "Password (confirm)", "invalid_email": "Invalid email", "invalid_password": "Password must be at least 8 characters", + "invalid_locale": "Invalid language", "username_empty": "username is empty", "password_empty": "password is empty", "password_not_match": "Pasword does not match", @@ -125,6 +126,9 @@ "confirm_new_password": "Confirm New Password", "update_password": "Update Password", "password_updated": "Password updated successfully", + "change_locale": "Change Language", + "update_locale": "Update Language", + "locale_updated": "Language updated successfully", "change_avatar": "Change Avatar", "upload_avatar": "Upload Avatar", "remove_avatar": "Remove Avatar", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 1d05d02..26e0f01 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -101,6 +101,7 @@ "confirm_password": "パスワード(確認)", "invalid_email": "無効なメールアドレスです", "invalid_password": "パスワードは8文字以上である必要があります", + "invalid_locale": "無効な言語です", "username_empty": "ユーザー名が入力されていません", "password_empty": "パスワードが入力されていません", "password_not_match": "パスワードが一致しません", @@ -125,6 +126,9 @@ "confirm_new_password": "新しいパスワード(確認)", "update_password": "パスワードを更新", "password_updated": "パスワードが正常に更新されました", + "change_locale": "言語の変更", + "update_locale": "言語を更新", + "locale_updated": "言語が正常に更新されました", "change_avatar": "アバターの変更", "upload_avatar": "アバターをアップロード", "remove_avatar": "アバターを削除", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 2dce4f6..eed385f 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -101,6 +101,7 @@ "confirm_password": "Senha (confirmação)", "invalid_email": "E-mail inválido", "invalid_password": "A senha deve ter pelo menos 8 caracteres", + "invalid_locale": "Idioma inválido", "username_empty": "O nome de usuário está vazio", "password_empty": "A senha está vazia", "password_not_match": "A senha não corresponde", @@ -125,6 +126,9 @@ "confirm_new_password": "Confirmar Nova Senha", "update_password": "Atualizar Senha", "password_updated": "Senha atualizada com sucesso", + "change_locale": "Alterar Idioma", + "update_locale": "Atualizar Idioma", + "locale_updated": "Idioma atualizado com sucesso", "change_avatar": "Alterar Avatar", "upload_avatar": "Enviar Avatar", "remove_avatar": "Remover Avatar", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index 01344b5..7fa89e6 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -101,6 +101,7 @@ "confirm_password": "确认密码", "invalid_email": "无效的邮箱地址", "invalid_password": "密码必须至少包含 8 个字符", + "invalid_locale": "无效的语言", "username_empty": "用户名不能为空", "password_empty": "密码不能为空", "password_not_match": "密码不匹配", @@ -125,6 +126,9 @@ "confirm_new_password": "确认新密码", "update_password": "更新密码", "password_updated": "密码更新成功", + "change_locale": "修改语言", + "update_locale": "更新语言", + "locale_updated": "语言更新成功", "change_avatar": "修改头像", "upload_avatar": "上传头像", "remove_avatar": "移除头像", diff --git a/frontend/src/app/[locale]/DropdownLanguage.tsx b/frontend/src/app/[locale]/DropdownLanguage.tsx index 18463c1..0a455ba 100644 --- a/frontend/src/app/[locale]/DropdownLanguage.tsx +++ b/frontend/src/app/[locale]/DropdownLanguage.tsx @@ -16,7 +16,7 @@ export default function DropdownLanguage({ locale, onChangeLocale }: Props) { {locales.find((entry) => entry.code === locale)?.name || locale} - + {locales.map((entry) => ( onChangeLocale(entry.code)}> {entry.name} diff --git a/frontend/src/app/[locale]/HeaderNavbarMenu.tsx b/frontend/src/app/[locale]/HeaderNavbarMenu.tsx index d8fe6d0..80f2745 100644 --- a/frontend/src/app/[locale]/HeaderNavbarMenu.tsx +++ b/frontend/src/app/[locale]/HeaderNavbarMenu.tsx @@ -136,7 +136,7 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
{}} /> - + {!context.isSignedIn() && }
setIsMenuOpen(!isMenuOpen)} /> @@ -224,51 +224,53 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) { /> ) : ( - - } - title={messages.signIn} - onPress={() => { - router.push('/account/signin', { locale: locale }); - setIsMenuOpen(false); + <> + - } - onPress={() => { - router.push('/account/signup', { locale: locale }); - setIsMenuOpen(false); + > + } + title={messages.signIn} + onPress={() => { + router.push('/account/signin', { locale: locale }); + setIsMenuOpen(false); + }} + /> + } + onPress={() => { + router.push('/account/signup', { locale: locale }); + setIsMenuOpen(false); + }} + /> + +

{messages.languages}

+ - + > + {locales.map((entry) => ( + } + title={entry.name} + onPress={() => { + changeLocale(entry.code); + setIsMenuOpen(false); + }} + /> + ))} +
+ )} -

{messages.languages}

- - {locales.map((entry) => ( - } - title={entry.name} - onPress={() => { - changeLocale(entry.code); - setIsMenuOpen(false); - }} - /> - ))} - diff --git a/frontend/src/app/[locale]/account/authControl.ts b/frontend/src/app/[locale]/account/authControl.ts index f0d77df..29e2481 100644 --- a/frontend/src/app/[locale]/account/authControl.ts +++ b/frontend/src/app/[locale]/account/authControl.ts @@ -60,6 +60,7 @@ async function signInAsGuest() { username: 'Guest', role: roles.findIndex((entry) => entry.uid === 'user'), avatarPath: '', + locale: null, }; const token = await signUp(guestUser); return token; diff --git a/frontend/src/app/[locale]/account/authPage.tsx b/frontend/src/app/[locale]/account/authPage.tsx index d83cea3..096db89 100644 --- a/frontend/src/app/[locale]/account/authPage.tsx +++ b/frontend/src/app/[locale]/account/authPage.tsx @@ -30,6 +30,7 @@ export default function AuthPage({ isSignup, messages, locale }: Props) { username: '', role: roles.findIndex((entry) => entry.uid === 'user'), avatarPath: '', + locale: null, }); const [confirmPassword, setConfirmPassword] = useState(''); const [errorMessage, setErrorMessage] = useState(''); @@ -83,14 +84,14 @@ export default function AuthPage({ isSignup, messages, locale }: Props) { context.setToken(token); context.storeTokenToLocalStorage(token); - router.push('/account', { locale: locale }); + router.push('/account', { locale: token.user?.locale ?? locale }); }; const handleSignInAsGuest = async () => { const token = await signInAsGuest(); context.setToken(token); context.storeTokenToLocalStorage(token); - router.push('/account', { locale: locale }); + router.push('/account', { locale: token.user?.locale ?? locale }); }; return ( diff --git a/frontend/src/app/[locale]/account/settings/ProfileSettingsPage.tsx b/frontend/src/app/[locale]/account/settings/ProfileSettingsPage.tsx index 8a95e03..4e00f09 100644 --- a/frontend/src/app/[locale]/account/settings/ProfileSettingsPage.tsx +++ b/frontend/src/app/[locale]/account/settings/ProfileSettingsPage.tsx @@ -1,11 +1,15 @@ 'use client'; import { useState, useContext, useRef } from 'react'; -import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter } from '@heroui/react'; +import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter, Select, SelectItem } from '@heroui/react'; +import { Globe } from 'lucide-react'; import { TokenContext } from '@/utils/TokenProvider'; -import { updateUsername, updatePassword, uploadAvatar, deleteAvatar } from '@/utils/usersControl'; +import { updateUsername, updatePassword, uploadAvatar, deleteAvatar, updateLocale } from '@/utils/usersControl'; import { LocaleCodeType } from '@/types/locale'; import { logError } from '@/utils/errorHandler'; import UserAvatar from '@/components/UserAvatar'; +import { useRouter, usePathname } from '@/src/i18n/routing'; +import { locales } from '@/config/selection'; +import { LocaleType } from '@/types/locale'; type ProfileSettingsPageMessages = { profileSettings: string; @@ -19,6 +23,9 @@ type ProfileSettingsPageMessages = { confirmNewPassword: string; updatePassword: string; passwordUpdated: string; + changeLocale: string; + updateLocale: string; + localeUpdated: string; changeAvatar: string; uploadAvatar: string; removeAvatar: string; @@ -31,6 +38,7 @@ type ProfileSettingsPageMessages = { invalidPassword: string; passwordNotMatch: string; usernameEmpty: string; + invalidLocale: string; }; type Props = { @@ -38,15 +46,21 @@ type Props = { locale: LocaleCodeType; }; -export default function ProfileSettingsPage({ messages }: Props) { +export default function ProfileSettingsPage({ messages, locale: defaultLocale }: Props) { const context = useContext(TokenContext); + + const router = useRouter(); + const pathname = usePathname(); + const fileInputRef = useRef(null); const [username, setUsername] = useState(''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + const [locale, setLocale] = useState(context.token?.user?.locale ?? defaultLocale); const [isUpdatingUsername, setIsUpdatingUsername] = useState(false); const [isUpdatingPassword, setIsUpdatingPassword] = useState(false); + const [isUpdatingLocale, setIsUpdatingLocale] = useState(false); const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); const handleUsernameUpdate = async () => { @@ -150,6 +164,53 @@ export default function ProfileSettingsPage({ messages }: Props) { } }; + const handleLocaleUpdate = async () => { + if (!locales.some((l) => l.code === locale)) { + addToast({ + title: 'Warning', + color: 'warning', + description: messages.invalidLocale, + }); + return; + } + + setIsUpdatingLocale(true); + try { + const result = await updateLocale(context.token.access_token, locale); + if (result && result.user) { + // refresh locale + const newToken = { ...context.token }; + if (newToken.user) { + newToken.user.locale = result.user.locale; + } + context.setToken(newToken); + context.storeTokenToLocalStorage(newToken); + + addToast({ + title: 'Success', + color: 'success', + description: messages.localeUpdated, + }); + const nextLocale = result.user.locale ?? locale; + setLocale(nextLocale); + changeLocale(nextLocale); + } + } catch (error) { + logError('Error updating locale:', error); + addToast({ + title: 'Error', + color: 'danger', + description: messages.updateError, + }); + } finally { + setIsUpdatingLocale(false); + } + }; + + async function changeLocale(nextLocale: LocaleCodeType) { + router.push(pathname, { locale: nextLocale }); + } + const handleAvatarUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; @@ -333,6 +394,46 @@ export default function ProfileSettingsPage({ messages }: Props) { + {/* Change Locale */} + + + +

{messages.changeLocale}

+
+ +
+
+ + fullWidth + aria-label="change locale" + selectedKeys={[locale]} + disabledKeys={[locale]} + onSelectionChange={(value) => { + const selectedLocale = locales.find((locale) => locale.code === value.currentKey); + if (!selectedLocale) return; + setLocale(selectedLocale.code); + }} + > + {locales.map((locale) => ( + {locale.name} + ))} + +
+
+
+ + + +
+ {/* Change Avatar */} diff --git a/frontend/src/app/[locale]/account/settings/page.tsx b/frontend/src/app/[locale]/account/settings/page.tsx index d2d8e94..ef53a3b 100644 --- a/frontend/src/app/[locale]/account/settings/page.tsx +++ b/frontend/src/app/[locale]/account/settings/page.tsx @@ -17,6 +17,9 @@ export default function Page({ params }: PageType) { confirmNewPassword: t('confirm_new_password'), updatePassword: t('update_password'), passwordUpdated: t('password_updated'), + changeLocale: t('change_locale'), + updateLocale: t('update_locale'), + localeUpdated: t('locale_updated'), changeAvatar: t('change_avatar'), uploadAvatar: t('upload_avatar'), removeAvatar: t('remove_avatar'), @@ -29,6 +32,7 @@ export default function Page({ params }: PageType) { invalidPassword: t('invalid_password'), passwordNotMatch: t('password_not_match'), usernameEmpty: t('username_empty'), + invalidLocale: t('invalid_locale'), }; return ; diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx index 0a24be2..8af5cc3 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx @@ -43,6 +43,7 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage avatarPath: '', role: -1, username: '', + locale: null, }); useEffect(() => { diff --git a/frontend/types/user.ts b/frontend/types/user.ts index cc3e204..45e2a27 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -8,6 +8,7 @@ export type UserType = { username: string; role: number; avatarPath: string | null; + locale: LocaleCodeType | null; }; export type TokenProps = { diff --git a/frontend/utils/usersControl.ts b/frontend/utils/usersControl.ts index 211e69f..c226ac5 100644 --- a/frontend/utils/usersControl.ts +++ b/frontend/utils/usersControl.ts @@ -219,6 +219,36 @@ async function adminResetPassword(jwt: string, userId: number, newPassword: stri } } +async function updateLocale(jwt: string, locale: string) { + const updateData = { + locale, + }; + + const fetchOptions = { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(updateData), + }; + + const url = `${apiServer}/users/locale`; + + try { + const response = await fetch(url, fetchOptions); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `HTTP error! Status: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error: unknown) { + logError('Error updating locale:', error); + throw error; + } +} + export { findUser, searchUsers, @@ -228,4 +258,5 @@ export { uploadAvatar, deleteAvatar, adminResetPassword, + updateLocale, }; From 93e66d012281e3a2d79c54eceefea908b8a8e09c Mon Sep 17 00:00:00 2001 From: Han Sen Date: Tue, 3 Mar 2026 14:31:59 +0900 Subject: [PATCH 2/4] fix: Step case overwriting prevention by hypothetical ID (#396) --- .../[folderId]/cases/[caseId]/CaseEditor.tsx | 137 ++++++++++-------- 1 file changed, 74 insertions(+), 63 deletions(-) diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx index 20d607b..911561c 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx @@ -60,7 +60,7 @@ export default function CaseEditor({ const [testCase, setTestCase] = useState(defaultTestCase); const [isTitleInvalid] = useState(false); const [isUpdating, setIsUpdating] = useState(false); - const [plusCount, setPlusCount] = useState(0); + const [idCounter, setIdCounter] = useState(0); const [isDirty, setIsDirty] = useState(false); const [selectedTags, setSelectedTags] = useState<{ id: number; name: string }[]>([]); @@ -68,9 +68,14 @@ export default function CaseEditor({ useFormGuard(isDirty, messages.areYouSureLeave); const onPlusClick = async (newStepNo: number) => { + if (!testCase.Steps) { + return; + } setIsDirty(true); + const nextId = idCounter + 1; const newStep: StepType = { - id: plusCount, + // hypothetical ID + id: nextId, step: '', result: '', createdAt: new Date(), @@ -78,66 +83,65 @@ export default function CaseEditor({ caseSteps: { stepNo: newStepNo, }, - uid: `uid${plusCount}`, + uid: `uid${nextId}`, editState: 'new', }; - setPlusCount(plusCount + 1); - if (testCase.Steps) { - const updatedSteps = testCase.Steps.map((step) => { - if (step.caseSteps.stepNo >= newStepNo) { - return { - ...step, - editState: step.editState === 'notChanged' ? 'changed' : step.editState, - caseSteps: { - ...step.caseSteps, - stepNo: step.caseSteps.stepNo + 1, - }, - }; - } - return step; - }); + const updatedSteps = testCase.Steps.map((step) => { + if (step.caseSteps.stepNo >= newStepNo) { + return { + ...step, + editState: step.editState === 'notChanged' ? 'changed' : step.editState, + caseSteps: { + ...step.caseSteps, + stepNo: step.caseSteps.stepNo + 1, + }, + }; + } + return step; + }); - updatedSteps.push(newStep); + updatedSteps.push(newStep); - setTestCase({ - ...testCase, - Steps: updatedSteps, - }); - } + setTestCase({ + ...testCase, + Steps: updatedSteps, + }); + setIdCounter(nextId); }; const onDeleteClick = async (stepId: number) => { setIsDirty(true); - - // find deletedStep's stepNo - if (testCase.Steps) { - const deletedStep = testCase.Steps.find((step) => step.id === stepId); - if (!deletedStep) { - return; - } - const deletedStepNo = deletedStep.caseSteps.stepNo; - deletedStep.editState = 'deleted'; - - const updatedSteps = testCase.Steps.map((step) => { - if (step.caseSteps.stepNo > deletedStepNo) { - return { - ...step, - editState: step.editState === 'notChanged' ? 'changed' : step.editState, - caseSteps: { - ...step.caseSteps, - stepNo: step.caseSteps.stepNo - 1, - }, - }; - } - return step; - }); - - setTestCase({ - ...testCase, - Steps: updatedSteps, - }); + if (!testCase.Steps) { + return; } + // find deletedStep's stepNo + + const deletedStep = testCase.Steps.find((step) => step.id === stepId); + if (!deletedStep) { + return; + } + const deletedStepNo = deletedStep.caseSteps.stepNo; + deletedStep.editState = 'deleted'; + + const updatedSteps = testCase.Steps.map((step) => { + if (step.caseSteps.stepNo > deletedStepNo) { + return { + ...step, + editState: step.editState === 'notChanged' ? 'changed' : step.editState, + caseSteps: { + ...step.caseSteps, + stepNo: step.caseSteps.stepNo - 1, + }, + }; + } + return step; + }); + + setTestCase({ + ...testCase, + Steps: updatedSteps, + }); }; const handleDrop = (event: DragEvent) => { @@ -201,18 +205,20 @@ export default function CaseEditor({ changeStep.editState = 'changed'; } - if (testCase.Steps) { - setTestCase({ - ...testCase, - Steps: testCase.Steps.map((step) => { - if (step.id === stepId) { - return changeStep; - } else { - return step; - } - }), - }); + if (!testCase.Steps) { + return; } + + setTestCase({ + ...testCase, + Steps: testCase.Steps.map((step) => { + if (step.id === stepId) { + return changeStep; + } else { + return step; + } + }), + }); }; useEffect(() => { @@ -223,6 +229,11 @@ export default function CaseEditor({ data.Steps.forEach((step: StepType) => { step.editState = 'notChanged'; }); + + // set idCounter to the max step id to avoid id conflict for new steps + // id is not reflected on database + const maxStepId = data.Steps.reduce((maxId: number, step: StepType) => Math.max(maxId, step.id), 0); + setIdCounter(maxStepId); setTestCase(data); if (data.Tags) { setSelectedTags(Array.isArray(data.Tags) ? data.Tags : []); From 4cafcaeedc1090f27c2a4b4c5bb3141783134980 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:13:18 +0900 Subject: [PATCH 3/4] fix: spurious unsaved-changes warning when navigating between test cases in a test run (#406) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kimatata <117462761+kimatata@users.noreply.github.com> --- frontend/utils/formGuard.test.ts | 103 ++++++++++++++++++++++++++++++ frontend/utils/formGuard.ts | 18 +++++- package-lock.json | 106 ++++++++++++++++++++++++++++++- package.json | 2 + 4 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 frontend/utils/formGuard.test.ts diff --git a/frontend/utils/formGuard.test.ts b/frontend/utils/formGuard.test.ts new file mode 100644 index 0000000..db09668 --- /dev/null +++ b/frontend/utils/formGuard.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// mock cleanup +let cleanupFn: (() => void) | undefined; +vi.mock('react', () => ({ + useEffect: (fn: () => void) => { + cleanupFn = fn() as (() => void) | undefined; + }, +})); + +import { useFormGuard } from './formGuard'; + +describe('useFormGuard', () => { + let confirmSpy: ReturnType; + + beforeEach(() => { + // @ts-expect-error - we will mock confirm + confirmSpy = vi.spyOn(window, 'confirm'); + cleanupFn = undefined; + }); + + afterEach(() => { + cleanupFn?.(); + cleanupFn = undefined; + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + // helper: simulate clicking an anchor + const clickAnchor = (href: string, target?: string): MouseEvent => { + const anchor = document.createElement('a'); + anchor.setAttribute('href', href); + if (target) anchor.setAttribute('target', target); + document.body.appendChild(anchor); + + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + anchor.dispatchEvent(event); + return event; + }; + + describe('Basic functionality', () => { + it('does not show confirm when form is clean', () => { + useFormGuard(false, 'Leave?'); + clickAnchor('/some-path'); + + expect(confirmSpy).not.toHaveBeenCalled(); + }); + + it('shows confirm with the given text when dirty', () => { + confirmSpy.mockReturnValue(true); + useFormGuard(true, 'Are you sure?'); + clickAnchor('/other-page'); + + expect(confirmSpy).toHaveBeenCalledWith('Are you sure?'); + }); + + it('prevents navigation when dirty and user cancels confirm', () => { + confirmSpy.mockReturnValue(false); + useFormGuard(true, 'Leave?'); + + const event = clickAnchor('/other-page'); + expect(event.defaultPrevented).toBe(true); + }); + + it('allows navigation when dirty and user confirms', () => { + confirmSpy.mockReturnValue(true); + useFormGuard(true, 'Leave?'); + + const event = clickAnchor('/other-page'); + expect(event.defaultPrevented).toBe(false); + }); + }); + + describe('external links', () => { + it('does not show confirm for anchor with target="_blank"', () => { + useFormGuard(true, 'Leave?'); + clickAnchor('https://example.com', '_blank'); + + expect(confirmSpy).not.toHaveBeenCalled(); + }); + }); + + describe('UnitTCMS use case', () => { + it('does not show confirm when navigating to case detail page of test run', () => { + const projectId = '1'; + const runId = '2'; + useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]); + clickAnchor(`/projects/${projectId}/runs/${runId}/cases/123`); + + expect(confirmSpy).not.toHaveBeenCalled(); + }); + + it('shows confirm when navigating to test cases page', () => { + const projectId = '1'; + const runId = '2'; + useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]); + clickAnchor(`/projects/${projectId}/runs/${runId}/cases`); + + expect(confirmSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/utils/formGuard.ts b/frontend/utils/formGuard.ts index cc5a468..5db3447 100644 --- a/frontend/utils/formGuard.ts +++ b/frontend/utils/formGuard.ts @@ -1,7 +1,19 @@ import { useEffect } from 'react'; -export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?: string[]) => { +const isIgnoredPath = (href: string, compiledPatterns: RegExp[]): boolean => { + return compiledPatterns.some((regex) => regex.test(href)); +}; + +export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePathPatterns?: string[]) => { useEffect(() => { + const compiledPatterns: RegExp[] = (ignorePathPatterns ?? []).flatMap((pattern) => { + try { + return [new RegExp(pattern)]; + } catch { + return []; + } + }); + const handleClick = (event: MouseEvent) => { if (!isDirty) return; @@ -13,7 +25,7 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths? if (!href) return; // do not show confirm for ignored paths - if (ignorePaths && ignorePaths.some((path) => href.includes(path))) { + if (isIgnoredPath(href, compiledPatterns)) { return; } @@ -38,5 +50,5 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths? window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('click', handleClick, true); }; - }, [confirmText, isDirty, ignorePaths]); + }, [confirmText, isDirty, ignorePathPatterns]); }; diff --git a/package-lock.json b/package-lock.json index 5ecd736..6ea78d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,9 @@ "eslint-plugin-react": "^7.37.5", "express": "^4.21.0", "globals": "^16.0.0", + "happy-dom": "^20.8.3", "prettier": "^3.3.3", + "react": "^19.2.4", "sequelize": "^6.37.4", "sqlite3": "^5.1.7", "supertest": "^7.1.4", @@ -50,10 +52,11 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -1255,6 +1258,23 @@ "integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==", "dev": true }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.29.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz", @@ -1289,6 +1309,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz", "integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.29.1", "@typescript-eslint/types": "8.29.1", @@ -1838,6 +1859,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2938,6 +2960,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3182,6 +3217,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -3361,6 +3397,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -4220,6 +4257,25 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/happy-dom": { + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.3.tgz", + "integrity": "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -6349,6 +6405,16 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -7498,6 +7564,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -7974,6 +8041,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -8034,6 +8102,16 @@ } } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8184,6 +8262,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index a8df1ae..190d631 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "eslint-plugin-react": "^7.37.5", "express": "^4.21.0", "globals": "^16.0.0", + "happy-dom": "^20.8.3", "prettier": "^3.3.3", + "react": "^19.2.4", "sequelize": "^6.37.4", "sqlite3": "^5.1.7", "supertest": "^7.1.4", From 4dcff487921e95846baadbc520402665e72f67e9 Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:19:11 +0900 Subject: [PATCH 4/4] chore: release (#410) --- frontend/src/app/[locale]/health/HealthPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/[locale]/health/HealthPage.tsx b/frontend/src/app/[locale]/health/HealthPage.tsx index 4f1ae68..0aa4533 100644 --- a/frontend/src/app/[locale]/health/HealthPage.tsx +++ b/frontend/src/app/[locale]/health/HealthPage.tsx @@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) { {messages.unittcms_version} - 1.0.0-beta.27 + 1.0.0-beta.28 {messages.api_server}