improvement: enhance workspace invitation modularity (#6594)
This commit is contained in:
parent
e071bf4861
commit
cc9b448a9b
13 changed files with 390 additions and 249 deletions
|
|
@ -5,4 +5,4 @@ export * from "./confirm-workspace-member-remove";
|
|||
export * from "./create-workspace-form";
|
||||
export * from "./delete-workspace-modal";
|
||||
export * from "./logo";
|
||||
export * from "./send-workspace-invitation-modal";
|
||||
export * from "./invite-modal";
|
||||
|
|
|
|||
64
web/core/components/workspace/invite-modal/actions.tsx
Normal file
64
web/core/components/workspace/invite-modal/actions.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type TInvitationModalActionsProps = {
|
||||
isInviteDisabled?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
handleClose: () => void;
|
||||
appendField: () => void;
|
||||
addMoreButtonText?: string;
|
||||
submitButtonText?: {
|
||||
default: string;
|
||||
loading: string;
|
||||
};
|
||||
cancelButtonText?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const InvitationModalActions: React.FC<TInvitationModalActionsProps> = observer((props) => {
|
||||
const {
|
||||
isInviteDisabled = false,
|
||||
isSubmitting = false,
|
||||
handleClose,
|
||||
appendField,
|
||||
addMoreButtonText,
|
||||
submitButtonText,
|
||||
cancelButtonText,
|
||||
className,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={cn("mt-5 flex items-center justify-between gap-2", className)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1 bg-transparent py-2 pr-3 text-xs font-medium text-custom-primary outline-custom-primary",
|
||||
{
|
||||
"cursor-not-allowed opacity-60": isInviteDisabled,
|
||||
}
|
||||
)}
|
||||
onClick={appendField}
|
||||
disabled={isInviteDisabled}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{addMoreButtonText || t("common.add_more")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{cancelButtonText || t("cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} disabled={isInviteDisabled}>
|
||||
{isSubmitting
|
||||
? submitButtonText?.loading || t("workspace_settings.settings.members.modal.button_loading")
|
||||
: submitButtonText?.default || t("workspace_settings.settings.members.modal.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
114
web/core/components/workspace/invite-modal/fields.tsx
Normal file
114
web/core/components/workspace/invite-modal/fields.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Control, Controller, FieldArrayWithId, FormState } from "react-hook-form";
|
||||
import { X } from "lucide-react";
|
||||
// plane imports
|
||||
import { ROLE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CustomSelect, Input } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
import { InvitationFormValues } from "@/hooks/use-workspace-invitation";
|
||||
|
||||
type TInvitationFieldsProps = {
|
||||
workspaceSlug: string;
|
||||
fields: FieldArrayWithId<InvitationFormValues, "emails", "id">[];
|
||||
control: Control<InvitationFormValues>;
|
||||
formState: FormState<InvitationFormValues>;
|
||||
remove: (index: number) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const InvitationFields = observer((props: TInvitationFieldsProps) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
fields,
|
||||
control,
|
||||
formState: { errors },
|
||||
remove,
|
||||
className,
|
||||
} = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { workspaceInfoBySlug } = useUserPermissions();
|
||||
// derived values
|
||||
const currentWorkspaceRole = workspaceInfoBySlug(workspaceSlug.toString())?.role;
|
||||
|
||||
return (
|
||||
<div className={cn("mb-3 space-y-4", className)}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="relative group mb-1 flex items-start justify-between gap-x-4 text-sm w-full">
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`emails.${index}.email`}
|
||||
rules={{
|
||||
required: t("workspace_settings.settings.members.modal.errors.required"),
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: t("workspace_settings.settings.members.modal.errors.invalid"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<>
|
||||
<Input
|
||||
id={`emails.${index}.email`}
|
||||
name={`emails.${index}.email`}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.emails?.[index]?.email)}
|
||||
placeholder={t("workspace_settings.settings.members.modal.placeholder")}
|
||||
className="w-full text-xs sm:text-sm"
|
||||
/>
|
||||
{errors.emails?.[index]?.email && (
|
||||
<span className="ml-1 text-xs text-red-500">{errors.emails?.[index]?.email?.message}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 flex-shrink-0 ">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`emails.${index}.role`}
|
||||
rules={{ required: true }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span className="text-xs sm:text-sm">{ROLE[value]}</span>}
|
||||
onChange={onChange}
|
||||
optionsClassName="w-full"
|
||||
className="flex-grow w-24"
|
||||
input
|
||||
>
|
||||
{Object.entries(ROLE).map(([key, value]) => {
|
||||
if (currentWorkspaceRole && currentWorkspaceRole >= parseInt(key))
|
||||
return (
|
||||
<CustomSelect.Option key={key} value={parseInt(key)}>
|
||||
{value}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{fields.length > 1 && (
|
||||
<div className="flex-item flex w-6">
|
||||
<button type="button" className="place-items-center self-center rounded" onClick={() => remove(index)}>
|
||||
<X className="h-4 w-4 text-custom-text-200" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
36
web/core/components/workspace/invite-modal/form.tsx
Normal file
36
web/core/components/workspace/invite-modal/form.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Dialog } from "@headlessui/react";
|
||||
|
||||
type TInvitationFormProps = {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onSubmit: () => void;
|
||||
actions: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const InvitationForm = observer((props: TInvitationFormProps) => {
|
||||
const { title, description, children, actions, onSubmit, className } = props;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === "Enter") e.preventDefault();
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<div className="text-sm text-custom-text-200">{description}</div>
|
||||
{children}
|
||||
</div>
|
||||
{actions}
|
||||
</form>
|
||||
);
|
||||
});
|
||||
3
web/core/components/workspace/invite-modal/index.ts
Normal file
3
web/core/components/workspace/invite-modal/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./actions";
|
||||
export * from "./fields";
|
||||
export * from "./form";
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { ROLE, EUserPermissions } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IWorkspaceBulkInviteFormData } from "@plane/types";
|
||||
// ui
|
||||
import { Button, CustomSelect, Input } from "@plane/ui";
|
||||
// constants
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: IWorkspaceBulkInviteFormData) => Promise<void> | undefined;
|
||||
};
|
||||
|
||||
type EmailRole = {
|
||||
email: string;
|
||||
role: EUserPermissions;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
emails: EmailRole[];
|
||||
};
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
emails: [
|
||||
{
|
||||
email: "",
|
||||
role: 15,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const SendWorkspaceInvitationModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose, onSubmit } = props;
|
||||
// store hooks
|
||||
const { workspaceInfoBySlug } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<FormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "emails",
|
||||
});
|
||||
|
||||
const currentWorkspaceRole = workspaceInfoBySlug(workspaceSlug.toString())?.role;
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
const appendField = () => {
|
||||
append({ email: "", role: 15 });
|
||||
};
|
||||
|
||||
const onSubmitForm = async (data: FormValues) => {
|
||||
await onSubmit(data)?.then(() => {
|
||||
reset(defaultValues);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) append([{ email: "", role: 15 }]);
|
||||
}, [fields, append]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative translate-y-0 transform rounded-lg bg-custom-background-100 p-5 text-left opacity-100 shadow-custom-shadow-md transition-all w-full sm:max-w-2xl sm:scale-100">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmitForm)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === "Enter") e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{t("workspace_settings.settings.members.modal.title")}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
{t("workspace_settings.settings.members.modal.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="relative group mb-1 flex items-start justify-between gap-x-4 text-sm w-full"
|
||||
>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`emails.${index}.email`}
|
||||
rules={{
|
||||
required: t("workspace_settings.settings.members.modal.errors.required"),
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: t("workspace_settings.settings.members.modal.errors.invalid"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<>
|
||||
<Input
|
||||
id={`emails.${index}.email`}
|
||||
name={`emails.${index}.email`}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.emails?.[index]?.email)}
|
||||
placeholder={t("workspace_settings.settings.members.modal.placeholder")}
|
||||
className="w-full text-xs sm:text-sm"
|
||||
/>
|
||||
{errors.emails?.[index]?.email && (
|
||||
<span className="ml-1 text-xs text-red-500">
|
||||
{errors.emails?.[index]?.email?.message}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 flex-shrink-0 ">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`emails.${index}.role`}
|
||||
rules={{ required: true }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span className="text-xs sm:text-sm">{ROLE[value]}</span>}
|
||||
onChange={onChange}
|
||||
optionsClassName="w-full"
|
||||
className="flex-grow w-24"
|
||||
input
|
||||
>
|
||||
{Object.entries(ROLE).map(([key, value]) => {
|
||||
if (currentWorkspaceRole && currentWorkspaceRole >= parseInt(key))
|
||||
return (
|
||||
<CustomSelect.Option key={key} value={parseInt(key)}>
|
||||
{value}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{fields.length > 1 && (
|
||||
<div className="flex-item flex w-6">
|
||||
<button
|
||||
type="button"
|
||||
className="place-items-center self-center rounded"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<X className="h-4 w-4 text-custom-text-200" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 bg-transparent py-2 pr-3 text-sm font-medium text-custom-primary outline-custom-primary"
|
||||
onClick={appendField}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add_more")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t("workspace_settings.settings.members.modal.button_loading")
|
||||
: t("workspace_settings.settings.members.modal.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
84
web/core/hooks/use-workspace-invitation.tsx
Normal file
84
web/core/hooks/use-workspace-invitation.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { useEffect } from "react";
|
||||
import { Control, FieldArrayWithId, FormState, useFieldArray, useForm, UseFormWatch } from "react-hook-form";
|
||||
// plane imports
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
|
||||
type EmailRole = {
|
||||
email: string;
|
||||
role: EUserPermissions;
|
||||
};
|
||||
|
||||
export type InvitationFormValues = {
|
||||
emails: EmailRole[];
|
||||
};
|
||||
|
||||
const SEND_WORKSPACE_INVITATION_MODAL_DEFAULT_VALUES: InvitationFormValues = {
|
||||
emails: [
|
||||
{
|
||||
email: "",
|
||||
role: EUserPermissions.MEMBER,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
type TUseWorkspaceInvitationProps = {
|
||||
onSubmit: (data: InvitationFormValues) => Promise<void> | undefined;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type TUseWorkspaceInvitationReturn = {
|
||||
control: Control<InvitationFormValues>;
|
||||
fields: FieldArrayWithId<InvitationFormValues, "emails", "id">[];
|
||||
formState: FormState<InvitationFormValues>;
|
||||
watch: UseFormWatch<InvitationFormValues>;
|
||||
remove: (index: number) => void;
|
||||
onFormSubmit: () => void;
|
||||
handleClose: () => void;
|
||||
appendField: () => void;
|
||||
};
|
||||
|
||||
export const useWorkspaceInvitationActions = (props: TUseWorkspaceInvitationProps): TUseWorkspaceInvitationReturn => {
|
||||
const { onSubmit, onClose } = props;
|
||||
// form info
|
||||
const { control, reset, watch, handleSubmit, formState } = useForm<InvitationFormValues>({
|
||||
defaultValues: SEND_WORKSPACE_INVITATION_MODAL_DEFAULT_VALUES,
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "emails",
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
const timeout = setTimeout(() => {
|
||||
reset(SEND_WORKSPACE_INVITATION_MODAL_DEFAULT_VALUES);
|
||||
clearTimeout(timeout);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
const appendField = () => {
|
||||
append({ email: "", role: EUserPermissions.MEMBER });
|
||||
};
|
||||
|
||||
const onSubmitForm = async (data: InvitationFormValues) => {
|
||||
await onSubmit(data)?.then(() => {
|
||||
reset(SEND_WORKSPACE_INVITATION_MODAL_DEFAULT_VALUES);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) append([{ email: "", role: EUserPermissions.MEMBER }]);
|
||||
}, [fields, append]);
|
||||
|
||||
return {
|
||||
control,
|
||||
fields,
|
||||
formState,
|
||||
watch,
|
||||
remove,
|
||||
onFormSubmit: handleSubmit(onSubmitForm),
|
||||
handleClose,
|
||||
appendField,
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue