Skip to content

Commit

Permalink
improvement: enhance workspace invitation modularity (#6594)
Browse files Browse the repository at this point in the history
  • Loading branch information
prateekshourya29 authored Feb 12, 2025
1 parent e071bf4 commit cc9b448
Show file tree
Hide file tree
Showing 13 changed files with 390 additions and 249 deletions.
20 changes: 15 additions & 5 deletions web/app/[workspaceSlug]/(projects)/settings/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -83,6 +86,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
title: "Error!",
message: `${err.error ?? t("something_went_wrong_please_try_again")}`,
});
throw err;
});
};

Expand All @@ -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
Expand All @@ -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>
Expand Down
10 changes: 10 additions & 0 deletions web/ce/components/workspace/billing/billing-actions-button.tsx
Original file line number Diff line number Diff line change
@@ -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) => <></>);
1 change: 1 addition & 0 deletions web/ce/components/workspace/billing/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./root";
export * from "./billing-actions-button";
1 change: 1 addition & 0 deletions web/ce/components/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./edition-badge";
export * from "./upgrade-badge";
export * from "./billing";
export * from "./delete-workspace-section";
export * from "./members";
1 change: 1 addition & 0 deletions web/ce/components/workspace/members/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./invite-modal";
60 changes: 60 additions & 0 deletions web/ce/components/workspace/members/invite-modal.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
});
2 changes: 1 addition & 1 deletion web/core/components/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 64 additions & 0 deletions web/core/components/workspace/invite-modal/actions.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 114 additions & 0 deletions web/core/components/workspace/invite-modal/fields.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 36 additions & 0 deletions web/core/components/workspace/invite-modal/form.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions web/core/components/workspace/invite-modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./actions";
export * from "./fields";
export * from "./form";
Loading

0 comments on commit cc9b448

Please sign in to comment.