diff --git a/ui/agents/patterns.md b/ui/agents/patterns.md index c5fe522a..d8c8c266 100644 --- a/ui/agents/patterns.md +++ b/ui/agents/patterns.md @@ -96,7 +96,7 @@ All user-facing API feedback flows through `MutationCache` / `QueryCache` in `sr Behavior: -- `MutationCache.onSuccess` — shows a green notification **only if `meta.successMessage` is provided**, then auto-invalidates `meta.invalidates` query keys. +- `MutationCache.onSuccess` — shows a notification **only if `meta.successMessage` is provided**, then auto-invalidates `meta.invalidates` query keys. `successMessage` and `successColor` may be resolver functions when the message/color depends on the mutation result. - `MutationCache.onError` — shows a red notification with `meta.errorMessage` as the title + the error detail. - `QueryCache.onError` — shows a red notification **only** for background refetch failures (query that previously had data). Initial load failures surface through route `errorComponent`. - Both caches respect `meta.skipNotification` for background/silent operations. diff --git a/ui/eslint.config.js b/ui/eslint.config.js index 9a3deeaf..9681a22a 100644 --- a/ui/eslint.config.js +++ b/ui/eslint.config.js @@ -256,4 +256,52 @@ export default defineConfig( 'jsonc/no-comments': 'off', }, }, + + // ============================================= + // Project architecture guardrails + // ============================================= + { + files: ['src/shared/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/features/*'], + message: 'Shared code must not depend on feature modules. Move the component into features or extract a shared primitive.', + }, + ], + }, + ], + }, + }, + { + files: ['src/features/**/*.{ts,tsx}', 'src/routes/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@mantine/notifications', + message: 'Use NotificationMeta on query/mutation options; global QueryCache/MutationCache owns notifications.', + }, + { + name: 'mantine-react-table', + importNames: ['MantineReactTable'], + message: 'Use the shared DataTable wrapper instead of rendering MantineReactTable directly.', + }, + ], + }, + ], + 'no-restricted-syntax': [ + 'error', + { + selector: 'Property[key.name=\'queryKey\'][value.type=\'ArrayExpression\']', + message: 'Use a feature query key factory and queryOptions() instead of inline queryKey arrays.', + }, + ], + }, + }, ) diff --git a/ui/src/features/admin/registries/registries.query.ts b/ui/src/features/admin/registries/registries.query.ts index 8d1b224f..0fbc4ca1 100644 --- a/ui/src/features/admin/registries/registries.query.ts +++ b/ui/src/features/admin/registries/registries.query.ts @@ -14,6 +14,7 @@ export const adminRegistryKeys = { all: ['admin', 'registries'] as const, lists: () => [...adminRegistryKeys.all, 'list'] as const, list: (search: RegistriesSearch) => [...adminRegistryKeys.lists(), search] as const, + allRegistries: () => [...adminRegistryKeys.lists(), 'all'] as const, } export function registriesQueryOptions(search: RegistriesSearch) { @@ -34,6 +35,23 @@ export function registriesQueryOptions(search: RegistriesSearch) { }) } +export function allRegistriesQueryOptions() { + return queryOptions({ + queryKey: adminRegistryKeys.allRegistries(), + queryFn: async () => { + const response = await Registries.ListRegistries({ + page: 1, + pageSize: -1, + }) + + return { + registries: response.registries ?? [], + pagination: response.pagination, + } + }, + }) +} + // -- Custom hook -- export function useRegistries(search: RegistriesSearch) { diff --git a/ui/src/features/admin/replications/components/ReplicationFormModal.tsx b/ui/src/features/admin/replications/components/ReplicationFormModal.tsx index 00779a48..149f7ae3 100644 --- a/ui/src/features/admin/replications/components/ReplicationFormModal.tsx +++ b/ui/src/features/admin/replications/components/ReplicationFormModal.tsx @@ -23,7 +23,7 @@ import { useMutation, useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { registriesQueryOptions } from '@/features/admin/registries/registries.query' +import { allRegistriesQueryOptions } from '@/features/admin/registries/registries.query' import { FieldHintLabel } from '@/shared/components/FieldHintLabel' import { ModalWrapper } from '@/shared/components/ModalWrapper' import { useForm } from '@/shared/hooks/useForm' @@ -189,10 +189,7 @@ export function ReplicationFormModal({ const createMutation = useMutation(createReplicationMutationOptions()) const updateMutation = useMutation(updateReplicationMutationOptions()) const registriesQuery = useQuery({ - ...registriesQueryOptions({ - page: 1, - query: '', - }), + ...allRegistriesQueryOptions(), enabled: opened, }) diff --git a/ui/src/features/admin/users/components/BatchDeleteUsersModal.tsx b/ui/src/features/admin/users/components/BatchDeleteUsersModal.tsx index 9fc8390d..cc9bf548 100644 --- a/ui/src/features/admin/users/components/BatchDeleteUsersModal.tsx +++ b/ui/src/features/admin/users/components/BatchDeleteUsersModal.tsx @@ -4,7 +4,6 @@ import { Stack, Text, } from '@mantine/core' -import { notifications } from '@mantine/notifications' import { useMutation } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' @@ -33,22 +32,7 @@ export function BatchDeleteUsersModal({ const usernames = users.map(getUserDisplayName).join(', ') const handleDelete = async () => { - const res = await mutation.mutateAsync(users) - - if (res && res.partial) { - notifications.show({ - color: 'yellow', - message: t('routes.admin.users.notifications.batchDeletePartialError', { - successCount: res.successCount, - failureCount: res.failureCount, - }), - }) - } else { - notifications.show({ - color: 'green', - message: t('routes.admin.users.notifications.batchDeleteSuccess'), - }) - } + await mutation.mutateAsync(users) onSuccess() onClose() } diff --git a/ui/src/features/admin/users/users.mutation.ts b/ui/src/features/admin/users/users.mutation.ts index 184c921c..456bc85a 100644 --- a/ui/src/features/admin/users/users.mutation.ts +++ b/ui/src/features/admin/users/users.mutation.ts @@ -24,6 +24,13 @@ function requireUserId(id?: number) { type UserSysAdminMutationInput = Pick +interface BatchDeleteUsersResult { + partial: boolean + successCount: number + failureCount: number + failed: PromiseRejectedResult[] +} + function userSysAdminMutationOptions({ sysadminFlag, successMessage, @@ -108,7 +115,7 @@ export function deleteUserMutationOptions() { export function batchDeleteUsersMutationOptions() { return mutationOptions({ - mutationFn: async (users: readonly User[]) => { + mutationFn: async (users: readonly User[]): Promise => { if (users.length === 0) { throw new Error(i18n.t('routes.admin.users.errors.noUsersSelected')) } @@ -146,6 +153,17 @@ export function batchDeleteUsersMutationOptions() { } }, meta: { + successMessage: (data) => { + const result = data as BatchDeleteUsersResult + + return result.partial + ? i18n.t('routes.admin.users.notifications.batchDeletePartialError', { + successCount: result.successCount, + failureCount: result.failureCount, + }) + : i18n.t('routes.admin.users.notifications.batchDeleteSuccess') + }, + successColor: data => ((data as BatchDeleteUsersResult).partial ? 'yellow' : 'green'), errorMessage: i18n.t('routes.admin.users.notifications.batchDeleteError'), invalidates: [adminUserKeys.lists()], } satisfies NotificationMeta, diff --git a/ui/src/features/admin/users/users.query.ts b/ui/src/features/admin/users/users.query.ts index 91d4e205..09cc4b94 100644 --- a/ui/src/features/admin/users/users.query.ts +++ b/ui/src/features/admin/users/users.query.ts @@ -14,6 +14,7 @@ export const adminUserKeys = { all: ['admin', 'users'] as const, lists: () => [...adminUserKeys.all, 'list'] as const, list: (search: UsersSearch) => [...adminUserKeys.lists(), search] as const, + allUsers: () => [...adminUserKeys.lists(), 'all'] as const, } export function usersQueryOptions(search: UsersSearch) { @@ -34,6 +35,23 @@ export function usersQueryOptions(search: UsersSearch) { }) } +export function allUsersQueryOptions() { + return queryOptions({ + queryKey: adminUserKeys.allUsers(), + queryFn: async () => { + const response = await Users.ListUsers({ + page: 1, + pageSize: -1, + }) + + return { + users: response.users ?? [], + pagination: response.pagination, + } + }, + }) +} + // -- Custom hook -- export function useUsers(search: UsersSearch) { diff --git a/ui/src/features/login/login.mutation.ts b/ui/src/features/login/login.mutation.ts new file mode 100644 index 00000000..5571fa8a --- /dev/null +++ b/ui/src/features/login/login.mutation.ts @@ -0,0 +1,24 @@ +import { Login, type LoginRequest } from '@matrixhub/api-ts/v1alpha1/login.pb' +import { mutationOptions } from '@tanstack/react-query' + +import i18n from '@/i18n' + +import type { NotificationMeta } from '@/types/tanstack-query' + +export function loginMutationOptions() { + return mutationOptions({ + mutationFn: (values: LoginRequest) => Login.Login(values), + meta: { + errorMessage: i18n.t('login.error'), + } satisfies NotificationMeta, + }) +} + +export function logoutMutationOptions() { + return mutationOptions({ + mutationFn: () => Login.Logout({}), + meta: { + errorMessage: i18n.t('login.logoutError'), + } satisfies NotificationMeta, + }) +} diff --git a/ui/src/features/login/pages/LoginPage.tsx b/ui/src/features/login/pages/LoginPage.tsx index da715b17..08245a54 100644 --- a/ui/src/features/login/pages/LoginPage.tsx +++ b/ui/src/features/login/pages/LoginPage.tsx @@ -1,12 +1,12 @@ import { Button, Checkbox, Flex, Group, PasswordInput, ScrollArea, Stack, Text, TextInput, rem, } from '@mantine/core' -import { Login, type LoginRequest } from '@matrixhub/api-ts/v1alpha1/login.pb' import { useMutation } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import LogoIcon from '@/assets/svgs/logo.svg?react' +import { loginMutationOptions } from '@/features/login/login.mutation' import { LanguageSwitcher } from '@/shared/components/LanguageSwitcher' import { useForm } from '@/shared/hooks/useForm' @@ -17,7 +17,7 @@ export function LoginPage() { const { mutate: handleLogin, isPending: isLoggingIn, } = useMutation({ - mutationFn: (values: LoginRequest) => Login.Login(values), + ...loginMutationOptions(), onSuccess: () => { navigate({ to: '/' }) }, @@ -75,6 +75,7 @@ export function LoginPage() { size="md" name={field.name} value={field.state.value} + onBlur={field.handleBlur} onChange={e => field.handleChange(e.target.value)} /> )} @@ -91,6 +92,7 @@ export function LoginPage() { size="md" name={field.name} value={field.state.value} + onBlur={field.handleBlur} onChange={e => field.handleChange(e.target.value)} /> )} @@ -101,6 +103,7 @@ export function LoginPage() { field.handleChange(e.target.checked)} label={t('login.rememberMe')} style={{ alignSelf: 'flex-start' }} diff --git a/ui/src/features/models/components/AllModelList.tsx b/ui/src/features/models/components/AllModelList.tsx index bf4b790e..df5c2afa 100644 --- a/ui/src/features/models/components/AllModelList.tsx +++ b/ui/src/features/models/components/AllModelList.tsx @@ -11,9 +11,9 @@ import { getRouteApi } from '@tanstack/react-router' import { startTransition } from 'react' import { useTranslation } from 'react-i18next' +import { ModelCard } from '@/features/models/components/ModelCard' import { catalogModelsQueryOptions } from '@/features/models/models.query' import { Pagination } from '@/shared/components/Pagination' -import { ModelCard } from '@/shared/components/resource-card/ModelCard.tsx' import { ResourceCardGrid } from '@/shared/components/ResourceCardGrid' import { SearchToolbar } from '@/shared/components/SearchToolbar' import { diff --git a/ui/src/features/models/components/HotModelList.tsx b/ui/src/features/models/components/HotModelList.tsx index 102ac45a..b35679a5 100644 --- a/ui/src/features/models/components/HotModelList.tsx +++ b/ui/src/features/models/components/HotModelList.tsx @@ -7,8 +7,8 @@ import { useQuery } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { ModelCard } from '@/features/models/components/ModelCard' import { catalogModelsQueryOptions } from '@/features/models/models.query.ts' -import { ModelCard } from '@/shared/components/resource-card/ModelCard.tsx' import { ResourceCardGrid } from '@/shared/components/ResourceCardGrid' const { useSearch } = getRouteApi('/(auth)/(app)/models/') diff --git a/ui/src/shared/components/resource-card/ModelCard.tsx b/ui/src/features/models/components/ModelCard.tsx similarity index 100% rename from ui/src/shared/components/resource-card/ModelCard.tsx rename to ui/src/features/models/components/ModelCard.tsx diff --git a/ui/src/features/profile/components/AccessTokenTable.tsx b/ui/src/features/profile/components/AccessTokenTable.tsx index 1cb94c1e..4f781157 100644 --- a/ui/src/features/profile/components/AccessTokenTable.tsx +++ b/ui/src/features/profile/components/AccessTokenTable.tsx @@ -5,14 +5,12 @@ import { Text, } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import { - CurrentUser, AccessTokenStatus, type AccessToken, -} from '@matrixhub/api-ts/v1alpha1/current_user.pb' +import { AccessTokenStatus, type AccessToken } from '@matrixhub/api-ts/v1alpha1/current_user.pb' import { useMutation } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { profileKeys } from '@/features/profile/profile.query' +import { deleteAccessTokenMutationOptions } from '@/features/profile/profile.mutation' import { formatExpiredAt } from '@/features/profile/profile.utils.ts' import { DataTable, type DataTableRowActionsProps } from '@/shared/components/DataTable' import { ModalWrapper } from '@/shared/components/ModalWrapper' @@ -76,11 +74,7 @@ export function AccessTokenTable({ tokens }: AccessTokenTableProps) { const { mutate: deleteToken, isPending: isDeleting, } = useMutation({ - mutationFn: () => CurrentUser.DeleteAccessToken({ id: deletingToken?.id }), - meta: { - successMessage: t('profile.tokenDeleted'), - invalidates: [profileKeys.accessTokens], - }, + ...deleteAccessTokenMutationOptions(), onSuccess: () => { closeDelete() setDeletingToken(null) @@ -97,6 +91,14 @@ export function AccessTokenTable({ tokens }: AccessTokenTableProps) { setDeletingToken(null) } + const handleDeleteConfirm = () => { + if (deletingToken?.id == null) { + return + } + + deleteToken(deletingToken.id) + } + const columns: MRT_ColumnDef[] = [ { accessorKey: 'name', @@ -136,7 +138,7 @@ export function AccessTokenTable({ tokens }: AccessTokenTableProps) { title={t('profile.deleteToken')} opened={deleteOpened} onClose={handleDeleteClose} - onConfirm={() => deleteToken()} + onConfirm={handleDeleteConfirm} confirmLoading={isDeleting} > diff --git a/ui/src/features/profile/components/CreateSshKeyModal.tsx b/ui/src/features/profile/components/CreateSshKeyModal.tsx index 0ed7196f..928fcfab 100644 --- a/ui/src/features/profile/components/CreateSshKeyModal.tsx +++ b/ui/src/features/profile/components/CreateSshKeyModal.tsx @@ -99,6 +99,7 @@ export function CreateSshKeyModal({ )} diff --git a/ui/src/features/profile/components/ExpireAtField.tsx b/ui/src/features/profile/components/ExpireAtField.tsx index 62ebad13..2303f2cb 100644 --- a/ui/src/features/profile/components/ExpireAtField.tsx +++ b/ui/src/features/profile/components/ExpireAtField.tsx @@ -11,12 +11,14 @@ const defaultExpireAt = () => dayjs().add(1, 'day').format('YYYY-MM-DD') interface ExpireAtFieldProps { value: string onChange: (value: string) => void + onBlur?: () => void error?: string } export function ExpireAtField({ value, onChange, + onBlur, error, }: ExpireAtFieldProps) { const { t } = useTranslation() @@ -59,6 +61,7 @@ export function ExpireAtField({ highlightToday required onChange={next => onChange(next ?? '')} + onBlur={onBlur} error={error} /> )} diff --git a/ui/src/features/profile/pages/AccessTokenPage.tsx b/ui/src/features/profile/pages/AccessTokenPage.tsx index 3cb2e97c..b6c86222 100644 --- a/ui/src/features/profile/pages/AccessTokenPage.tsx +++ b/ui/src/features/profile/pages/AccessTokenPage.tsx @@ -10,7 +10,6 @@ import { TextInput, } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import { CurrentUser, type CreateAccessTokenRequest } from '@matrixhub/api-ts/v1alpha1/current_user.pb' import { IconInfoCircle, IconKey, @@ -24,6 +23,7 @@ import z from 'zod' import { AccessTokenTable } from '@/features/profile/components/AccessTokenTable' import { ExpireAtField } from '@/features/profile/components/ExpireAtField' +import { createAccessTokenMutationOptions } from '@/features/profile/profile.mutation' import { profileKeys, useAccessTokens } from '@/features/profile/profile.query' import { expireAtSchema } from '@/features/profile/profile.schema.ts' import { CopyValueButton } from '@/shared/components/CopyValueButton' @@ -61,11 +61,7 @@ export function AccessTokenPage() { const { mutate: createToken, isPending: isCreating, } = useMutation({ - mutationFn: (value: CreateAccessTokenRequest) => CurrentUser.CreateAccessToken(value), - meta: { - successMessage: t('profile.tokenCreated'), - invalidates: [profileKeys.accessTokens], - }, + ...createAccessTokenMutationOptions(), onSuccess: (res) => { setNewToken(res.token ?? '') handleCreateClose() @@ -148,15 +144,14 @@ export function AccessTokenPage() { name="name" validators={{ onChange: nameSchema }} > - {({ - state, handleChange, - }) => ( + {field => ( handleChange(e.currentTarget.value)} - error={state.meta.errors[0]?.message} + value={field.state.value} + onChange={e => field.handleChange(e.currentTarget.value)} + onBlur={field.handleBlur} + error={fieldError(field)} /> )} @@ -165,6 +160,7 @@ export function AccessTokenPage() { )} diff --git a/ui/src/features/profile/pages/SecurityPage.tsx b/ui/src/features/profile/pages/SecurityPage.tsx index 393658b8..db868eff 100644 --- a/ui/src/features/profile/pages/SecurityPage.tsx +++ b/ui/src/features/profile/pages/SecurityPage.tsx @@ -4,13 +4,14 @@ import { Stack, } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import { CurrentUser } from '@matrixhub/api-ts/v1alpha1/current_user.pb' import { useMutation } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { z } from 'zod' +import { resetPasswordMutationOptions } from '@/features/profile/profile.mutation' import { ModalWrapper } from '@/shared/components/ModalWrapper' import { useForm } from '@/shared/hooks/useForm' +import { fieldError } from '@/shared/utils/form' interface FormValues { oldPassword: string @@ -57,14 +58,7 @@ export function SecurityPage() { const { mutate: resetPassword, isPending, } = useMutation({ - mutationFn: (value: FormValues) => - CurrentUser.ResetPassword({ - oldPassword: value.oldPassword, - newPassword: value.newPassword, - }), - meta: { - successMessage: t('profile.passwordChanged'), - }, + ...resetPasswordMutationOptions(), onSuccess: () => { handleClose() }, @@ -78,7 +72,10 @@ export function SecurityPage() { onSubmit: formSchema, }, onSubmit: ({ value }) => { - resetPassword(value) + resetPassword({ + oldPassword: value.oldPassword, + newPassword: value.newPassword, + }) }, }) @@ -117,15 +114,14 @@ export function SecurityPage() { name="oldPassword" validators={{ onChange: fieldSchemas.oldPassword }} > - {({ - state, handleChange, - }) => ( + {field => ( handleChange(e.currentTarget.value)} - error={state.meta.errors[0]?.message} + value={field.state.value} + onChange={e => field.handleChange(e.currentTarget.value)} + onBlur={field.handleBlur} + error={fieldError(field)} /> )} @@ -133,15 +129,14 @@ export function SecurityPage() { name="newPassword" validators={{ onChange: fieldSchemas.newPassword }} > - {({ - state, handleChange, - }) => ( + {field => ( handleChange(e.currentTarget.value)} - error={state.meta.errors[0]?.message} + value={field.state.value} + onChange={e => field.handleChange(e.currentTarget.value)} + onBlur={field.handleBlur} + error={fieldError(field)} /> )} @@ -149,15 +144,14 @@ export function SecurityPage() { name="confirmNewPassword" validators={{ onChange: fieldSchemas.confirmNewPassword }} > - {({ - state, handleChange, - }) => ( + {field => ( handleChange(e.currentTarget.value)} - error={state.meta.errors[0]?.message} + value={field.state.value} + onChange={e => field.handleChange(e.currentTarget.value)} + onBlur={field.handleBlur} + error={fieldError(field)} /> )} diff --git a/ui/src/features/profile/profile.mutation.ts b/ui/src/features/profile/profile.mutation.ts index 1cbd82a4..dc459f78 100644 --- a/ui/src/features/profile/profile.mutation.ts +++ b/ui/src/features/profile/profile.mutation.ts @@ -1,4 +1,9 @@ -import { CurrentUser, type CreateSSHKeyRequest } from '@matrixhub/api-ts/v1alpha1/current_user.pb' +import { + CurrentUser, + type CreateAccessTokenRequest, + type CreateSSHKeyRequest, + type ResetPasswordRequest, +} from '@matrixhub/api-ts/v1alpha1/current_user.pb' import { mutationOptions } from '@tanstack/react-query' import i18n from '@/i18n' @@ -13,6 +18,7 @@ export function deleteSshKeyMutationOptions() { CurrentUser.DeleteSSHKey({ id }), meta: { successMessage: i18n.t('profile.sshKey.delete.success'), + errorMessage: i18n.t('profile.sshKey.delete.error'), invalidates: [profileKeys.sshKeys], } satisfies NotificationMeta, }) @@ -24,7 +30,43 @@ export function createSshKeyMutationOptions() { CurrentUser.CreateSSHKey(params), meta: { successMessage: i18n.t('profile.sshKey.create.success'), + errorMessage: i18n.t('profile.sshKey.create.error'), invalidates: [profileKeys.sshKeys], } satisfies NotificationMeta, }) } + +export function resetPasswordMutationOptions() { + return mutationOptions({ + mutationFn: (params: ResetPasswordRequest) => + CurrentUser.ResetPassword(params), + meta: { + successMessage: i18n.t('profile.passwordChanged'), + errorMessage: i18n.t('profile.passwordChangeError'), + } satisfies NotificationMeta, + }) +} + +export function createAccessTokenMutationOptions() { + return mutationOptions({ + mutationFn: (params: CreateAccessTokenRequest) => + CurrentUser.CreateAccessToken(params), + meta: { + successMessage: i18n.t('profile.tokenCreated'), + errorMessage: i18n.t('profile.tokenCreateError'), + invalidates: [profileKeys.accessTokens], + } satisfies NotificationMeta, + }) +} + +export function deleteAccessTokenMutationOptions() { + return mutationOptions({ + mutationFn: (id: number) => + CurrentUser.DeleteAccessToken({ id }), + meta: { + successMessage: i18n.t('profile.tokenDeleted'), + errorMessage: i18n.t('profile.tokenDeleteError'), + invalidates: [profileKeys.accessTokens], + } satisfies NotificationMeta, + }) +} diff --git a/ui/src/features/projects/components/CreateProjectModal.tsx b/ui/src/features/projects/components/CreateProjectModal.tsx index e4d4164a..801a72a2 100644 --- a/ui/src/features/projects/components/CreateProjectModal.tsx +++ b/ui/src/features/projects/components/CreateProjectModal.tsx @@ -6,11 +6,11 @@ import { Switch, TextInput, } from '@mantine/core' -import { Registries } from '@matrixhub/api-ts/v1alpha1/registry.pb' import { useMutation, useQuery } from '@tanstack/react-query' import { useEffect, useEffectEvent } from 'react' import { useTranslation } from 'react-i18next' +import { allRegistriesQueryOptions } from '@/features/admin/registries/registries.query' import { useCurrentUser } from '@/features/auth/auth.query' import { FieldHintLabel } from '@/shared/components/FieldHintLabel.tsx' import { ModalWrapper } from '@/shared/components/ModalWrapper' @@ -63,8 +63,7 @@ export function CreateProjectModal({ // Fetch registries for the dropdown when proxy is enabled const registriesQuery = useQuery({ - queryKey: ['registries', 'list'], - queryFn: () => Registries.ListRegistries({ pageSize: -1 }), + ...allRegistriesQueryOptions(), enabled: opened, }) @@ -111,6 +110,7 @@ export function CreateProjectModal({ mt={4} label={t('projects.createModal.public')} checked={field.state.value} + onBlur={field.handleBlur} onChange={e => field.handleChange(e.currentTarget.checked)} /> )} @@ -135,6 +135,7 @@ export function CreateProjectModal({ ? t('projects.createModal.proxyEnabled') : t('projects.createModal.proxyDisabled')} checked={field.state.value} + onBlur={field.handleBlur} onChange={(e) => { field.handleChange(e.currentTarget.checked) if (!e.currentTarget.checked) { @@ -175,6 +176,7 @@ export function CreateProjectModal({ field.handleChange(e.currentTarget.value)} error={fieldError(field)} /> diff --git a/ui/src/features/projects/members/components/AddMemberModal.tsx b/ui/src/features/projects/members/components/AddMemberModal.tsx index 5af96413..96f1abda 100644 --- a/ui/src/features/projects/members/components/AddMemberModal.tsx +++ b/ui/src/features/projects/members/components/AddMemberModal.tsx @@ -1,6 +1,5 @@ import { Select, Stack } from '@mantine/core' import { MemberType } from '@matrixhub/api-ts/v1alpha1/project.pb' -import { Users } from '@matrixhub/api-ts/v1alpha1/user.pb' import { useStore } from '@tanstack/react-form' import { useMutation, @@ -9,6 +8,7 @@ import { import { useTranslation } from 'react-i18next' import { z } from 'zod' +import { allUsersQueryOptions } from '@/features/admin/users/users.query' import { ModalWrapper } from '@/shared/components/ModalWrapper' import { useForm } from '@/shared/hooks/useForm' import { fieldError } from '@/shared/utils/form' @@ -70,11 +70,7 @@ export function AddMemberModal({ const memberType = useStore(form.store, s => s.values.memberType) const { data: usersData } = useQuery({ - queryKey: ['users', 'list'], - queryFn: () => Users.ListUsers({ - page: 1, - pageSize: -1, - }), + ...allUsersQueryOptions(), enabled: opened && memberType === MemberType.MEMBER_TYPE_USER, }) @@ -114,6 +110,7 @@ export function AddMemberModal({ withAsterisk data={memberTypeOptions} value={field.state.value} + onBlur={field.handleBlur} onChange={(value) => { field.handleChange(value ?? '') form.setFieldValue('memberId', '') @@ -133,6 +130,7 @@ export function AddMemberModal({ data={userOptions} value={field.state.value || null} onChange={value => field.handleChange(value ?? '')} + onBlur={field.handleBlur} searchable disabled={memberType === MemberType.MEMBER_TYPE_GROUP} error={fieldError(field)} @@ -150,6 +148,7 @@ export function AddMemberModal({ data={roleOptions} value={field.state.value || null} onChange={value => field.handleChange(value ?? '')} + onBlur={field.handleBlur} error={fieldError(field)} /> )} diff --git a/ui/src/features/projects/members/components/EditRoleModal.tsx b/ui/src/features/projects/members/components/EditRoleModal.tsx index bac16902..23b96813 100644 --- a/ui/src/features/projects/members/components/EditRoleModal.tsx +++ b/ui/src/features/projects/members/components/EditRoleModal.tsx @@ -95,6 +95,7 @@ export function EditRoleModal({ data={roleOptions} value={field.state.value || null} onChange={value => field.handleChange(value ?? '')} + onBlur={field.handleBlur} error={fieldError(field)} /> )} diff --git a/ui/src/features/projects/members/members.mutation.ts b/ui/src/features/projects/members/members.mutation.ts index cc875dc7..e06f379f 100644 --- a/ui/src/features/projects/members/members.mutation.ts +++ b/ui/src/features/projects/members/members.mutation.ts @@ -6,15 +6,20 @@ import { } from '@matrixhub/api-ts/v1alpha1/project.pb' import { mutationOptions } from '@tanstack/react-query' +import i18n from '@/i18n' + import { memberKeys } from './members.query' +import type { NotificationMeta } from '@/types/tanstack-query' + export function addMemberMutationOptions() { return mutationOptions({ mutationFn: (input: AddProjectMemberWithRoleRequest) => Projects.AddProjectMemberWithRole(input), meta: { + errorMessage: i18n.t('projects.detail.membersPage.notifications.addError'), invalidates: [memberKeys.lists()], - }, + } satisfies NotificationMeta, }) } @@ -23,8 +28,9 @@ export function updateMemberRoleMutationOptions() { mutationFn: (input: UpdateProjectMemberRoleRequest) => Projects.UpdateProjectMemberRole(input), meta: { + errorMessage: i18n.t('projects.detail.membersPage.notifications.updateRoleError'), invalidates: [memberKeys.lists()], - }, + } satisfies NotificationMeta, }) } @@ -33,7 +39,8 @@ export function removeMembersMutationOptions() { mutationFn: (input: RemoveProjectMembersRequest) => Projects.RemoveProjectMembers(input), meta: { + errorMessage: i18n.t('projects.detail.membersPage.notifications.removeError'), invalidates: [memberKeys.lists()], - }, + } satisfies NotificationMeta, }) } diff --git a/ui/src/features/projects/pages/ProjectModelsPage.tsx b/ui/src/features/projects/pages/ProjectModelsPage.tsx index 189bee62..1d096d14 100644 --- a/ui/src/features/projects/pages/ProjectModelsPage.tsx +++ b/ui/src/features/projects/pages/ProjectModelsPage.tsx @@ -13,9 +13,9 @@ import { getRouteApi, Link } from '@tanstack/react-router' import { startTransition } from 'react' import { useTranslation } from 'react-i18next' +import { ModelCard } from '@/features/models/components/ModelCard' import { projectModelsQueryOptions } from '@/features/models/models.query.ts' import { Pagination } from '@/shared/components/Pagination' -import { ModelCard } from '@/shared/components/resource-card/ModelCard.tsx' import { ResourceCardGrid } from '@/shared/components/ResourceCardGrid' import { SearchToolbar } from '@/shared/components/SearchToolbar' import { SortDropdown } from '@/shared/components/SortDropdown' diff --git a/ui/src/locales/en/login.json b/ui/src/locales/en/login.json index b59f347a..1b7ea269 100644 --- a/ui/src/locales/en/login.json +++ b/ui/src/locales/en/login.json @@ -3,5 +3,7 @@ "username": "Username", "password": "Password", "rememberMe": "Remember me", - "submit": "Log in" + "submit": "Log in", + "error": "Failed to log in", + "logoutError": "Failed to log out" } diff --git a/ui/src/locales/en/profile.json b/ui/src/locales/en/profile.json index 8ce8e946..33269e5b 100644 --- a/ui/src/locales/en/profile.json +++ b/ui/src/locales/en/profile.json @@ -14,13 +14,15 @@ "title": "Import SSH Public Key", "name": "SSH Public Key Name", "publicKey": "Import Public Key", - "success": "SSH public key imported successfully" + "success": "SSH public key imported successfully", + "error": "Failed to import SSH public key" }, "delete": { "action": "Delete", "title": "Delete", "confirm": "Are you sure you want to delete SSH public key {{name}}? After deletion, this key becomes invalid immediately.", - "success": "SSH public key deleted successfully" + "success": "SSH public key deleted successfully", + "error": "Failed to delete SSH public key" }, "status": { "SSH_KEY_STATUS_VALID": "Valid", @@ -36,6 +38,7 @@ "confirmNewPassword": "Confirm New Password", "passwordMismatch": "Passwords do not match", "passwordChanged": "Password changed successfully", + "passwordChangeError": "Failed to change password", "createToken": "Create Token", "newTokenName": "Token Name", "tokenName": "Name", @@ -52,8 +55,10 @@ "deleteToken": "Delete Token", "deleteTokenConfirm": "Are you sure you want to delete token {{name}}? This action cannot be undone.", "tokenCreated": "Token created successfully", + "tokenCreateError": "Failed to create token", "validity": "Validity", "tokenDeleted": "Token deleted successfully", + "tokenDeleteError": "Failed to delete token", "expireNever": "Never", "expireCustom": "Custom", "expireTime": "Expiration Date", diff --git a/ui/src/locales/en/projects.json b/ui/src/locales/en/projects.json index e5e0ce0d..5c207a32 100644 --- a/ui/src/locales/en/projects.json +++ b/ui/src/locales/en/projects.json @@ -63,6 +63,11 @@ "batchRemoveModal": { "title": "Batch Remove", "message": "Are you sure you want to remove {{count}} members? After removal, these members will no longer have the corresponding permissions for {{projectName}}." + }, + "notifications": { + "addError": "Failed to add member", + "updateRoleError": "Failed to update member permission", + "removeError": "Failed to remove member" } } }, diff --git a/ui/src/locales/zh/login.json b/ui/src/locales/zh/login.json index 7832cc01..afb735d3 100644 --- a/ui/src/locales/zh/login.json +++ b/ui/src/locales/zh/login.json @@ -3,5 +3,7 @@ "username": "用户名", "password": "密码", "rememberMe": "记住我的登录状态", - "submit": "登录" + "submit": "登录", + "error": "登录失败", + "logoutError": "退出登录失败" } diff --git a/ui/src/locales/zh/profile.json b/ui/src/locales/zh/profile.json index c3ca493a..97f68754 100644 --- a/ui/src/locales/zh/profile.json +++ b/ui/src/locales/zh/profile.json @@ -14,13 +14,15 @@ "title": "导入 SSH 公钥", "name": "SSH 公钥名称", "publicKey": "导入公钥", - "success": "SSH 公钥导入成功" + "success": "SSH 公钥导入成功", + "error": "SSH 公钥导入失败" }, "delete": { "action": "删除", "title": "删除", "confirm": "确认删除 SSH 公钥 {{name}} 吗?删除此公钥后,此公钥立即失效。", - "success": "SSH 公钥删除成功" + "success": "SSH 公钥删除成功", + "error": "SSH 公钥删除失败" }, "status": { "SSH_KEY_STATUS_VALID": "有效", @@ -36,6 +38,7 @@ "confirmNewPassword": "确认新密码", "passwordMismatch": "两次密码不一致", "passwordChanged": "密码修改成功", + "passwordChangeError": "密码修改失败", "createToken": "创建密钥", "newTokenName": "密钥名称", "tokenName": "名称", @@ -52,8 +55,10 @@ "deleteToken": "删除密钥", "deleteTokenConfirm": "确认删除密钥 {{name}} 吗?此操作不可撤销。", "tokenCreated": "密钥创建成功", + "tokenCreateError": "密钥创建失败", "validity": "有效期", "tokenDeleted": "密钥删除成功", + "tokenDeleteError": "密钥删除失败", "expireNever": "永久", "expireCustom": "自定义", "expireTime": "过期时间", diff --git a/ui/src/locales/zh/projects.json b/ui/src/locales/zh/projects.json index 60b02503..d651a558 100644 --- a/ui/src/locales/zh/projects.json +++ b/ui/src/locales/zh/projects.json @@ -63,6 +63,11 @@ "batchRemoveModal": { "title": "批量移除", "message": "确定要移除 {{count}} 个成员吗?移除后该成员将不再拥有 {{projectName}} 的相应权限。" + }, + "notifications": { + "addError": "添加成员失败", + "updateRoleError": "更新成员权限失败", + "removeError": "移除成员失败" } } }, diff --git a/ui/src/queryClient.ts b/ui/src/queryClient.ts index dc63758f..bc2daa50 100644 --- a/ui/src/queryClient.ts +++ b/ui/src/queryClient.ts @@ -19,6 +19,17 @@ export function getErrorMessage(error: unknown): string { return String(error) } +function resolveNotificationMetaValue( + value: string | ((data: unknown, variables: unknown, context: unknown) => string | undefined) | undefined, + data: unknown, + variables: unknown, + context: unknown, +) { + return typeof value === 'function' + ? value(data, variables, context) + : value +} + export const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { @@ -36,17 +47,29 @@ export const queryClient = new QueryClient({ }, }), mutationCache: new MutationCache({ - onSuccess: (_data, _variables, _context, mutation) => { + onSuccess: (data, variables, context, mutation) => { const meta = mutation.meta as NotificationMeta | undefined if (meta?.skipNotification) { return } - if (meta?.successMessage) { + const successMessage = resolveNotificationMetaValue( + meta?.successMessage, + data, + variables, + context, + ) + + if (successMessage) { notifications.show({ - message: meta.successMessage, - color: 'green', + message: successMessage, + color: resolveNotificationMetaValue( + meta?.successColor, + data, + variables, + context, + ) ?? 'green', }) } diff --git a/ui/src/routes/(auth)/(app)/projects/index.tsx b/ui/src/routes/(auth)/(app)/projects/index.tsx index ad493c01..750c098f 100644 --- a/ui/src/routes/(auth)/(app)/projects/index.tsx +++ b/ui/src/routes/(auth)/(app)/projects/index.tsx @@ -29,7 +29,7 @@ export const Route = createFileRoute('/(auth)/(app)/projects/')({ loader: ({ context, deps, }) => { - void context.queryClient.ensureQueryData( + void context.queryClient.prefetchQuery( projectsQueryOptions({ query: deps.query ?? '', page: deps.page ?? DEFAULT_PROJECTS_PAGE, diff --git a/ui/src/routes/(auth)/route.tsx b/ui/src/routes/(auth)/route.tsx index bf9e92ae..58374c1b 100644 --- a/ui/src/routes/(auth)/route.tsx +++ b/ui/src/routes/(auth)/route.tsx @@ -9,7 +9,6 @@ import { UnstyledButton, rem, } from '@mantine/core' -import { Login } from '@matrixhub/api-ts/v1alpha1/login.pb' import { IconChevronDown as ArrowDownIcon, IconCube as ModelIcon, @@ -34,6 +33,7 @@ import { useTranslation } from 'react-i18next' import LogoIcon from '@/assets/svgs/logo.svg?react' import { CurrentUserContext } from '@/context/current-user-context.tsx' import { currentUserQueryOptions } from '@/features/auth/auth.query' +import { logoutMutationOptions } from '@/features/login/login.mutation' import { queryClient } from '@/queryClient' import { Route as ModelsRoute } from '@/routes/(auth)/(app)/models' import { Route as CreateModelRoute } from '@/routes/(auth)/(app)/models/new' @@ -194,7 +194,7 @@ function AccountMenu() { const { mutate: logout, } = useMutation({ - mutationFn: () => Login.Logout({}), + ...logoutMutationOptions(), onSuccess: () => { queryClient.clear() window.location.reload() diff --git a/ui/src/types/tanstack-query.ts b/ui/src/types/tanstack-query.ts index 1dffc0b8..33cc369e 100644 --- a/ui/src/types/tanstack-query.ts +++ b/ui/src/types/tanstack-query.ts @@ -1,7 +1,15 @@ /** Shared meta shape for query and mutation notification behavior. */ +export type NotificationMetaResolver = ( + data: unknown, + variables: unknown, + context: unknown, +) => T | undefined + export interface NotificationMeta { /** Notification message shown on success */ - successMessage?: string + successMessage?: string | NotificationMetaResolver + /** Notification color shown on success */ + successColor?: string | NotificationMetaResolver /** Notification title shown on error */ errorMessage?: string /** Skip all notifications for this query/mutation */ diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 1dfc95ce..cdc1e09d 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -7,6 +7,62 @@ import { import svgr from 'vite-plugin-svgr' import tsconfigPaths from 'vite-tsconfig-paths' +function manualChunks(id: string) { + if (!id.includes('/node_modules/')) { + return undefined + } + + if ( + id.includes('/node_modules/mantine-react-table/') + || id.includes('/node_modules/@tanstack/table') + ) { + return 'vendor-tanstack' + } + + if ( + id.includes('/node_modules/react/') + || id.includes('/node_modules/react-dom/') + || id.includes('/node_modules/scheduler/') + ) { + return 'vendor-react' + } + + if ( + id.includes('/node_modules/@mantine/') + || id.includes('/node_modules/@floating-ui/') + ) { + return 'vendor-mantine' + } + + if (id.includes('/node_modules/@tanstack/')) { + return 'vendor-tanstack' + } + + if (id.includes('/node_modules/@tabler/icons-react/')) { + return 'vendor-icons' + } + + if ( + id.includes('/node_modules/@monaco-editor/') + || id.includes('/node_modules/monaco-editor/') + ) { + return 'vendor-monaco' + } + + if (id.includes('/node_modules/diff2html/')) { + return 'vendor-diff' + } + + if ( + id.includes('/node_modules/i18next') + || id.includes('/node_modules/dayjs/') + ) { + return 'vendor-i18n' + } + + return 'vendor' +} + // https://vite.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') @@ -26,6 +82,13 @@ export default defineConfig(({ mode }) => { optimizeDeps: { include: ['mantine-react-table'], }, + build: { + rollupOptions: { + output: { + manualChunks, + }, + }, + }, plugins: [ // Please make sure that '@tanstack/router-plugin' is passed before '@vitejs/plugin-react' tanstackRouter({