From ec619c5413746f93dd9ae80f34864a4c6edd757d Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Wed, 12 Feb 2025 14:49:45 +0530 Subject: [PATCH] improvement: enhance workspace invitation modularity --- .../(projects)/settings/members/page.tsx | 20 +- .../billing/billing-actions-button.tsx | 10 + web/ce/components/workspace/billing/index.ts | 1 + web/ce/components/workspace/index.ts | 1 + web/ce/components/workspace/members/index.ts | 1 + .../workspace/members/invite-modal.tsx | 60 +++++ web/core/components/workspace/index.ts | 2 +- .../workspace/invite-modal/actions.tsx | 64 +++++ .../workspace/invite-modal/fields.tsx | 114 ++++++++ .../workspace/invite-modal/form.tsx | 36 +++ .../workspace/invite-modal/index.ts | 3 + .../send-workspace-invitation-modal.tsx | 243 ------------------ web/core/hooks/use-workspace-invitation.tsx | 84 ++++++ 13 files changed, 390 insertions(+), 249 deletions(-) create mode 100644 web/ce/components/workspace/billing/billing-actions-button.tsx create mode 100644 web/ce/components/workspace/members/index.ts create mode 100644 web/ce/components/workspace/members/invite-modal.tsx create mode 100644 web/core/components/workspace/invite-modal/actions.tsx create mode 100644 web/core/components/workspace/invite-modal/fields.tsx create mode 100644 web/core/components/workspace/invite-modal/form.tsx create mode 100644 web/core/components/workspace/invite-modal/index.ts delete mode 100644 web/core/components/workspace/send-workspace-invitation-modal.tsx create mode 100644 web/core/hooks/use-workspace-invitation.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx index 936eda0ba18..74b45236c9a 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx @@ -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, })} > -
-

{t("workspace_settings.settings.members.title")}

+
+

+ {t("workspace_settings.settings.members.title")} + {workspaceMemberIds && workspaceMemberIds.length > 0 && ( + + )} +

{ {t("workspace_settings.settings.members.add_member")} )} +
diff --git a/web/ce/components/workspace/billing/billing-actions-button.tsx b/web/ce/components/workspace/billing/billing-actions-button.tsx new file mode 100644 index 00000000000..b15d67b9111 --- /dev/null +++ b/web/ce/components/workspace/billing/billing-actions-button.tsx @@ -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) => <>); diff --git a/web/ce/components/workspace/billing/index.ts b/web/ce/components/workspace/billing/index.ts index 1efe34c51ec..f02c34d3f6d 100644 --- a/web/ce/components/workspace/billing/index.ts +++ b/web/ce/components/workspace/billing/index.ts @@ -1 +1,2 @@ export * from "./root"; +export * from "./billing-actions-button"; diff --git a/web/ce/components/workspace/index.ts b/web/ce/components/workspace/index.ts index 94a16694754..ab13bb3907e 100644 --- a/web/ce/components/workspace/index.ts +++ b/web/ce/components/workspace/index.ts @@ -2,3 +2,4 @@ export * from "./edition-badge"; export * from "./upgrade-badge"; export * from "./billing"; export * from "./delete-workspace-section"; +export * from "./members"; diff --git a/web/ce/components/workspace/members/index.ts b/web/ce/components/workspace/members/index.ts new file mode 100644 index 00000000000..5e1651e901c --- /dev/null +++ b/web/ce/components/workspace/members/index.ts @@ -0,0 +1 @@ +export * from "./invite-modal"; diff --git a/web/ce/components/workspace/members/invite-modal.tsx b/web/ce/components/workspace/members/invite-modal.tsx new file mode 100644 index 00000000000..629370cc14c --- /dev/null +++ b/web/ce/components/workspace/members/invite-modal.tsx @@ -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 | undefined; +}; + +export const SendWorkspaceInvitationModal: React.FC = 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 ( + + + } + className="p-5" + > + + + + ); +}); diff --git a/web/core/components/workspace/index.ts b/web/core/components/workspace/index.ts index a8f404d1b96..29c7a050497 100644 --- a/web/core/components/workspace/index.ts +++ b/web/core/components/workspace/index.ts @@ -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"; diff --git a/web/core/components/workspace/invite-modal/actions.tsx b/web/core/components/workspace/invite-modal/actions.tsx new file mode 100644 index 00000000000..e6c535282b4 --- /dev/null +++ b/web/core/components/workspace/invite-modal/actions.tsx @@ -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 = observer((props) => { + const { + isInviteDisabled = false, + isSubmitting = false, + handleClose, + appendField, + addMoreButtonText, + submitButtonText, + cancelButtonText, + className, + } = props; + // store hooks + const { t } = useTranslation(); + + return ( +
+ +
+ + +
+
+ ); +}); diff --git a/web/core/components/workspace/invite-modal/fields.tsx b/web/core/components/workspace/invite-modal/fields.tsx new file mode 100644 index 00000000000..d46a2499542 --- /dev/null +++ b/web/core/components/workspace/invite-modal/fields.tsx @@ -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[]; + control: Control; + formState: FormState; + 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 ( +
+ {fields.map((field, index) => ( +
+
+ ( + <> + + {errors.emails?.[index]?.email && ( + {errors.emails?.[index]?.email?.message} + )} + + )} + /> +
+
+
+ ( + {ROLE[value]}} + onChange={onChange} + optionsClassName="w-full" + className="flex-grow w-24" + input + > + {Object.entries(ROLE).map(([key, value]) => { + if (currentWorkspaceRole && currentWorkspaceRole >= parseInt(key)) + return ( + + {value} + + ); + })} + + )} + /> +
+ {fields.length > 1 && ( +
+ +
+ )} +
+
+ ))} +
+ ); +}); diff --git a/web/core/components/workspace/invite-modal/form.tsx b/web/core/components/workspace/invite-modal/form.tsx new file mode 100644 index 00000000000..1178f1f6ecf --- /dev/null +++ b/web/core/components/workspace/invite-modal/form.tsx @@ -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 ( +
{ + if (e.code === "Enter") e.preventDefault(); + }} + className={className} + > +
+ + {title} + +
{description}
+ {children} +
+ {actions} +
+ ); +}); diff --git a/web/core/components/workspace/invite-modal/index.ts b/web/core/components/workspace/invite-modal/index.ts new file mode 100644 index 00000000000..235edf9800b --- /dev/null +++ b/web/core/components/workspace/invite-modal/index.ts @@ -0,0 +1,3 @@ +export * from "./actions"; +export * from "./fields"; +export * from "./form"; diff --git a/web/core/components/workspace/send-workspace-invitation-modal.tsx b/web/core/components/workspace/send-workspace-invitation-modal.tsx deleted file mode 100644 index a30e8e51fd1..00000000000 --- a/web/core/components/workspace/send-workspace-invitation-modal.tsx +++ /dev/null @@ -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 | undefined; -}; - -type EmailRole = { - email: string; - role: EUserPermissions; -}; - -type FormValues = { - emails: EmailRole[]; -}; - -const defaultValues: FormValues = { - emails: [ - { - email: "", - role: 15, - }, - ], -}; - -export const SendWorkspaceInvitationModal: React.FC = 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(); - - 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 ( - - - -
- - -
-
- - -
{ - if (e.code === "Enter") e.preventDefault(); - }} - > -
- - {t("workspace_settings.settings.members.modal.title")} - -
-

- {t("workspace_settings.settings.members.modal.description")} -

-
- -
- {fields.map((field, index) => ( -
-
- ( - <> - - {errors.emails?.[index]?.email && ( - - {errors.emails?.[index]?.email?.message} - - )} - - )} - /> -
-
-
- ( - {ROLE[value]}} - onChange={onChange} - optionsClassName="w-full" - className="flex-grow w-24" - input - > - {Object.entries(ROLE).map(([key, value]) => { - if (currentWorkspaceRole && currentWorkspaceRole >= parseInt(key)) - return ( - - {value} - - ); - })} - - )} - /> -
- {fields.length > 1 && ( -
- -
- )} -
-
- ))} -
-
- -
- -
- - -
-
-
-
-
-
-
-
-
- ); -}); diff --git a/web/core/hooks/use-workspace-invitation.tsx b/web/core/hooks/use-workspace-invitation.tsx new file mode 100644 index 00000000000..20d19e894f0 --- /dev/null +++ b/web/core/hooks/use-workspace-invitation.tsx @@ -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 | undefined; + onClose: () => void; +}; + +type TUseWorkspaceInvitationReturn = { + control: Control; + fields: FieldArrayWithId[]; + formState: FormState; + watch: UseFormWatch; + 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({ + 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, + }; +};