Skip to content

Commit cc9b448

Browse files
improvement: enhance workspace invitation modularity (#6594)
1 parent e071bf4 commit cc9b448

File tree

13 files changed

+390
-249
lines changed

13 files changed

+390
-249
lines changed

web/app/[workspaceSlug]/(projects)/settings/members/page.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@ import { IWorkspaceBulkInviteFormData } from "@plane/types";
1212
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
1313
// components
1414
import { NotAuthorizedView } from "@/components/auth-screens";
15+
import { CountChip } from "@/components/common";
1516
import { PageHead } from "@/components/core";
16-
import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace";
17-
// constants
17+
import { WorkspaceMembersList } from "@/components/workspace";
1818
// helpers
1919
import { cn } from "@/helpers/common.helper";
2020
import { getUserRole } from "@/helpers/user.helper";
2121
// hooks
2222
import { useEventTracker, useMember, useUserPermissions, useWorkspace } from "@/hooks/store";
23+
// plane web components
24+
import { BillingActionsButton } from "@/plane-web/components/workspace/billing";
25+
import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members";
2326

2427
const WorkspaceMembersSettingsPage = observer(() => {
2528
// states
@@ -31,7 +34,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
3134
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
3235
const { captureEvent } = useEventTracker();
3336
const {
34-
workspace: { inviteMembersToWorkspace },
37+
workspace: { workspaceMemberIds, inviteMembersToWorkspace },
3538
} = useMember();
3639
const { currentWorkspace } = useWorkspace();
3740
const { t } = useTranslation();
@@ -83,6 +86,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
8386
title: "Error!",
8487
message: `${err.error ?? t("something_went_wrong_please_try_again")}`,
8588
});
89+
throw err;
8690
});
8791
};
8892

@@ -107,8 +111,13 @@ const WorkspaceMembersSettingsPage = observer(() => {
107111
"opacity-60": !canPerformWorkspaceMemberActions,
108112
})}
109113
>
110-
<div className="flex justify-between gap-4 pb-3.5 items-start ">
111-
<h4 className="text-xl font-medium">{t("workspace_settings.settings.members.title")}</h4>
114+
<div className="flex justify-between gap-4 pb-3.5 items-start">
115+
<h4 className="flex items-center gap-2.5 text-xl font-medium">
116+
{t("workspace_settings.settings.members.title")}
117+
{workspaceMemberIds && workspaceMemberIds.length > 0 && (
118+
<CountChip count={workspaceMemberIds.length} className="h-5 m-auto" />
119+
)}
120+
</h4>
112121
<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">
113122
<Search className="h-3.5 w-3.5 text-custom-text-400" />
114123
<input
@@ -124,6 +133,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
124133
{t("workspace_settings.settings.members.add_member")}
125134
</Button>
126135
)}
136+
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
127137
</div>
128138
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
129139
</section>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"use client";
2+
3+
import { observer } from "mobx-react";
4+
5+
export type TBillingActionsButtonProps = {
6+
canPerformWorkspaceAdminActions: boolean;
7+
};
8+
9+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
10+
export const BillingActionsButton = observer((props: TBillingActionsButtonProps) => <></>);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from "./root";
2+
export * from "./billing-actions-button";

web/ce/components/workspace/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from "./edition-badge";
22
export * from "./upgrade-badge";
33
export * from "./billing";
44
export * from "./delete-workspace-section";
5+
export * from "./members";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./invite-modal";
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import React from "react";
4+
import { observer } from "mobx-react";
5+
import { useParams } from "next/navigation";
6+
// plane imports
7+
import { useTranslation } from "@plane/i18n";
8+
import { IWorkspaceBulkInviteFormData } from "@plane/types";
9+
// ui
10+
import { EModalWidth, EModalPosition, ModalCore } from "@plane/ui";
11+
// components
12+
import { InvitationFields, InvitationModalActions } from "@/components/workspace/invite-modal";
13+
import { InvitationForm } from "@/components/workspace/invite-modal/form";
14+
// hooks
15+
import { useWorkspaceInvitationActions } from "@/hooks/use-workspace-invitation";
16+
17+
export type TSendWorkspaceInvitationModalProps = {
18+
isOpen: boolean;
19+
onClose: () => void;
20+
onSubmit: (data: IWorkspaceBulkInviteFormData) => Promise<void> | undefined;
21+
};
22+
23+
export const SendWorkspaceInvitationModal: React.FC<TSendWorkspaceInvitationModalProps> = observer((props) => {
24+
const { isOpen, onClose, onSubmit } = props;
25+
// store hooks
26+
const { t } = useTranslation();
27+
// router
28+
const { workspaceSlug } = useParams();
29+
// derived values
30+
const { control, fields, formState, remove, onFormSubmit, handleClose, appendField } = useWorkspaceInvitationActions({
31+
onSubmit,
32+
onClose,
33+
});
34+
35+
return (
36+
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
37+
<InvitationForm
38+
title={t("workspace_settings.settings.members.modal.title")}
39+
description={t("workspace_settings.settings.members.modal.description")}
40+
onSubmit={onFormSubmit}
41+
actions={
42+
<InvitationModalActions
43+
isSubmitting={formState.isSubmitting}
44+
handleClose={handleClose}
45+
appendField={appendField}
46+
/>
47+
}
48+
className="p-5"
49+
>
50+
<InvitationFields
51+
workspaceSlug={workspaceSlug.toString()}
52+
fields={fields}
53+
control={control}
54+
formState={formState}
55+
remove={remove}
56+
/>
57+
</InvitationForm>
58+
</ModalCore>
59+
);
60+
});

web/core/components/workspace/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ export * from "./confirm-workspace-member-remove";
55
export * from "./create-workspace-form";
66
export * from "./delete-workspace-modal";
77
export * from "./logo";
8-
export * from "./send-workspace-invitation-modal";
8+
export * from "./invite-modal";
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { observer } from "mobx-react";
2+
import { Plus } from "lucide-react";
3+
// plane imports
4+
import { useTranslation } from "@plane/i18n";
5+
import { Button } from "@plane/ui";
6+
import { cn } from "@plane/utils";
7+
8+
type TInvitationModalActionsProps = {
9+
isInviteDisabled?: boolean;
10+
isSubmitting?: boolean;
11+
handleClose: () => void;
12+
appendField: () => void;
13+
addMoreButtonText?: string;
14+
submitButtonText?: {
15+
default: string;
16+
loading: string;
17+
};
18+
cancelButtonText?: string;
19+
className?: string;
20+
};
21+
22+
export const InvitationModalActions: React.FC<TInvitationModalActionsProps> = observer((props) => {
23+
const {
24+
isInviteDisabled = false,
25+
isSubmitting = false,
26+
handleClose,
27+
appendField,
28+
addMoreButtonText,
29+
submitButtonText,
30+
cancelButtonText,
31+
className,
32+
} = props;
33+
// store hooks
34+
const { t } = useTranslation();
35+
36+
return (
37+
<div className={cn("mt-5 flex items-center justify-between gap-2", className)}>
38+
<button
39+
type="button"
40+
className={cn(
41+
"flex items-center gap-1 bg-transparent py-2 pr-3 text-xs font-medium text-custom-primary outline-custom-primary",
42+
{
43+
"cursor-not-allowed opacity-60": isInviteDisabled,
44+
}
45+
)}
46+
onClick={appendField}
47+
disabled={isInviteDisabled}
48+
>
49+
<Plus className="h-3.5 w-3.5" />
50+
{addMoreButtonText || t("common.add_more")}
51+
</button>
52+
<div className="flex items-center gap-2">
53+
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
54+
{cancelButtonText || t("cancel")}
55+
</Button>
56+
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} disabled={isInviteDisabled}>
57+
{isSubmitting
58+
? submitButtonText?.loading || t("workspace_settings.settings.members.modal.button_loading")
59+
: submitButtonText?.default || t("workspace_settings.settings.members.modal.button")}
60+
</Button>
61+
</div>
62+
</div>
63+
);
64+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"use client";
2+
3+
import { observer } from "mobx-react";
4+
import { Control, Controller, FieldArrayWithId, FormState } from "react-hook-form";
5+
import { X } from "lucide-react";
6+
// plane imports
7+
import { ROLE } from "@plane/constants";
8+
import { useTranslation } from "@plane/i18n";
9+
import { CustomSelect, Input } from "@plane/ui";
10+
import { cn } from "@plane/utils";
11+
// hooks
12+
import { useUserPermissions } from "@/hooks/store";
13+
import { InvitationFormValues } from "@/hooks/use-workspace-invitation";
14+
15+
type TInvitationFieldsProps = {
16+
workspaceSlug: string;
17+
fields: FieldArrayWithId<InvitationFormValues, "emails", "id">[];
18+
control: Control<InvitationFormValues>;
19+
formState: FormState<InvitationFormValues>;
20+
remove: (index: number) => void;
21+
className?: string;
22+
};
23+
24+
export const InvitationFields = observer((props: TInvitationFieldsProps) => {
25+
const {
26+
workspaceSlug,
27+
fields,
28+
control,
29+
formState: { errors },
30+
remove,
31+
className,
32+
} = props;
33+
// plane hooks
34+
const { t } = useTranslation();
35+
// store hooks
36+
const { workspaceInfoBySlug } = useUserPermissions();
37+
// derived values
38+
const currentWorkspaceRole = workspaceInfoBySlug(workspaceSlug.toString())?.role;
39+
40+
return (
41+
<div className={cn("mb-3 space-y-4", className)}>
42+
{fields.map((field, index) => (
43+
<div key={field.id} className="relative group mb-1 flex items-start justify-between gap-x-4 text-sm w-full">
44+
<div className="w-full">
45+
<Controller
46+
control={control}
47+
name={`emails.${index}.email`}
48+
rules={{
49+
required: t("workspace_settings.settings.members.modal.errors.required"),
50+
pattern: {
51+
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
52+
message: t("workspace_settings.settings.members.modal.errors.invalid"),
53+
},
54+
}}
55+
render={({ field: { value, onChange, ref } }) => (
56+
<>
57+
<Input
58+
id={`emails.${index}.email`}
59+
name={`emails.${index}.email`}
60+
type="text"
61+
value={value}
62+
onChange={onChange}
63+
ref={ref}
64+
hasError={Boolean(errors.emails?.[index]?.email)}
65+
placeholder={t("workspace_settings.settings.members.modal.placeholder")}
66+
className="w-full text-xs sm:text-sm"
67+
/>
68+
{errors.emails?.[index]?.email && (
69+
<span className="ml-1 text-xs text-red-500">{errors.emails?.[index]?.email?.message}</span>
70+
)}
71+
</>
72+
)}
73+
/>
74+
</div>
75+
<div className="flex items-center justify-between gap-2 flex-shrink-0 ">
76+
<div className="flex flex-col gap-1">
77+
<Controller
78+
control={control}
79+
name={`emails.${index}.role`}
80+
rules={{ required: true }}
81+
render={({ field: { value, onChange } }) => (
82+
<CustomSelect
83+
value={value}
84+
label={<span className="text-xs sm:text-sm">{ROLE[value]}</span>}
85+
onChange={onChange}
86+
optionsClassName="w-full"
87+
className="flex-grow w-24"
88+
input
89+
>
90+
{Object.entries(ROLE).map(([key, value]) => {
91+
if (currentWorkspaceRole && currentWorkspaceRole >= parseInt(key))
92+
return (
93+
<CustomSelect.Option key={key} value={parseInt(key)}>
94+
{value}
95+
</CustomSelect.Option>
96+
);
97+
})}
98+
</CustomSelect>
99+
)}
100+
/>
101+
</div>
102+
{fields.length > 1 && (
103+
<div className="flex-item flex w-6">
104+
<button type="button" className="place-items-center self-center rounded" onClick={() => remove(index)}>
105+
<X className="h-4 w-4 text-custom-text-200" />
106+
</button>
107+
</div>
108+
)}
109+
</div>
110+
</div>
111+
))}
112+
</div>
113+
);
114+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"use client";
2+
3+
import { observer } from "mobx-react";
4+
import { Dialog } from "@headlessui/react";
5+
6+
type TInvitationFormProps = {
7+
title: string;
8+
description: React.ReactNode;
9+
children: React.ReactNode;
10+
onSubmit: () => void;
11+
actions: React.ReactNode;
12+
className?: string;
13+
};
14+
15+
export const InvitationForm = observer((props: TInvitationFormProps) => {
16+
const { title, description, children, actions, onSubmit, className } = props;
17+
18+
return (
19+
<form
20+
onSubmit={onSubmit}
21+
onKeyDown={(e) => {
22+
if (e.code === "Enter") e.preventDefault();
23+
}}
24+
className={className}
25+
>
26+
<div className="space-y-4">
27+
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
28+
{title}
29+
</Dialog.Title>
30+
<div className="text-sm text-custom-text-200">{description}</div>
31+
{children}
32+
</div>
33+
{actions}
34+
</form>
35+
);
36+
});

0 commit comments

Comments
 (0)