improvement: enhance workspace invitation modularity (#6594)

This commit is contained in:
Prateek Shourya 2025-02-12 17:05:09 +05:30 committed by GitHub
parent e071bf4861
commit cc9b448a9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 390 additions and 249 deletions

View file

@ -12,14 +12,17 @@ import { IWorkspaceBulkInviteFormData } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { NotAuthorizedView } from "@/components/auth-screens";
import { CountChip } from "@/components/common";
import { PageHead } from "@/components/core";
import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace";
// constants
import { WorkspaceMembersList } from "@/components/workspace";
// helpers
import { cn } from "@/helpers/common.helper";
import { getUserRole } from "@/helpers/user.helper";
// hooks
import { useEventTracker, useMember, useUserPermissions, useWorkspace } from "@/hooks/store";
// plane web components
import { BillingActionsButton } from "@/plane-web/components/workspace/billing";
import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members";
const WorkspaceMembersSettingsPage = observer(() => {
// states
@ -31,7 +34,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { captureEvent } = useEventTracker();
const {
workspace: { inviteMembersToWorkspace },
workspace: { workspaceMemberIds, inviteMembersToWorkspace },
} = useMember();
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
@ -83,6 +86,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
title: "Error!",
message: `${err.error ?? t("something_went_wrong_please_try_again")}`,
});
throw err;
});
};
@ -107,8 +111,13 @@ const WorkspaceMembersSettingsPage = observer(() => {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
<div className="flex justify-between gap-4 pb-3.5 items-start ">
<h4 className="text-xl font-medium">{t("workspace_settings.settings.members.title")}</h4>
<div className="flex justify-between gap-4 pb-3.5 items-start">
<h4 className="flex items-center gap-2.5 text-xl font-medium">
{t("workspace_settings.settings.members.title")}
{workspaceMemberIds && workspaceMemberIds.length > 0 && (
<CountChip count={workspaceMemberIds.length} className="h-5 m-auto" />
)}
</h4>
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
<Search className="h-3.5 w-3.5 text-custom-text-400" />
<input
@ -124,6 +133,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
{t("workspace_settings.settings.members.add_member")}
</Button>
)}
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
</div>
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
</section>

View file

@ -0,0 +1,10 @@
"use client";
import { observer } from "mobx-react";
export type TBillingActionsButtonProps = {
canPerformWorkspaceAdminActions: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const BillingActionsButton = observer((props: TBillingActionsButtonProps) => <></>);

View file

@ -1 +1,2 @@
export * from "./root";
export * from "./billing-actions-button";

View file

@ -2,3 +2,4 @@ export * from "./edition-badge";
export * from "./upgrade-badge";
export * from "./billing";
export * from "./delete-workspace-section";
export * from "./members";

View file

@ -0,0 +1 @@
export * from "./invite-modal";

View file

@ -0,0 +1,60 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { useTranslation } from "@plane/i18n";
import { IWorkspaceBulkInviteFormData } from "@plane/types";
// ui
import { EModalWidth, EModalPosition, ModalCore } from "@plane/ui";
// components
import { InvitationFields, InvitationModalActions } from "@/components/workspace/invite-modal";
import { InvitationForm } from "@/components/workspace/invite-modal/form";
// hooks
import { useWorkspaceInvitationActions } from "@/hooks/use-workspace-invitation";
export type TSendWorkspaceInvitationModalProps = {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: IWorkspaceBulkInviteFormData) => Promise<void> | undefined;
};
export const SendWorkspaceInvitationModal: React.FC<TSendWorkspaceInvitationModalProps> = observer((props) => {
const { isOpen, onClose, onSubmit } = props;
// store hooks
const { t } = useTranslation();
// router
const { workspaceSlug } = useParams();
// derived values
const { control, fields, formState, remove, onFormSubmit, handleClose, appendField } = useWorkspaceInvitationActions({
onSubmit,
onClose,
});
return (
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<InvitationForm
title={t("workspace_settings.settings.members.modal.title")}
description={t("workspace_settings.settings.members.modal.description")}
onSubmit={onFormSubmit}
actions={
<InvitationModalActions
isSubmitting={formState.isSubmitting}
handleClose={handleClose}
appendField={appendField}
/>
}
className="p-5"
>
<InvitationFields
workspaceSlug={workspaceSlug.toString()}
fields={fields}
control={control}
formState={formState}
remove={remove}
/>
</InvitationForm>
</ModalCore>
);
});

View file

@ -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";

View 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>
);
});

View 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>
);
});

View 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>
);
});

View file

@ -0,0 +1,3 @@
export * from "./actions";
export * from "./fields";
export * from "./form";

View file

@ -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>
);
});

View 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,
};
};