change basic form to form context #9

Merged
sam merged 1 commits from refactor/form-context into main 2026-01-23 09:51:04 +00:00
21 changed files with 558 additions and 804 deletions

View File

@@ -93,6 +93,7 @@
"settings_form_description": "Description", "settings_form_description": "Description",
"settings_form_keywords": "keywords", "settings_form_keywords": "keywords",
"settings_form_language": "Language", "settings_form_language": "Language",
"settings_form_select_language": "Please select language",
"settings_ui_title": "Settings", "settings_ui_title": "Settings",
"settings_messages_update_success": "Updated settings successfully!", "settings_messages_update_success": "Updated settings successfully!",
"settings_messages_update_fail": "Update fail!", "settings_messages_update_fail": "Update fail!",

View File

@@ -93,6 +93,7 @@
"settings_form_description": "Mô tả website", "settings_form_description": "Mô tả website",
"settings_form_keywords": "Từ khóa", "settings_form_keywords": "Từ khóa",
"settings_form_language": "Ngôn ngữ", "settings_form_language": "Ngôn ngữ",
"settings_form_select_language": "Hãy chọn ngôn ngữ",
"settings_ui_title": "Cài đặt", "settings_ui_title": "Cài đặt",
"settings_messages_update_success": "Cập nhật cài đặt thành công!", "settings_messages_update_success": "Cập nhật cài đặt thành công!",
"settings_messages_update_fail": "Cập nhật cài đặt thất bại!", "settings_messages_update_fail": "Cập nhật cài đặt thất bại!",

View File

@@ -1,21 +1,12 @@
import { useAppForm } from '@/hooks/use-app-form';
import { m } from '@/paraglide/messages'; import { m } from '@/paraglide/messages';
import { userBanSchema } from '@/service/user.schema'; import { userBanSchema } from '@/service/user.schema';
import { WarningIcon } from '@phosphor-icons/react'; import { WarningIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { UserWithRole } from 'better-auth/plugins'; import { UserWithRole } from 'better-auth/plugins';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { DialogClose, DialogFooter } from '../ui/dialog'; import { DialogClose, DialogFooter } from '../ui/dialog';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldGroup } from '../ui/field';
import { Input } from '../ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Textarea } from '../ui/textarea';
import { useBanContext } from '../user/ban-user-dialog'; import { useBanContext } from '../user/ban-user-dialog';
type FormProps = { type FormProps = {
@@ -25,7 +16,7 @@ type FormProps = {
const BanUserForm = ({ data }: FormProps) => { const BanUserForm = ({ data }: FormProps) => {
const { setSubmitData, setOpen, setOpenConfirm } = useBanContext(); const { setSubmitData, setOpen, setOpenConfirm } = useBanContext();
const form = useForm({ const form = useAppForm({
defaultValues: { defaultValues: {
id: data.id, id: data.id,
banReason: '', banReason: '',
@@ -59,99 +50,33 @@ const BanUserForm = ({ data }: FormProps) => {
<AlertTitle> <AlertTitle>
{m.profile_form_name()}: {data.name} {m.profile_form_name()}: {data.name}
</AlertTitle> </AlertTitle>
<AlertDescription className="sr-only">adá</AlertDescription> <AlertDescription className="sr-only">{data.name}</AlertDescription>
</Alert> </Alert>
<form.Field <form.AppField name="id">
name="id" {(field) => <field.HiddenField />}
children={(field) => { </form.AppField>
const isInvalid = <form.AppField name="banReason">
field.state.meta.isTouched && !field.state.meta.isValid; {(field) => (
return ( <field.TextArea label={m.users_page_ui_form_ban_reason()} />
<Field data-invalid={isInvalid}> )}
<Input </form.AppField>
type="hidden" <form.AppField name="banExp">
name={field.name} {(field) => (
id={field.name} <field.SelectNumber
value={field.state.value} label={m.users_page_ui_form_ban_exp()}
aria-invalid={isInvalid} values={[
/> { label: m.exp_time({ time: '1' }), value: '1' },
{isInvalid && <FieldError errors={field.state.meta.errors} />} { label: m.exp_time({ time: '7' }), value: '7' },
</Field> { label: m.exp_time({ time: '15' }), value: '15' },
); { label: m.exp_time({ time: '30' }), value: '30' },
}} { label: m.exp_time({ time: '180' }), value: '180' },
/> { label: m.exp_time({ time: '365' }), value: '365' },
<form.Field { label: m.exp_time({ time: '99999' }), value: '99999' },
name="banReason" ]}
children={(field) => { placeholder={m.users_page_ui_select_placeholder_ban_exp()}
const isInvalid = />
field.state.meta.isTouched && !field.state.meta.isValid; )}
return ( </form.AppField>
<Field data-invalid={isInvalid} className="col-span-2">
<FieldLabel htmlFor={field.name}>
{m.users_page_ui_form_ban_reason()}:
</FieldLabel>
<Textarea
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
rows={4}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="banExp"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.users_page_ui_form_ban_exp()}
</FieldLabel>
<Select
name={field.name}
value={String(field.state.value)}
onValueChange={(value) => field.handleChange(Number(value))}
>
<SelectTrigger aria-invalid={isInvalid}>
<SelectValue
placeholder={m.users_page_ui_select_placeholder_ban_exp()}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{m.exp_time({ time: '1' })}
</SelectItem>
<SelectItem value="7">
{m.exp_time({ time: '7' })}
</SelectItem>
<SelectItem value="15">
{m.exp_time({ time: '15' })}
</SelectItem>
<SelectItem value="30">
{m.exp_time({ time: '30' })}
</SelectItem>
<SelectItem value="180">
{m.exp_time({ time: '180' })}
</SelectItem>
<SelectItem value="365">
{m.exp_time({ time: '365' })}
</SelectItem>
<SelectItem value="99999">
{m.exp_time({ time: '99999' })}
</SelectItem>
</SelectContent>
</Select>
</Field>
);
}}
/>
<Field> <Field>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
@@ -159,9 +84,12 @@ const BanUserForm = ({ data }: FormProps) => {
{m.ui_cancel_btn()} {m.ui_cancel_btn()}
</Button> </Button>
</DialogClose> </DialogClose>
<Button type="submit" variant="destructive"> <form.AppForm>
{m.ui_ban_btn()} <form.SubscribeButton
</Button> label={m.ui_ban_btn()}
variant="destructive"
/>
</form.AppForm>
</DialogFooter> </DialogFooter>
</Field> </Field>
</FieldGroup> </FieldGroup>

View File

@@ -1,22 +1,14 @@
import { useAppForm } from '@/hooks/use-app-form';
import { m } from '@/paraglide/messages'; import { m } from '@/paraglide/messages';
import { usersQueries } from '@/service/queries'; import { usersQueries } from '@/service/queries';
import { createUser } from '@/service/user.api'; import { createUser } from '@/service/user.api';
import { RoleEnum, userCreateSchema } from '@/service/user.schema'; import { userCreateSchema } from '@/service/user.schema';
import { ReturnError } from '@/types/common'; import { ReturnError } from '@/types/common';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { DialogClose, DialogFooter } from '../ui/dialog'; import { DialogClose, DialogFooter } from '../ui/dialog';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldGroup } from '../ui/field';
import { Input } from '../ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
type FormProps = { type FormProps = {
onSubmit: (open: boolean) => void; onSubmit: (open: boolean) => void;
@@ -44,7 +36,7 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
}, },
}); });
const form = useForm({ const form = useAppForm({
defaultValues: { defaultValues: {
email: '', email: '',
password: '', password: '',
@@ -70,114 +62,33 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
}} }}
> >
<FieldGroup> <FieldGroup>
<form.Field <form.AppField name="email">
name="email" {(field) => <field.TextField label={m.login_page_form_email()} />}
children={(field) => { </form.AppField>
const isInvalid = <form.AppField name="password">
field.state.meta.isTouched && !field.state.meta.isValid; {(field) => (
return ( <field.TextField
<Field data-invalid={isInvalid} className="col-span-2"> label={m.login_page_form_password()}
<FieldLabel htmlFor={field.name}> type="password"
{m.login_page_form_email()}: />
</FieldLabel> )}
<Input </form.AppField>
id={field.name} <form.AppField name="name">
name={field.name} {(field) => <field.TextField label={m.profile_form_name()} />}
value={field.state.value} </form.AppField>
onBlur={field.handleBlur} <form.AppField name="role">
onChange={(e) => field.handleChange(e.target.value)} {(field) => (
aria-invalid={isInvalid} <field.Select
type="email" label={m.profile_form_role()}
/> placeholder={m.users_page_ui_select_placeholder_role()}
{isInvalid && <FieldError errors={field.state.meta.errors} />} isRole
</Field> values={[
); { value: 'admin', label: m.role_tags({ role: 'admin' }) },
}} { value: 'user', label: m.role_tags({ role: 'user' }) },
/> ]}
<form.Field />
name="password" )}
children={(field) => { </form.AppField>
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.login_page_form_password()}
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="password"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="name"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.profile_form_name()}
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="text"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="role"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.profile_form_role()}
</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={(value) =>
field.handleChange(RoleEnum.parse(value))
}
>
<SelectTrigger aria-invalid={isInvalid}>
<SelectValue
placeholder={m.users_page_ui_select_placeholder_role()}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">
{m.role_tags({ role: 'admin' })}
</SelectItem>
<SelectItem value="user">
{m.role_tags({ role: 'user' })}
</SelectItem>
</SelectContent>
</Select>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<Field> <Field>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
@@ -185,9 +96,9 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
{m.ui_cancel_btn()} {m.ui_cancel_btn()}
</Button> </Button>
</DialogClose> </DialogClose>
<Button type="submit" variant="destructive"> <form.AppForm>
{m.ui_signup_btn()} <form.SubscribeButton label={m.ui_signup_btn()} />
</Button> </form.AppForm>
</DialogFooter> </DialogFooter>
</Field> </Field>
</FieldGroup> </FieldGroup>

View File

@@ -1,16 +1,15 @@
import { useAppForm } from '@/hooks/use-app-form';
import { m } from '@/paraglide/messages'; import { m } from '@/paraglide/messages';
import { usersQueries } from '@/service/queries'; import { usersQueries } from '@/service/queries';
import { setUserPassword } from '@/service/user.api'; import { setUserPassword } from '@/service/user.api';
import { userSetPasswordSchema } from '@/service/user.schema'; import { userSetPasswordSchema } from '@/service/user.schema';
import { ReturnError } from '@/types/common'; import { ReturnError } from '@/types/common';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UserWithRole } from 'better-auth/plugins'; import { UserWithRole } from 'better-auth/plugins';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { DialogClose, DialogFooter } from '../ui/dialog'; import { DialogClose, DialogFooter } from '../ui/dialog';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldGroup } from '../ui/field';
import { Input } from '../ui/input';
type FormProps = { type FormProps = {
data: UserWithRole; data: UserWithRole;
@@ -39,7 +38,7 @@ const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
}, },
}); });
const form = useForm({ const form = useAppForm({
defaultValues: { defaultValues: {
id: data.id, id: data.id,
password: '', password: '',
@@ -62,49 +61,14 @@ const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
}} }}
> >
<FieldGroup> <FieldGroup>
<form.Field <form.AppField name="id">
name="id" {(field) => <field.HiddenField />}
children={(field) => { </form.AppField>
const isInvalid = <form.AppField name="password">
field.state.meta.isTouched && !field.state.meta.isValid; {(field) => (
return ( <field.TextField label={m.change_password_form_new_password()} />
<Field data-invalid={isInvalid}> )}
<Input </form.AppField>
type="hidden"
name={field.name}
id={field.name}
value={field.state.value}
aria-invalid={isInvalid}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="password"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.change_password_form_new_password()}
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="password"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<Field> <Field>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
@@ -112,7 +76,9 @@ const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
{m.ui_cancel_btn()} {m.ui_cancel_btn()}
</Button> </Button>
</DialogClose> </DialogClose>
<Button type="submit">{m.ui_save_btn()}</Button> <form.AppForm>
<form.SubscribeButton label={m.ui_save_btn()} />
</form.AppForm>
</DialogFooter> </DialogFooter>
</Field> </Field>
</FieldGroup> </FieldGroup>

View File

@@ -1,23 +1,15 @@
import { useAppForm } from '@/hooks/use-app-form';
import { m } from '@/paraglide/messages'; import { m } from '@/paraglide/messages';
import { usersQueries } from '@/service/queries'; import { usersQueries } from '@/service/queries';
import { setUserRole } from '@/service/user.api'; import { setUserRole } from '@/service/user.api';
import { RoleEnum, userUpdateRoleSchema } from '@/service/user.schema'; import { userUpdateRoleSchema } from '@/service/user.schema';
import { ReturnError } from '@/types/common'; import { ReturnError } from '@/types/common';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UserWithRole } from 'better-auth/plugins'; import { UserWithRole } from 'better-auth/plugins';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { DialogClose, DialogFooter } from '../ui/dialog'; import { DialogClose, DialogFooter } from '../ui/dialog';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldGroup } from '../ui/field';
import { Input } from '../ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
type SetRoleFormProps = { type SetRoleFormProps = {
data: UserWithRole; data: UserWithRole;
@@ -51,7 +43,7 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
}, },
}); });
const form = useForm({ const form = useAppForm({
defaultValues: userUpdateRoleSchema.parse(defaultFormValues), defaultValues: userUpdateRoleSchema.parse(defaultFormValues),
validators: { validators: {
onChange: userUpdateRoleSchema, onChange: userUpdateRoleSchema,
@@ -72,61 +64,21 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
}} }}
> >
<FieldGroup> <FieldGroup>
<form.Field <form.AppField name="id">
name="id" {(field) => <field.HiddenField />}
children={(field) => { </form.AppField>
const isInvalid = <form.AppField name="role">
field.state.meta.isTouched && !field.state.meta.isValid; {(field) => (
return ( <field.Select
<Field data-invalid={isInvalid}> label={m.profile_form_role()}
<Input placeholder={m.users_page_ui_select_placeholder_role()}
type="hidden" values={[
name={field.name} { value: 'admin', label: m.role_tags({ role: 'admin' }) },
id={field.name} { value: 'user', label: m.role_tags({ role: 'user' }) },
value={field.state.value} ]}
aria-invalid={isInvalid} />
/> )}
{isInvalid && <FieldError errors={field.state.meta.errors} />} </form.AppField>
</Field>
);
}}
/>
<form.Field
name="role"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.profile_form_role()}
</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={(value) =>
field.handleChange(RoleEnum.parse(value))
}
>
<SelectTrigger aria-invalid={isInvalid}>
<SelectValue
placeholder={m.users_page_ui_select_placeholder_role()}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">
{m.role_tags({ role: 'admin' })}
</SelectItem>
<SelectItem value="user">
{m.role_tags({ role: 'user' })}
</SelectItem>
</SelectContent>
</Select>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<Field> <Field>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
@@ -134,7 +86,9 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
{m.ui_cancel_btn()} {m.ui_cancel_btn()}
</Button> </Button>
</DialogClose> </DialogClose>
<Button type="submit">{m.ui_save_btn()}</Button> <form.AppForm>
<form.SubscribeButton label={m.ui_save_btn()} />
</form.AppForm>
</DialogFooter> </DialogFooter>
</Field> </Field>
</FieldGroup> </FieldGroup>

View File

@@ -1,16 +1,15 @@
import { useAppForm } from '@/hooks/use-app-form';
import { m } from '@/paraglide/messages'; import { m } from '@/paraglide/messages';
import { usersQueries } from '@/service/queries'; import { usersQueries } from '@/service/queries';
import { updateUserInformation } from '@/service/user.api'; import { updateUserInformation } from '@/service/user.api';
import { userUpdateInfoSchema } from '@/service/user.schema'; import { userUpdateInfoSchema } from '@/service/user.schema';
import { ReturnError } from '@/types/common'; import { ReturnError } from '@/types/common';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UserWithRole } from 'better-auth/plugins'; import { UserWithRole } from 'better-auth/plugins';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { DialogClose, DialogFooter } from '../ui/dialog'; import { DialogClose, DialogFooter } from '../ui/dialog';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldGroup } from '../ui/field';
import { Input } from '../ui/input';
type UpdateUserFormProps = { type UpdateUserFormProps = {
data: UserWithRole; data: UserWithRole;
@@ -38,7 +37,7 @@ const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
}); });
}, },
}); });
const form = useForm({ const form = useAppForm({
defaultValues: { defaultValues: {
id: data.id, id: data.id,
name: data.name, name: data.name,
@@ -61,49 +60,12 @@ const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
}} }}
> >
<FieldGroup> <FieldGroup>
<form.Field <form.AppField name="id">
name="id" {(field) => <field.HiddenField />}
children={(field) => { </form.AppField>
const isInvalid = <form.AppField name="name">
field.state.meta.isTouched && !field.state.meta.isValid; {(field) => <field.TextField label={m.profile_form_name()} />}
return ( </form.AppField>
<Field data-invalid={isInvalid}>
<Input
type="hidden"
name={field.name}
id={field.name}
value={field.state.value}
aria-invalid={isInvalid}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="name"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.profile_form_name()}
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="text"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<Field> <Field>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
@@ -111,7 +73,9 @@ const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
{m.ui_cancel_btn()} {m.ui_cancel_btn()}
</Button> </Button>
</DialogClose> </DialogClose>
<Button type="submit">{m.ui_save_btn()}</Button> <form.AppForm>
<form.SubscribeButton label={m.ui_save_btn()} />
</form.AppForm>
</DialogFooter> </DialogFooter>
</Field> </Field>
</FieldGroup> </FieldGroup>

View File

@@ -1,43 +1,14 @@
import { useAppForm } from '@/hooks/use-app-form';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { m } from '@/paraglide/messages'; import { m } from '@/paraglide/messages';
import {
ChangePassword,
ChangePasswordFormSchema,
} from '@/service/user.schema';
import { KeyIcon } from '@phosphor-icons/react'; import { KeyIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import z from 'zod';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldGroup } from '../ui/field';
import { Input } from '../ui/input';
const ChangePasswordFormSchema = z
.object({
currentPassword: z.string().nonempty(
m.common_is_required({
field: m.change_password_form_current_password(),
}),
),
newPassword: z.string().nonempty(
m.common_is_required({
field: m.change_password_form_new_password(),
}),
),
confirmPassword: z.string().nonempty(
m.common_is_required({
field: m.change_password_form_confirm_password(),
}),
),
})
.superRefine((data, ctx) => {
if (data.newPassword !== data.confirmPassword) {
ctx.addIssue({
path: ['confirmPassword'],
code: z.ZodIssueCode.custom,
message: m.change_password_messages_password_not_match(),
});
}
});
type ChangePassword = z.infer<typeof ChangePasswordFormSchema>;
const defaultValues: ChangePassword = { const defaultValues: ChangePassword = {
currentPassword: '', currentPassword: '',
@@ -46,7 +17,7 @@ const defaultValues: ChangePassword = {
}; };
const ChangePasswordForm = () => { const ChangePasswordForm = () => {
const form = useForm({ const form = useAppForm({
defaultValues, defaultValues,
validators: { validators: {
onSubmit: ChangePasswordFormSchema, onSubmit: ChangePasswordFormSchema,
@@ -98,86 +69,33 @@ const ChangePasswordForm = () => {
}} }}
> >
<FieldGroup> <FieldGroup>
<form.Field <form.AppField name="currentPassword">
name="currentPassword" {(field) => (
children={(field) => { <field.TextField
const isInvalid = label={m.change_password_form_current_password()}
field.state.meta.isTouched && !field.state.meta.isValid; />
return ( )}
<Field data-invalid={isInvalid}> </form.AppField>
<FieldLabel htmlFor={field.name}> <form.AppField name="newPassword">
{m.change_password_form_current_password()}: {(field) => (
</FieldLabel> <field.TextField
<Input label={m.change_password_form_new_password()}
id={field.name} type="password"
name={field.name} />
value={field.state.value} )}
onBlur={field.handleBlur} </form.AppField>
onChange={(e) => field.handleChange(e.target.value)} <form.AppField name="confirmPassword">
aria-invalid={isInvalid} {(field) => (
type="password" <field.TextField
/> label={m.change_password_form_confirm_password()}
{isInvalid && ( type="password"
<FieldError errors={field.state.meta.errors} /> />
)} )}
</Field> </form.AppField>
);
}}
/>
<form.Field
name="newPassword"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.change_password_form_new_password()}:
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="password"
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
/>
<form.Field
name="confirmPassword"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.change_password_form_confirm_password()}:
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="password"
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
/>
<Field> <Field>
<Button type="submit">{m.ui_change_password_btn()}</Button> <form.AppForm>
<form.SubscribeButton label={m.ui_change_password_btn()} />
</form.AppForm>
</Field> </Field>
</FieldGroup> </FieldGroup>
</form> </form>

View File

@@ -0,0 +1,218 @@
import { useFieldContext, useFormContext } from '@/hooks/use-app-form';
import { RoleEnum } from '@/service/user.schema';
import { useStore } from '@tanstack/react-form';
import { type VariantProps } from 'class-variance-authority';
import { Button, buttonVariants } from '../ui/button';
import { Field, FieldError, FieldLabel } from '../ui/field';
import { Input } from '../ui/input';
import * as ShadcnSelect from '../ui/select';
import { Textarea } from '../ui/textarea';
export function SubscribeButton({
label,
variant = 'default',
}: {
label: string;
} & VariantProps<typeof buttonVariants>) {
const form = useFormContext();
return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<Button type="submit" disabled={isSubmitting} variant={variant}>
{label}
</Button>
)}
</form.Subscribe>
);
}
export function HiddenField() {
const field = useFieldContext<string>();
const errors = useStore(field.store, (state) => state.meta.errors);
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<Input
id={field.name}
name={field.name}
value={field.state.value}
aria-invalid={isInvalid}
type="hidden"
/>
{isInvalid && <FieldError errors={errors} />}
</Field>
);
}
export function TextField({
label,
placeholder,
type = 'text',
}: {
label: string;
placeholder?: string;
type?: string;
}) {
const field = useFieldContext<string>();
const errors = useStore(field.store, (state) => state.meta.errors);
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>{label}:</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
placeholder={placeholder}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type={type}
/>
{isInvalid && <FieldError errors={errors} />}
</Field>
);
}
export function FileField({
label,
className,
...props
}: {
label: string;
className?: string;
} & React.ComponentProps<'input'>) {
const field = useFieldContext<string>();
const errors = useStore(field.store, (state) => state.meta.errors);
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid} className={className}>
<FieldLabel htmlFor={field.name}>{label}:</FieldLabel>
<Input
type="file"
id={field.name}
name={field.name}
aria-invalid={isInvalid}
{...props}
/>
{isInvalid && <FieldError errors={errors} />}
</Field>
);
}
export function TextArea({
label,
rows = 4,
}: {
label: string;
rows?: number;
}) {
const field = useFieldContext<string>();
const errors = useStore(field.store, (state) => state.meta.errors);
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid} className="col-span-2">
<FieldLabel htmlFor={field.name}>{label}:</FieldLabel>
<Textarea
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
rows={rows}
/>
{isInvalid && <FieldError errors={errors} />}
</Field>
);
}
export function Select({
label,
values,
placeholder,
isRole = false,
}: {
label: string;
values: Array<{ label: string; value: string }>;
placeholder?: string;
isRole?: boolean;
}) {
const field = useFieldContext<string>();
const errors = useStore(field.store, (state) => state.meta.errors);
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid} className="col-span-2">
<FieldLabel htmlFor={field.name}>{label}:</FieldLabel>
<ShadcnSelect.Select
name={field.name}
value={String(field.state.value)}
onValueChange={(value) =>
isRole
? field.handleChange(RoleEnum.parse(value))
: field.handleChange(value)
}
>
<ShadcnSelect.SelectTrigger aria-invalid={isInvalid}>
<ShadcnSelect.SelectValue placeholder={placeholder} />
</ShadcnSelect.SelectTrigger>
<ShadcnSelect.SelectContent>
<ShadcnSelect.SelectGroup>
<ShadcnSelect.SelectLabel>{label}</ShadcnSelect.SelectLabel>
{values.map((value) => (
<ShadcnSelect.SelectItem key={value.value} value={value.value}>
{value.label}
</ShadcnSelect.SelectItem>
))}
</ShadcnSelect.SelectGroup>
</ShadcnSelect.SelectContent>
</ShadcnSelect.Select>
{isInvalid && <FieldError errors={errors} />}
</Field>
);
}
export function SelectNumber({
label,
values,
placeholder,
}: {
label: string;
values: Array<{ label: string; value: string }>;
placeholder?: string;
}) {
const field = useFieldContext<number>();
const errors = useStore(field.store, (state) => state.meta.errors);
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid} className="col-span-2">
<FieldLabel htmlFor={field.name}>{label}:</FieldLabel>
<ShadcnSelect.Select
name={field.name}
value={field.state.value === 0 ? String('') : String(field.state.value)}
onValueChange={(value) => field.handleChange(Number(value))}
>
<ShadcnSelect.SelectTrigger aria-invalid={isInvalid}>
<ShadcnSelect.SelectValue placeholder={placeholder} />
</ShadcnSelect.SelectTrigger>
<ShadcnSelect.SelectContent>
<ShadcnSelect.SelectGroup>
<ShadcnSelect.SelectLabel>{label}</ShadcnSelect.SelectLabel>
{values.map((value) => (
<ShadcnSelect.SelectItem key={value.value} value={value.value}>
{value.label}
</ShadcnSelect.SelectItem>
))}
</ShadcnSelect.SelectGroup>
</ShadcnSelect.SelectContent>
</ShadcnSelect.Select>
{isInvalid && <FieldError errors={errors} />}
</Field>
);
}

View File

@@ -1,18 +1,17 @@
import { useAppForm } from '@/hooks/use-app-form';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { m } from '@/paraglide/messages'; import { m } from '@/paraglide/messages';
import { uploadProfileImage } from '@/service/profile.api'; import { uploadProfileImage } from '@/service/profile.api';
import { ProfileInput, profileUpdateSchema } from '@/service/profile.schema'; import { ProfileInput, profileUpdateSchema } from '@/service/profile.schema';
import { UserCircleIcon } from '@phosphor-icons/react'; import { UserCircleIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useRef } from 'react'; import { useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAuth } from '../auth/auth-provider'; import { useAuth } from '../auth/auth-provider';
import AvatarUser from '../avatar/avatar-user'; import AvatarUser from '../avatar/avatar-user';
import RoleBadge from '../avatar/role-badge'; import RoleBadge from '../avatar/role-badge';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldGroup, FieldLabel } from '../ui/field';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
const defaultValues: ProfileInput = { const defaultValues: ProfileInput = {
@@ -25,7 +24,7 @@ const ProfileForm = () => {
const { data: session, isPending } = useAuth(); const { data: session, isPending } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const form = useForm({ const form = useAppForm({
defaultValues: { defaultValues: {
...defaultValues, ...defaultValues,
name: session?.user?.name || '', name: session?.user?.name || '',
@@ -102,58 +101,20 @@ const ProfileForm = () => {
<FieldGroup> <FieldGroup>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<AvatarUser className="h-20 w-20" textSize="2xl" /> <AvatarUser className="h-20 w-20" textSize="2xl" />
<form.Field <form.AppField name="image">
name="image" {(field) => (
children={(field) => { <field.FileField
const isInvalid = label="Avatar"
field.state.meta.isTouched && !field.state.meta.isValid; className="col-span-2"
return ( accept=".jpg, .jpeg, .png, .webp"
<Field data-invalid={isInvalid} className="col-span-2"> onChange={(e) => field.handleChange(e.target.files?.[0])}
<FieldLabel htmlFor={field.name}>Avatar</FieldLabel> />
<Input )}
type="file" </form.AppField>
id={field.name}
name={field.name}
accept=".jpg, .jpeg, .png, .webp"
ref={fileInputRef}
onChange={(e) =>
field.handleChange(e.target.files?.[0])
}
aria-invalid={isInvalid}
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
/>
</div> </div>
<form.Field <form.AppField name="name">
name="name" {(field) => <field.TextField label={m.profile_form_name()} />}
children={(field) => { </form.AppField>
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.profile_form_name()}
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
/>
<Field> <Field>
<FieldLabel htmlFor="name">{m.profile_form_email()}</FieldLabel> <FieldLabel htmlFor="name">{m.profile_form_email()}</FieldLabel>
<Input <Input
@@ -171,7 +132,9 @@ const ProfileForm = () => {
</div> </div>
</Field> </Field>
<Field> <Field>
<Button type="submit">{m.ui_update_btn()}</Button> <form.AppForm>
<form.SubscribeButton label={m.ui_update_btn()} />
</form.AppForm>
</Field> </Field>
</FieldGroup> </FieldGroup>
</form> </form>

View File

@@ -1,18 +1,15 @@
import { useAppForm } from '@/hooks/use-app-form';
import { m } from '@/paraglide/messages'; import { m } from '@/paraglide/messages';
import { settingQueries } from '@/service/queries'; import { settingQueries } from '@/service/queries';
import { updateAdminSettings } from '@/service/setting.api'; import { updateAdminSettings } from '@/service/setting.api';
import { settingSchema, SettingsInput } from '@/service/setting.schema'; import { settingSchema, SettingsInput } from '@/service/setting.schema';
import { ReturnError } from '@/types/common'; import { ReturnError } from '@/types/common';
import { GearIcon } from '@phosphor-icons/react'; import { GearIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldGroup } from '../ui/field';
import { Input } from '../ui/input';
import { Skeleton } from '../ui/skeleton'; import { Skeleton } from '../ui/skeleton';
import { Textarea } from '../ui/textarea';
const defaultValues: SettingsInput = { const defaultValues: SettingsInput = {
site_name: '', site_name: '',
@@ -41,7 +38,7 @@ const SettingsForm = () => {
}, },
}); });
const form = useForm({ const form = useAppForm({
defaultValues: { defaultValues: {
...defaultValues, ...defaultValues,
site_name: settings?.site_name?.value || '', site_name: settings?.site_name?.value || '',
@@ -78,85 +75,21 @@ const SettingsForm = () => {
}} }}
> >
<FieldGroup> <FieldGroup>
<form.Field <form.AppField name="site_name">
name="site_name" {(field) => <field.TextField label={m.settings_form_name()} />}
children={(field) => { </form.AppField>
const isInvalid = <form.AppField name="site_description">
field.state.meta.isTouched && !field.state.meta.isValid; {(field) => (
return ( <field.TextArea label={m.settings_form_description()} />
<Field data-invalid={isInvalid} className="col-span-2"> )}
<FieldLabel htmlFor={field.name}> </form.AppField>
{m.settings_form_name()} <form.AppField name="site_keywords">
</FieldLabel> {(field) => <field.TextArea label={m.settings_form_keywords()} />}
<Input </form.AppField>
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
/>
<form.Field
name="site_description"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid} className="col-span-2">
<FieldLabel htmlFor={field.name}>
{m.settings_form_description()}
</FieldLabel>
<Textarea
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
rows={4}
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
/>
<form.Field
name="site_keywords"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid} className="col-span-2">
<FieldLabel htmlFor={field.name}>
{m.settings_form_keywords()}
</FieldLabel>
<Textarea
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
rows={4}
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
/>
<Field> <Field>
<Button type="submit">{m.ui_update_btn()}</Button> <form.AppForm>
<form.SubscribeButton label={m.ui_update_btn()} />
</form.AppForm>
</Field> </Field>
</FieldGroup> </FieldGroup>
</form> </form>

View File

@@ -1,14 +1,13 @@
import { useAppForm } from '@/hooks/use-app-form';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { m } from '@/paraglide/messages'; import { m } from '@/paraglide/messages';
import { useForm } from '@tanstack/react-form';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { createLink, useNavigate } from '@tanstack/react-router'; import { createLink, useNavigate } from '@tanstack/react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import z from 'zod'; import z from 'zod';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldGroup } from '../ui/field';
import { Input } from '../ui/input';
const SignInFormSchema = z.object({ const SignInFormSchema = z.object({
email: z email: z
@@ -27,7 +26,8 @@ const ButtonLink = createLink(Button);
const SignInForm = () => { const SignInForm = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const form = useForm({
const form = useAppForm({
defaultValues: { defaultValues: {
email: '', email: '',
password: '', password: '',
@@ -91,62 +91,26 @@ const SignInForm = () => {
</Button> </Button>
</Field> </Field>
<FieldSeparator>Or continue with</FieldSeparator> */} <FieldSeparator>Or continue with</FieldSeparator> */}
<form.Field <form.AppField name="email">
name="email" {(field) => (
children={(field) => { <field.TextField
const isInvalid = label={m.login_page_form_email()}
field.state.meta.isTouched && !field.state.meta.isValid; type="email"
return ( />
<Field data-invalid={isInvalid}> )}
<FieldLabel htmlFor={field.name}> </form.AppField>
{m.login_page_form_email()} <form.AppField name="password">
</FieldLabel> {(field) => (
<Input <field.TextField
id={field.name} type="password"
name={field.name} label={m.login_page_form_password()}
value={field.state.value} />
onBlur={field.handleBlur} )}
onChange={(e) => field.handleChange(e.target.value)} </form.AppField>
aria-invalid={isInvalid}
type="email"
placeholder="m@example.com"
autoComplete="off"
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
/>
<form.Field
name="password"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.login_page_form_password()}
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="password"
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
/>
<Field> <Field>
<Button type="submit">{m.ui_login_btn()}</Button> <form.AppForm>
<form.SubscribeButton label={m.ui_login_btn()} />
</form.AppForm>
<ButtonLink to="/" variant="outline"> <ButtonLink to="/" variant="outline">
{m.ui_cancel_btn()} {m.ui_cancel_btn()}
</ButtonLink> </ButtonLink>

View File

@@ -1,3 +1,4 @@
import { useAppForm } from '@/hooks/use-app-form';
import { m } from '@/paraglide/messages'; import { m } from '@/paraglide/messages';
import { Locale, setLocale } from '@/paraglide/runtime'; import { Locale, setLocale } from '@/paraglide/runtime';
import { settingQueries } from '@/service/queries'; import { settingQueries } from '@/service/queries';
@@ -5,20 +6,11 @@ import { updateUserSettings } from '@/service/setting.api';
import { UserSettingInput, userSettingSchema } from '@/service/setting.schema'; import { UserSettingInput, userSettingSchema } from '@/service/setting.schema';
import { ReturnError } from '@/types/common'; import { ReturnError } from '@/types/common';
import { GearIcon } from '@phosphor-icons/react'; import { GearIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Field, FieldGroup } from '../ui/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Skeleton } from '../ui/skeleton'; import { Skeleton } from '../ui/skeleton';
const defaultValues: UserSettingInput = { const defaultValues: UserSettingInput = {
@@ -49,7 +41,7 @@ const UserSettingsForm = () => {
}, },
}); });
const form = useForm({ const form = useAppForm({
defaultValues, defaultValues,
validators: { validators: {
onSubmit: userSettingSchema, onSubmit: userSettingSchema,
@@ -68,6 +60,13 @@ const UserSettingsForm = () => {
} }
}, [data, form]); }, [data, form]);
if (isLoading)
return (
<div>
<Skeleton className="h-40 w-full" />
</div>
);
return ( return (
<Card className="@container/card col-span-1 @xl/main:col-span-2"> <Card className="@container/card col-span-1 @xl/main:col-span-2">
<CardHeader> <CardHeader>
@@ -86,47 +85,22 @@ const UserSettingsForm = () => {
}} }}
> >
<FieldGroup> <FieldGroup>
{isLoading ? ( <form.AppField name="language">
<div className="col-span-2 space-y-2"> {(field) => (
<Skeleton className="h-4 w-20" /> <field.Select
<Skeleton className="h-10 w-full" /> label={m.settings_form_language()}
</div> placeholder={m.settings_form_select_language()}
) : ( values={[
<form.Field { value: 'en', label: 'English' },
name="language" { value: 'vi', label: 'Tiếng Việt' },
children={(field) => { ]}
const isInvalid = />
field.state.meta.isTouched && !field.state.meta.isValid; )}
return ( </form.AppField>
<Field data-invalid={isInvalid} className="col-span-2">
<FieldLabel htmlFor={field.name}>
{m.settings_form_language()}
</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={(value) => field.handleChange(value)}
>
<SelectTrigger aria-invalid={isInvalid}>
<SelectValue placeholder="Select Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="vi">Vietnamese</SelectItem>
</SelectContent>
</Select>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
/>
)}
<Field> <Field>
<Button type="submit" disabled={isLoading}> <form.AppForm>
{m.ui_update_btn()} <form.SubscribeButton label={m.ui_update_btn()} />
</Button> </form.AppForm>
</Field> </Field>
</FieldGroup> </FieldGroup>
</form> </form>

View File

@@ -18,8 +18,8 @@ const AddNewUserButton = () => {
const prevent = usePreventAutoFocus(); const prevent = usePreventAutoFocus();
return ( return (
<Dialog> <Dialog open={_open} onOpenChange={_setOpen}>
<DialogTrigger> <DialogTrigger asChild>
<Button type="button" variant="default"> <Button type="button" variant="default">
<PlusIcon /> <PlusIcon />
{m.nav_add_new()} {m.nav_add_new()}
@@ -39,7 +39,7 @@ const AddNewUserButton = () => {
{m.nav_add_new()} {m.nav_add_new()}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<AdminCreateUserForm /> <AdminCreateUserForm onSubmit={_setOpen} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

29
src/hooks/use-app-form.ts Normal file
View File

@@ -0,0 +1,29 @@
import {
FileField,
HiddenField,
Select,
SelectNumber,
SubscribeButton,
TextArea,
TextField,
} from '@/components/form/form-components';
import { createFormHook, createFormHookContexts } from '@tanstack/react-form';
export const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts();
export const { useAppForm } = createFormHook({
fieldComponents: {
HiddenField,
TextField,
TextArea,
Select,
SelectNumber,
FileField,
},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
});

View File

@@ -11,7 +11,6 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as appRouteRouteImport } from './routes/(app)/route' import { Route as appRouteRouteImport } from './routes/(app)/route'
import { Route as appIndexRouteImport } from './routes/(app)/index' import { Route as appIndexRouteImport } from './routes/(app)/index'
import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in' import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route' import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$' import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$'
@@ -36,11 +35,6 @@ const appIndexRoute = appIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => appRouteRoute, getParentRoute: () => appRouteRoute,
} as any) } as any)
const authSignUpRoute = authSignUpRouteImport.update({
id: '/(auth)/sign-up',
path: '/sign-up',
getParentRoute: () => rootRouteImport,
} as any)
const authSignInRoute = authSignInRouteImport.update({ const authSignInRoute = authSignInRouteImport.update({
id: '/(auth)/sign-in', id: '/(auth)/sign-in',
path: '/sign-in', path: '/sign-in',
@@ -114,7 +108,6 @@ const appauthAccountChangePasswordRoute =
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/sign-in': typeof authSignInRoute '/sign-in': typeof authSignInRoute
'/sign-up': typeof authSignUpRoute
'/': typeof appIndexRoute '/': typeof appIndexRoute
'/account': typeof appauthAccountRouteRouteWithChildren '/account': typeof appauthAccountRouteRouteWithChildren
'/kanri': typeof appauthKanriRouteRouteWithChildren '/kanri': typeof appauthKanriRouteRouteWithChildren
@@ -131,7 +124,6 @@ export interface FileRoutesByFullPath {
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/sign-in': typeof authSignInRoute '/sign-in': typeof authSignInRoute
'/sign-up': typeof authSignUpRoute
'/': typeof appIndexRoute '/': typeof appIndexRoute
'/dashboard': typeof appauthDashboardRoute '/dashboard': typeof appauthDashboardRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
@@ -149,7 +141,6 @@ export interface FileRoutesById {
'/(app)': typeof appRouteRouteWithChildren '/(app)': typeof appRouteRouteWithChildren
'/(app)/(auth)': typeof appauthRouteRouteWithChildren '/(app)/(auth)': typeof appauthRouteRouteWithChildren
'/(auth)/sign-in': typeof authSignInRoute '/(auth)/sign-in': typeof authSignInRoute
'/(auth)/sign-up': typeof authSignUpRoute
'/(app)/': typeof appIndexRoute '/(app)/': typeof appIndexRoute
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren '/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
'/(app)/(auth)/kanri': typeof appauthKanriRouteRouteWithChildren '/(app)/(auth)/kanri': typeof appauthKanriRouteRouteWithChildren
@@ -168,7 +159,6 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/sign-in' | '/sign-in'
| '/sign-up'
| '/' | '/'
| '/account' | '/account'
| '/kanri' | '/kanri'
@@ -185,7 +175,6 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/sign-in' | '/sign-in'
| '/sign-up'
| '/' | '/'
| '/dashboard' | '/dashboard'
| '/api/auth/$' | '/api/auth/$'
@@ -202,7 +191,6 @@ export interface FileRouteTypes {
| '/(app)' | '/(app)'
| '/(app)/(auth)' | '/(app)/(auth)'
| '/(auth)/sign-in' | '/(auth)/sign-in'
| '/(auth)/sign-up'
| '/(app)/' | '/(app)/'
| '/(app)/(auth)/account' | '/(app)/(auth)/account'
| '/(app)/(auth)/kanri' | '/(app)/(auth)/kanri'
@@ -221,7 +209,6 @@ export interface FileRouteTypes {
export interface RootRouteChildren { export interface RootRouteChildren {
appRouteRoute: typeof appRouteRouteWithChildren appRouteRoute: typeof appRouteRouteWithChildren
authSignInRoute: typeof authSignInRoute authSignInRoute: typeof authSignInRoute
authSignUpRoute: typeof authSignUpRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
} }
@@ -241,13 +228,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof appIndexRouteImport preLoaderRoute: typeof appIndexRouteImport
parentRoute: typeof appRouteRoute parentRoute: typeof appRouteRoute
} }
'/(auth)/sign-up': {
id: '/(auth)/sign-up'
path: '/sign-up'
fullPath: '/sign-up'
preLoaderRoute: typeof authSignUpRouteImport
parentRoute: typeof rootRouteImport
}
'/(auth)/sign-in': { '/(auth)/sign-in': {
id: '/(auth)/sign-in' id: '/(auth)/sign-in'
path: '/sign-in' path: '/sign-in'
@@ -416,7 +396,6 @@ const appRouteRouteWithChildren = appRouteRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
appRouteRoute: appRouteRouteWithChildren, appRouteRoute: appRouteRouteWithChildren,
authSignInRoute: authSignInRoute, authSignInRoute: authSignInRoute,
authSignUpRoute: authSignUpRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@@ -2,13 +2,25 @@ import { AuthProvider } from '@/components/auth/auth-provider';
import Header from '@/components/Header'; import Header from '@/components/Header';
import AppSidebar from '@/components/sidebar/app-sidebar'; import AppSidebar from '@/components/sidebar/app-sidebar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Locale, setLocale } from '@/paraglide/runtime';
import { settingQueries } from '@/service/queries';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, Outlet } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router';
import { useEffect } from 'react';
export const Route = createFileRoute('/(app)')({ export const Route = createFileRoute('/(app)')({
component: RouteComponent, component: RouteComponent,
}); });
function RouteComponent() { function RouteComponent() {
const { data: language } = useQuery(
settingQueries.getCurrentUserLanguageSetting(),
);
useEffect(() => {
if (language) {
setLocale(language as Locale);
}
}, [language]);
return ( return (
<AuthProvider> <AuthProvider>
<SidebarProvider defaultOpen={false}> <SidebarProvider defaultOpen={false}>

View File

@@ -1,20 +0,0 @@
import SignupForm from '@/components/form/signup-form'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/(auth)/sign-up')({
component: RouteComponent,
})
function RouteComponent() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<h1 className="text-xl font-semibold flex items-center gap-2 self-center">
<img src="/logo.svg" alt="Fuware Logo" className="h-8" />
Fuware
</h1>
<SignupForm />
</div>
</div>
)
}

View File

@@ -1,7 +1,11 @@
import { getSession } from '@/lib/auth/session'; import { getSession } from '@/lib/auth/session';
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { getAllAudit } from './audit.api'; import { getAllAudit } from './audit.api';
import { getAdminSettings, getUserSettings } from './setting.api'; import {
getAdminSettings,
getCurrentUserLanguage,
getUserSettings,
} from './setting.api';
import { getAllUser } from './user.api'; import { getAllUser } from './user.api';
export const sessionQueries = { export const sessionQueries = {
@@ -27,6 +31,11 @@ export const settingQueries = {
queryKey: [...settingQueries.all, 'listUser'], queryKey: [...settingQueries.all, 'listUser'],
queryFn: () => getUserSettings(), queryFn: () => getUserSettings(),
}), }),
getCurrentUserLanguageSetting: () =>
queryOptions({
queryKey: [...settingQueries.all, 'language'],
queryFn: () => getCurrentUserLanguage(),
}),
}; };
export const auditQueries = { export const auditQueries = {

View File

@@ -12,6 +12,24 @@ export const getAdminSettings = createServerFn({ method: 'GET' })
return await getAllAdminSettings(); return await getAllAdminSettings();
}); });
export const getCurrentUserLanguage = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
try {
const setting = await prisma.setting.findUniqueOrThrow({
where: { key: context.user.id, relation: 'user' },
select: { value: true },
});
const value = JSON.parse(setting.value) as UserSetting;
return value.language;
} catch (error) {
console.log(error);
throw error;
}
});
export const updateAdminSettings = createServerFn({ method: 'POST' }) export const updateAdminSettings = createServerFn({ method: 'POST' })
.inputValidator(settingSchema) .inputValidator(settingSchema)
.middleware([authMiddleware]) .middleware([authMiddleware])
@@ -84,6 +102,7 @@ export const getUserSettings = createServerFn({ method: 'GET' })
value: JSON.parse(settings.value) as UserSetting, value: JSON.parse(settings.value) as UserSetting,
}; };
} catch (error) { } catch (error) {
console.log(error);
throw error; throw error;
} }
}); });
@@ -128,6 +147,7 @@ export const updateUserSettings = createServerFn({ method: 'POST' })
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.log(error);
throw error; throw error;
} }
}); });

View File

@@ -5,6 +5,36 @@ export const baseUser = z.object({
id: z.string().nonempty(m.users_page_message_user_not_found()), id: z.string().nonempty(m.users_page_message_user_not_found()),
}); });
export const ChangePasswordFormSchema = z
.object({
currentPassword: z.string().nonempty(
m.common_is_required({
field: m.change_password_form_current_password(),
}),
),
newPassword: z.string().nonempty(
m.common_is_required({
field: m.change_password_form_new_password(),
}),
),
confirmPassword: z.string().nonempty(
m.common_is_required({
field: m.change_password_form_confirm_password(),
}),
),
})
.superRefine((data, ctx) => {
if (data.newPassword !== data.confirmPassword) {
ctx.addIssue({
path: ['confirmPassword'],
code: z.ZodIssueCode.custom,
message: m.change_password_messages_password_not_match(),
});
}
});
export type ChangePassword = z.infer<typeof ChangePasswordFormSchema>;
export const userListSchema = z.object({ export const userListSchema = z.object({
page: z.coerce.number().min(1).default(1), page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(10).max(100).default(10), limit: z.coerce.number().min(10).max(100).default(10),