B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,311 @@
'use client';
import { useMemo, useState } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { Ellipsis } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Database } from '@kit/supabase/database';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { DataTable } from '@kit/ui/data-table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { Trans } from '@kit/ui/trans';
import { RemoveMemberDialog } from './remove-member-dialog';
import { RoleBadge } from './role-badge';
import { TransferOwnershipDialog } from './transfer-ownership-dialog';
import { UpdateMemberRoleDialog } from './update-member-role-dialog';
type Members =
Database['public']['Functions']['get_account_members']['Returns'];
interface Permissions {
canUpdateRole: (roleHierarchy: number) => boolean;
canRemoveFromAccount: (roleHierarchy: number) => boolean;
canTransferOwnership: boolean;
}
type AccountMembersTableProps = {
members: Members;
currentUserId: string;
currentAccountId: string;
userRoleHierarchy: number;
isPrimaryOwner: boolean;
canManageRoles: boolean;
};
export function AccountMembersTable({
members,
currentUserId,
currentAccountId,
isPrimaryOwner,
userRoleHierarchy,
canManageRoles,
}: AccountMembersTableProps) {
const [search, setSearch] = useState('');
const { t } = useTranslation('teams');
const permissions = {
canUpdateRole: (targetRole: number) => {
return (
isPrimaryOwner || (canManageRoles && userRoleHierarchy < targetRole)
);
},
canRemoveFromAccount: (targetRole: number) => {
return (
isPrimaryOwner || (canManageRoles && userRoleHierarchy < targetRole)
);
},
canTransferOwnership: isPrimaryOwner,
};
const columns = useGetColumns(permissions, {
currentUserId,
currentAccountId,
currentRoleHierarchy: userRoleHierarchy,
});
const filteredMembers = members
.filter((member) => {
const searchString = search.toLowerCase();
const displayName = (
member.name ??
member.email.split('@')[0] ??
''
).toLowerCase();
return (
displayName.includes(searchString) ||
member.role.toLowerCase().includes(searchString)
);
})
.sort((prev, next) => {
if (prev.primary_owner_user_id === prev.user_id) {
return -1;
}
if (prev.role_hierarchy_level < next.role_hierarchy_level) {
return -1;
}
return 1;
});
return (
<div className={'flex flex-col space-y-2'}>
<Input
value={search}
onInput={(e) => setSearch((e.target as HTMLInputElement).value)}
placeholder={t(`searchMembersPlaceholder`)}
/>
<DataTable columns={columns} data={filteredMembers} />
</div>
);
}
function useGetColumns(
permissions: Permissions,
params: {
currentUserId: string;
currentAccountId: string;
currentRoleHierarchy: number;
},
): ColumnDef<Members[0]>[] {
const { t } = useTranslation('teams');
return useMemo(
() => [
{
header: t('memberName'),
size: 200,
cell: ({ row }) => {
const member = row.original;
const displayName = member.name ?? member.email.split('@')[0];
const isSelf = member.user_id === params.currentUserId;
return (
<span className={'flex items-center space-x-4 text-left'}>
<span>
<ProfileAvatar
displayName={displayName}
pictureUrl={member.picture_url}
/>
</span>
<span>{displayName}</span>
<If condition={isSelf}>
<Badge variant={'outline'}>{t('youLabel')}</Badge>
</If>
</span>
);
},
},
{
header: t('emailLabel'),
accessorKey: 'email',
cell: ({ row }) => {
return row.original.email ?? '-';
},
},
{
header: t('roleLabel'),
cell: ({ row }) => {
const { role, primary_owner_user_id, user_id } = row.original;
const isPrimaryOwner = primary_owner_user_id === user_id;
return (
<span className={'flex items-center space-x-1'}>
<RoleBadge role={role} />
<If condition={isPrimaryOwner}>
<span
className={
'rounded-md bg-yellow-400 px-2.5 py-1 text-xs font-medium dark:text-black'
}
>
{t('primaryOwnerLabel')}
</span>
</If>
</span>
);
},
},
{
header: t('joinedAtLabel'),
cell: ({ row }) => {
return new Date(row.original.created_at).toLocaleDateString();
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => (
<ActionsDropdown
permissions={permissions}
member={row.original}
currentUserId={params.currentUserId}
currentTeamAccountId={params.currentAccountId}
currentRoleHierarchy={params.currentRoleHierarchy}
/>
),
},
],
[t, params, permissions],
);
}
function ActionsDropdown({
permissions,
member,
currentUserId,
currentTeamAccountId,
currentRoleHierarchy,
}: {
permissions: Permissions;
member: Members[0];
currentUserId: string;
currentTeamAccountId: string;
currentRoleHierarchy: number;
}) {
const [isRemoving, setIsRemoving] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
const isCurrentUser = member.user_id === currentUserId;
const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
if (isCurrentUser || isPrimaryOwner) {
return null;
}
const memberRoleHierarchy = member.role_hierarchy_level;
const canUpdateRole = permissions.canUpdateRole(memberRoleHierarchy);
const canRemoveFromAccount =
permissions.canRemoveFromAccount(memberRoleHierarchy);
// if has no permission to update role, transfer ownership or remove from account
// do not render the dropdown menu
if (
!canUpdateRole &&
!permissions.canTransferOwnership &&
!canRemoveFromAccount
) {
return null;
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<If condition={canUpdateRole}>
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
<Trans i18nKey={'teams:updateRole'} />
</DropdownMenuItem>
</If>
<If condition={permissions.canTransferOwnership}>
<DropdownMenuItem onClick={() => setIsTransferring(true)}>
<Trans i18nKey={'teams:transferOwnership'} />
</DropdownMenuItem>
</If>
<If condition={canRemoveFromAccount}>
<DropdownMenuItem onClick={() => setIsRemoving(true)}>
<Trans i18nKey={'teams:removeMember'} />
</DropdownMenuItem>
</If>
</DropdownMenuContent>
</DropdownMenu>
<If condition={isRemoving}>
<RemoveMemberDialog
isOpen
setIsOpen={setIsRemoving}
teamAccountId={currentTeamAccountId}
userId={member.user_id}
/>
</If>
<If condition={isUpdatingRole}>
<UpdateMemberRoleDialog
isOpen
setIsOpen={setIsUpdatingRole}
userId={member.user_id}
userRole={member.role}
teamAccountId={currentTeamAccountId}
userRoleHierarchy={currentRoleHierarchy}
/>
</If>
<If condition={isTransferring}>
<TransferOwnershipDialog
isOpen
setIsOpen={setIsTransferring}
targetDisplayName={member.name ?? member.email}
accountId={member.account_id}
userId={member.user_id}
/>
</If>
</>
);
}

View File

@@ -0,0 +1,277 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans';
import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { createInvitationsAction } from '../../server/actions/team-invitations-server-actions';
import { MembershipRoleSelector } from './membership-role-selector';
import { RolesDataProvider } from './roles-data-provider';
type InviteModel = ReturnType<typeof createEmptyInviteModel>;
type Role = string;
/**
* The maximum number of invites that can be sent at once.
* Useful to avoid spamming the server with too large payloads
*/
const MAX_INVITES = 5;
export function InviteMembersDialogContainer({
accountSlug,
userRoleHierarchy,
children,
}: React.PropsWithChildren<{
accountSlug: string;
userRoleHierarchy: number;
}>) {
const [pending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation('teams');
return (
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:inviteMembersHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams:inviteMembersDescription'} />
</DialogDescription>
</DialogHeader>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(roles) => (
<InviteMembersForm
pending={pending}
roles={roles}
onSubmit={(data) => {
startTransition(() => {
const promise = createInvitationsAction({
accountSlug,
invitations: data.invitations,
});
toast.promise(() => promise, {
loading: t('invitingMembers'),
success: t('inviteMembersSuccessMessage'),
error: t('inviteMembersErrorMessage'),
});
setIsOpen(false);
});
}}
/>
)}
</RolesDataProvider>
</DialogContent>
</Dialog>
);
}
function InviteMembersForm({
onSubmit,
roles,
pending,
}: {
onSubmit: (data: { invitations: InviteModel[] }) => void;
pending: boolean;
roles: string[];
}) {
const { t } = useTranslation('teams');
const form = useForm({
resolver: zodResolver(InviteMembersSchema),
shouldUseNativeValidation: true,
reValidateMode: 'onSubmit',
defaultValues: {
invitations: [createEmptyInviteModel()],
},
});
const fieldArray = useFieldArray({
control: form.control,
name: 'invitations',
});
return (
<Form {...form}>
<form
className={'flex flex-col space-y-8'}
data-test={'invite-members-form'}
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="flex flex-col gap-y-4">
{fieldArray.fields.map((field, index) => {
const isFirst = index === 0;
const emailInputName = `invitations.${index}.email` as const;
const roleInputName = `invitations.${index}.role` as const;
return (
<div data-test={'invite-member-form-item'} key={field.id}>
<div className={'flex items-end gap-x-1 md:space-x-2'}>
<div className={'w-7/12'}>
<FormField
name={emailInputName}
render={({ field }) => {
return (
<FormItem>
<If condition={isFirst}>
<FormLabel>{t('emailLabel')}</FormLabel>
</If>
<FormControl>
<Input
data-test={'invite-email-input'}
placeholder={t('emailPlaceholder')}
type="email"
required
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className={'w-4/12'}>
<FormField
name={roleInputName}
render={({ field }) => {
return (
<FormItem>
<If condition={isFirst}>
<FormLabel>
<Trans i18nKey={'teams:roleLabel'} />
</FormLabel>
</If>
<FormControl>
<MembershipRoleSelector
triggerClassName={'m-0'}
roles={roles}
value={field.value}
onChange={(role) => {
form.setValue(field.name, role);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className={'flex w-[40px] items-end justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={'ghost'}
size={'icon'}
type={'button'}
disabled={fieldArray.fields.length <= 1}
data-test={'remove-invite-button'}
aria-label={t('removeInviteButtonLabel')}
onClick={() => {
fieldArray.remove(index);
form.clearErrors(emailInputName);
}}
>
<X className={'h-4 lg:h-5'} />
</Button>
</TooltipTrigger>
<TooltipContent>
{t('removeInviteButtonLabel')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
);
})}
<If condition={fieldArray.fields.length < MAX_INVITES}>
<div>
<Button
data-test={'add-new-invite-button'}
type={'button'}
variant={'link'}
size={'sm'}
disabled={pending}
onClick={() => {
fieldArray.append(createEmptyInviteModel());
}}
>
<Plus className={'mr-1 h-3'} />
<span>
<Trans i18nKey={'teams:addAnotherMemberButtonLabel'} />
</span>
</Button>
</div>
</If>
</div>
<Button type={'submit'} disabled={pending}>
<Trans
i18nKey={
pending
? 'teams:invitingMembers'
: 'teams:inviteMembersButtonLabel'
}
/>
</Button>
</form>
</Form>
);
}
function createEmptyInviteModel() {
return { email: '', role: 'member' as Role };
}

View File

@@ -0,0 +1,52 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { Trans } from '@kit/ui/trans';
type Role = string;
export function MembershipRoleSelector({
roles,
value,
currentUserRole,
onChange,
triggerClassName,
}: {
roles: Role[];
value: Role;
currentUserRole?: Role;
onChange: (role: Role) => unknown;
triggerClassName?: string;
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
className={triggerClassName}
data-test={'role-selector-trigger'}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => {
return (
<SelectItem
key={role}
data-test={`role-option-${role}`}
disabled={currentUserRole === role}
value={role}
>
<span className={'text-sm capitalize'}>
<Trans i18nKey={`common:roles.${role}.label`} defaults={role} />
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,118 @@
import { useState, useTransition } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions';
export function RemoveMemberDialog({
isOpen,
setIsOpen,
teamAccountId,
userId,
}: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
teamAccountId: string;
userId: string;
}) {
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="teamS:removeMemberModalHeading" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey={'teams:removeMemberModalDescription'} />
</AlertDialogDescription>
</AlertDialogHeader>
<RemoveMemberForm
setIsOpen={setIsOpen}
accountId={teamAccountId}
userId={userId}
/>
</AlertDialogContent>
</AlertDialog>
);
}
function RemoveMemberForm({
accountId,
userId,
setIsOpen,
}: {
accountId: string;
userId: string;
setIsOpen: (isOpen: boolean) => void;
}) {
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const onMemberRemoved = () => {
startTransition(async () => {
try {
await removeMemberFromAccountAction({ accountId, userId });
setIsOpen(false);
} catch {
setError(true);
}
});
};
return (
<form action={onMemberRemoved}>
<div className={'flex flex-col space-y-6'}>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</p>
<If condition={error}>
<RemoveMemberErrorAlert />
</If>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<Button
data-test={'confirm-remove-member'}
variant={'destructive'}
disabled={isSubmitting}
>
<Trans i18nKey={'teams:removeMemberSubmitLabel'} />
</Button>
</AlertDialogFooter>
</div>
</form>
);
}
function RemoveMemberErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:removeMemberErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams:removeMemberErrorMessage'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,32 @@
import { cva } from 'class-variance-authority';
import { Badge } from '@kit/ui/badge';
import { Trans } from '@kit/ui/trans';
type Role = string;
const roles = {
owner: '',
member:
'bg-blue-50 hover:bg-blue-50 text-blue-500 dark:bg-blue-500/10 dark:hover:bg-blue-500/10',
};
const roleClassNameBuilder = cva('font-medium capitalize shadow-none', {
variants: {
role: roles,
},
});
export function RoleBadge({ role }: { role: Role }) {
// @ts-expect-error: hard to type this since users can add custom roles
const className = roleClassNameBuilder({ role });
const isCustom = !(role in roles);
return (
<Badge className={className} variant={isCustom ? 'outline' : 'default'}>
<span data-test={'member-role-badge'}>
<Trans i18nKey={`common:roles.${role}.label`} defaults={role} />
</span>
</Badge>
);
}

View File

@@ -0,0 +1,42 @@
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
export function RolesDataProvider(props: {
maxRoleHierarchy: number;
children: (roles: string[]) => React.ReactNode;
}) {
const rolesQuery = useFetchRoles(props);
if (rolesQuery.isLoading) {
return <LoadingOverlay fullPage={false} />;
}
if (rolesQuery.isError) {
return null;
}
return <>{props.children(rolesQuery.data ?? [])}</>;
}
function useFetchRoles(props: { maxRoleHierarchy: number }) {
const supabase = useSupabase();
return useQuery({
queryKey: ['roles', props.maxRoleHierarchy],
queryFn: async () => {
const { error, data } = await supabase
.from('roles')
.select('name')
.gte('hierarchy_level', props.maxRoleHierarchy)
.order('hierarchy_level', { ascending: true });
if (error) {
throw error;
}
return data.map((item) => item.name);
},
});
}

View File

@@ -0,0 +1,192 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import { VerifyOtpForm } from '@kit/otp/components';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { Form } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
import { transferOwnershipAction } from '../../server/actions/team-members-server-actions';
export function TransferOwnershipDialog({
isOpen,
setIsOpen,
targetDisplayName,
accountId,
userId,
}: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
accountId: string;
userId: string;
targetDisplayName: string;
}) {
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="team:transferOwnership" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey="team:transferOwnershipDescription" />
</AlertDialogDescription>
</AlertDialogHeader>
<TransferOrganizationOwnershipForm
accountId={accountId}
userId={userId}
targetDisplayName={targetDisplayName}
setIsOpen={setIsOpen}
/>
</AlertDialogContent>
</AlertDialog>
);
}
function TransferOrganizationOwnershipForm({
accountId,
userId,
targetDisplayName,
setIsOpen,
}: {
userId: string;
accountId: string;
targetDisplayName: string;
setIsOpen: (isOpen: boolean) => void;
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const { data: user } = useUser();
const form = useForm<{
accountId: string;
userId: string;
otp: string;
}>({
resolver: zodResolver(TransferOwnershipConfirmationSchema),
defaultValues: {
accountId,
userId,
otp: '',
},
});
const { otp } = useWatch({ control: form.control });
// If no OTP has been entered yet, show the OTP verification form
if (!otp) {
return (
<div className="flex flex-col space-y-6">
<VerifyOtpForm
purpose={`transfer-team-ownership-${accountId}`}
email={user?.email || ''}
onSuccess={(otpValue) => {
form.setValue('otp', otpValue, { shouldValidate: true });
}}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
data-test="verify-otp-form"
/>
</div>
);
}
return (
<Form {...form}>
<form
className={'flex flex-col space-y-4 text-sm'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await transferOwnershipAction(data);
setIsOpen(false);
} catch {
setError(true);
}
});
})}
>
<If condition={error}>
<TransferOwnershipErrorAlert />
</If>
<div className="border-destructive rounded-md border p-4">
<p className="text-destructive text-sm">
<Trans
i18nKey={'teams:transferOwnershipDisclaimer'}
values={{
member: targetDisplayName,
}}
components={{ b: <b /> }}
/>
</p>
</div>
<input type="hidden" name="otp" value={otp} />
<div>
<p className={'text-muted-foreground'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<Button
type={'submit'}
data-test={'confirm-transfer-ownership-button'}
variant={'destructive'}
disabled={pending}
>
<If
condition={pending}
fallback={<Trans i18nKey={'teams:transferOwnership'} />}
>
<Trans i18nKey={'teams:transferringOwnership'} />
</If>
</Button>
</AlertDialogFooter>
</form>
</Form>
);
}
function TransferOwnershipErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:transferTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams:transferTeamErrorMessage'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,186 @@
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { RoleSchema } from '../../schema/update-member-role.schema';
import { updateMemberRoleAction } from '../../server/actions/team-members-server-actions';
import { MembershipRoleSelector } from './membership-role-selector';
import { RolesDataProvider } from './roles-data-provider';
type Role = string;
export function UpdateMemberRoleDialog({
isOpen,
setIsOpen,
userId,
teamAccountId,
userRole,
userRoleHierarchy,
}: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
userId: string;
teamAccountId: string;
userRole: Role;
userRoleHierarchy: number;
}) {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:updateMemberRoleModalHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams:updateMemberRoleModalDescription'} />
</DialogDescription>
</DialogHeader>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(data) => (
<UpdateMemberForm
setIsOpen={setIsOpen}
userId={userId}
teamAccountId={teamAccountId}
userRole={userRole}
roles={data}
/>
)}
</RolesDataProvider>
</DialogContent>
</Dialog>
);
}
function UpdateMemberForm({
userId,
userRole,
teamAccountId,
setIsOpen,
roles,
}: React.PropsWithChildren<{
userId: string;
userRole: Role;
teamAccountId: string;
setIsOpen: (isOpen: boolean) => void;
roles: Role[];
}>) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const { t } = useTranslation('teams');
const onSubmit = ({ role }: { role: Role }) => {
startTransition(async () => {
try {
await updateMemberRoleAction({
accountId: teamAccountId,
userId,
role,
});
setIsOpen(false);
} catch {
setError(true);
}
});
};
const form = useForm({
resolver: zodResolver(
RoleSchema.refine(
(data) => {
return data.role !== userRole;
},
{
message: t(`roleMustBeDifferent`),
path: ['role'],
},
),
),
reValidateMode: 'onChange',
mode: 'onChange',
defaultValues: {
role: userRole,
},
});
return (
<Form {...form}>
<form
data-test={'update-member-role-form'}
onSubmit={form.handleSubmit(onSubmit)}
className={'flex flex-col space-y-6'}
>
<If condition={error}>
<UpdateRoleErrorAlert />
</If>
<FormField
name={'role'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>{t('roleLabel')}</FormLabel>
<FormControl>
<MembershipRoleSelector
roles={roles}
currentUserRole={userRole}
value={field.value}
onChange={(newRole) => form.setValue('role', newRole)}
/>
</FormControl>
<FormDescription>{t('updateRoleDescription')}</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<Button data-test={'confirm-update-member-role'} disabled={pending}>
<Trans i18nKey={'teams:updateRoleSubmitLabel'} />
</Button>
</form>
</Form>
);
}
function UpdateRoleErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:updateRoleErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams:updateRoleErrorMessage'} />
</AlertDescription>
</Alert>
);
}