diff --git a/web/components/integration/jira/import-users.tsx b/web/components/integration/jira/import-users.tsx index 440584e1c..c49a3483a 100644 --- a/web/components/integration/jira/import-users.tsx +++ b/web/components/integration/jira/import-users.tsx @@ -31,7 +31,7 @@ export const JiraImportUsers: FC = () => { const { data: members } = useSWR( workspaceSlug ? WORKSPACE_MEMBERS_WITH_EMAIL(workspaceSlug?.toString() ?? "") : null, - workspaceSlug ? () => workspaceService.workspaceMembersWithEmail(workspaceSlug?.toString() ?? "") : null + workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug?.toString() ?? "") : null ); const options = members?.map((member) => ({ diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index aee53a882..f48bb3894 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -1,6 +1,5 @@ export * from "./attachment"; export * from "./comment"; -export * from "./my-issues"; export * from "./sidebar-select"; export * from "./view-select"; export * from "./activity"; diff --git a/web/components/issues/my-issues/index.ts b/web/components/issues/my-issues/index.ts deleted file mode 100644 index 65a063f4c..000000000 --- a/web/components/issues/my-issues/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./my-issues-select-filters"; -export * from "./my-issues-view-options"; -export * from "./my-issues-view"; diff --git a/web/components/issues/my-issues/my-issues-select-filters.tsx b/web/components/issues/my-issues/my-issues-select-filters.tsx deleted file mode 100644 index 355496888..000000000 --- a/web/components/issues/my-issues/my-issues-select-filters.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useState } from "react"; -import { useRouter } from "next/router"; -import useSWR from "swr"; -// services -import { IssueLabelService } from "services/issue"; -// components -import { DateFilterModal } from "components/core"; -// ui -import { MultiLevelDropdown } from "components/ui"; -// icons -import { PriorityIcon, StateGroupIcon } from "@plane/ui"; -// helpers -import { checkIfArraysHaveSameElements } from "helpers/array.helper"; -// types -import { IIssueFilterOptions, TStateGroups } from "types"; -// fetch-keys -import { WORKSPACE_LABELS } from "constants/fetch-keys"; -// constants -import { GROUP_CHOICES, PRIORITIES } from "constants/project"; -import { DATE_FILTER_OPTIONS } from "constants/filters"; - -type Props = { - filters: Partial | any; - onSelect: (option: any) => void; - direction?: "left" | "right"; - height?: "sm" | "md" | "rg" | "lg"; -}; - -const issueLabelService = new IssueLabelService(); - -export const MyIssuesSelectFilters: React.FC = ({ filters, onSelect, direction = "right", height = "md" }) => { - const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); - const [dateFilterType, setDateFilterType] = useState<{ - title: string; - type: "start_date" | "target_date"; - }>({ - title: "", - type: "start_date", - }); - const [fetchLabels, setFetchLabels] = useState(false); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { data: labels } = useSWR( - workspaceSlug && fetchLabels ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, - workspaceSlug && fetchLabels ? () => issueLabelService.getWorkspaceIssueLabels(workspaceSlug.toString()) : null - ); - - return ( - <> - {/* {isDateFilterModalOpen && ( - setIsDateFilterModalOpen(false)} - isOpen={isDateFilterModalOpen} - onSelect={onSelect} - /> - )} */} - ({ - id: priority === null ? "null" : priority, - label: ( -
- {priority ?? "None"} -
- ), - value: { - key: "priority", - value: priority === null ? "null" : priority, - }, - selected: filters?.priority?.includes(priority === null ? "null" : priority), - })), - ], - }, - { - id: "state_group", - label: "State groups", - value: GROUP_CHOICES, - hasChildren: true, - children: [ - ...Object.keys(GROUP_CHOICES).map((key) => ({ - id: key, - label: ( -
- - {GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]} -
- ), - value: { - key: "state_group", - value: key, - }, - selected: filters?.state?.includes(key), - })), - ], - }, - { - id: "labels", - label: "Labels", - onClick: () => setFetchLabels(true), - value: labels, - hasChildren: true, - children: labels?.map((label) => ({ - id: label.id, - label: ( -
-
- {label.name} -
- ), - value: { - key: "labels", - value: label.id, - }, - selected: filters?.labels?.includes(label.id), - })), - }, - { - id: "start_date", - label: "Start date", - value: DATE_FILTER_OPTIONS, - hasChildren: true, - children: [ - ...(DATE_FILTER_OPTIONS?.map((option) => ({ - id: option.name, - label: option.name, - value: { - key: "start_date", - value: option.value, - }, - selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], [option.value]), - })) ?? []), - { - id: "custom", - label: "Custom", - value: "custom", - element: ( - - ), - }, - ], - }, - { - id: "target_date", - label: "Due date", - value: DATE_FILTER_OPTIONS, - hasChildren: true, - children: [ - ...(DATE_FILTER_OPTIONS?.map((option) => ({ - id: option.name, - label: option.name, - value: { - key: "target_date", - value: option.value, - }, - selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], [option.value]), - })) ?? []), - { - id: "custom", - label: "Custom", - value: "custom", - element: ( - - ), - }, - ], - }, - ]} - /> - - ); -}; diff --git a/web/components/issues/my-issues/my-issues-view-options.tsx b/web/components/issues/my-issues/my-issues-view-options.tsx deleted file mode 100644 index 7ca6dfce7..000000000 --- a/web/components/issues/my-issues/my-issues-view-options.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from "react"; - -import { useRouter } from "next/router"; - -// hooks -import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; -// components -import { MyIssuesSelectFilters } from "components/issues"; -// ui -import { Tooltip } from "@plane/ui"; -// icons -import { List, Sheet } from "lucide-react"; -// helpers -import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; -import { checkIfArraysHaveSameElements } from "helpers/array.helper"; -// types -import { TIssueLayouts } from "types"; - -const issueViewOptions: { type: TIssueLayouts; Icon: any }[] = [ - { - type: "list", - Icon: List, - }, - { - type: "spreadsheet", - Icon: Sheet, - }, -]; - -export const MyIssuesViewOptions: React.FC = () => { - const router = useRouter(); - const { workspaceSlug, globalViewId } = router.query; - - const { displayFilters, setDisplayFilters, filters, setFilters } = useMyIssuesFilters(workspaceSlug?.toString()); - - const workspaceViewPathName = ["workspace-views/all-issues"]; - - const isWorkspaceViewPath = workspaceViewPathName.some((pathname) => router.pathname.includes(pathname)); - - const showFilters = isWorkspaceViewPath || globalViewId; - - return ( -
-
- {issueViewOptions.map((option) => ( - {replaceUnderscoreIfSnakeCase(option.type)} View} - position="bottom" - > - - - ))} -
- {showFilters && ( - { - const key = option.key as keyof typeof filters; - - if (key === "start_date" || key === "target_date") { - const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value); - - setFilters({ - [key]: valueExists ? null : option.value, - }); - } else { - const valueExists = filters[key]?.includes(option.value); - - if (valueExists) - setFilters({ - [option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value), - }); - else - setFilters({ - [option.key]: [...((filters[key] ?? []) as any[]), option.value], - }); - } - }} - direction="left" - height="rg" - /> - )} -
- ); -}; diff --git a/web/components/issues/my-issues/my-issues-view.tsx b/web/components/issues/my-issues/my-issues-view.tsx deleted file mode 100644 index 9ec77cbbf..000000000 --- a/web/components/issues/my-issues/my-issues-view.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import { useState } from "react"; -import { useRouter } from "next/router"; -import useSWR from "swr"; -// services -import { IssueLabelService } from "services/issue"; -// hooks -import useMyIssues from "hooks/my-issues/use-my-issues"; -import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; -// components -import { FiltersList } from "components/core"; -import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; -// types -import { IIssue, IIssueFilterOptions } from "types"; -// fetch-keys -import { WORKSPACE_LABELS } from "constants/fetch-keys"; - -type Props = { - openIssuesListModal?: () => void; - disableUserActions?: false; -}; - -const issueLabelService = new IssueLabelService(); - -export const MyIssuesView: React.FC = () => { - // create issue modal - const [createIssueModal, setCreateIssueModal] = useState(false); - const [preloadedData] = useState<(Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined>( - undefined - ); - - // update issue modal - const [editIssueModal, setEditIssueModal] = useState(false); - const [issueToEdit] = useState<(IIssue & { actionType: "edit" | "delete" }) | undefined>(undefined); - - // delete issue modal - const [deleteIssueModal, setDeleteIssueModal] = useState(false); - const [issueToDelete] = useState(null); - - // trash box - // const [trashBox, setTrashBox] = useState(false); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); - const { filters, setFilters } = useMyIssuesFilters(workspaceSlug?.toString()); - - const { data: labels } = useSWR( - workspaceSlug && (filters?.labels ?? []).length > 0 ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, - workspaceSlug && (filters?.labels ?? []).length > 0 - ? () => issueLabelService.getWorkspaceIssueLabels(workspaceSlug.toString()) - : null - ); - - // const handleDeleteIssue = useCallback( - // (issue: IIssue) => { - // setDeleteIssueModal(true); - // setIssueToDelete(issue); - // }, - // [setDeleteIssueModal, setIssueToDelete] - // ); - - // const handleOnDragEnd = useCallback( - // async (result: DropResult) => { - // setTrashBox(false); - - // if (!result.destination || !workspaceSlug || !groupedIssues || displayFilters?.group_by !== "priority") return; - - // const { source, destination } = result; - - // if (source.droppableId === destination.droppableId) return; - - // const draggedItem = groupedIssues[source.droppableId][source.index]; - - // if (!draggedItem) return; - - // if (destination.droppableId === "trashBox") handleDeleteIssue(draggedItem); - // else { - // const sourceGroup = source.droppableId; - // const destinationGroup = destination.droppableId; - - // draggedItem[displayFilters.group_by] = destinationGroup as TIssuePriorities; - - // mutate<{ - // [key: string]: IIssue[]; - // }>( - // USER_ISSUES(workspaceSlug.toString(), params), - // (prevData) => { - // if (!prevData) return prevData; - - // const sourceGroupArray = [...groupedIssues[sourceGroup]]; - // const destinationGroupArray = [...groupedIssues[destinationGroup]]; - - // sourceGroupArray.splice(source.index, 1); - // destinationGroupArray.splice(destination.index, 0, draggedItem); - - // return { - // ...prevData, - // [sourceGroup]: orderArrayBy(sourceGroupArray, displayFilters.order_by ?? "-created_at"), - // [destinationGroup]: orderArrayBy(destinationGroupArray, displayFilters.order_by ?? "-created_at"), - // }; - // }, - // false - // ); - - // // patch request - // issuesService - // .patchIssue( - // workspaceSlug as string, - // draggedItem.project, - // draggedItem.id, - // { - // priority: draggedItem.priority, - // }, - // user - // ) - // .catch(() => mutate(USER_ISSUES(workspaceSlug.toString(), params))); - // } - // }, - // [displayFilters, groupedIssues, handleDeleteIssue, params, user, workspaceSlug] - // ); - - // const addIssueToGroup = useCallback( - // (groupTitle: string) => { - // setCreateIssueModal(true); - - // let preloadedValue: string | string[] = groupTitle; - - // if (displayFilters?.group_by === "labels") { - // if (groupTitle === "None") preloadedValue = []; - // else preloadedValue = [groupTitle]; - // } - - // if (displayFilters?.group_by) - // setPreloadedData({ - // [displayFilters?.group_by]: preloadedValue, - // actionType: "createIssue", - // }); - // else setPreloadedData({ actionType: "createIssue" }); - // }, - // [setCreateIssueModal, setPreloadedData, displayFilters?.group_by] - // ); - - // const addIssueToDate = useCallback( - // (date: string) => { - // setCreateIssueModal(true); - // setPreloadedData({ - // target_date: date, - // actionType: "createIssue", - // }); - // }, - // [setCreateIssueModal, setPreloadedData] - // ); - - // const makeIssueCopy = useCallback( - // (issue: IIssue) => { - // setCreateIssueModal(true); - - // setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); - // }, - // [setCreateIssueModal, setPreloadedData] - // ); - - // const handleEditIssue = useCallback( - // (issue: IIssue) => { - // setEditIssueModal(true); - // setIssueToEdit({ - // ...issue, - // actionType: "edit", - // cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, - // module: issue.issue_module ? issue.issue_module.module : null, - // }); - // }, - // [setEditIssueModal, setIssueToEdit] - // ); - - // const handleIssueAction = useCallback( - // (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { - // if (action === "copy") makeIssueCopy(issue); - // else if (action === "edit") handleEditIssue(issue); - // else if (action === "delete") handleDeleteIssue(issue); - // }, - // [makeIssueCopy, handleEditIssue, handleDeleteIssue] - // ); - - const filtersToDisplay = { ...filters, assignees: null, created_by: null, subscriber: null }; - - const nullFilters = Object.keys(filtersToDisplay).filter( - (key) => filtersToDisplay[key as keyof IIssueFilterOptions] === null - ); - const areFiltersApplied = - Object.keys(filtersToDisplay).length > 0 && nullFilters.length !== Object.keys(filtersToDisplay).length; - - // const isSubscribedIssuesRoute = router.pathname.includes("subscribed"); - // const isMySubscribedIssues = - // (filters.subscriber && filters.subscriber.length > 0 && router.pathname.includes("my-issues")) ?? false; - - // const disableAddIssueOption = isSubscribedIssuesRoute || isMySubscribedIssues; - - return ( - <> - setCreateIssueModal(false)} - prePopulateData={{ - ...preloadedData, - }} - onSubmit={async () => { - mutateMyIssues(); - }} - /> - setEditIssueModal(false)} - data={issueToEdit} - onSubmit={async () => { - mutateMyIssues(); - }} - /> - {issueToDelete && ( - setDeleteIssueModal(false)} - isOpen={deleteIssueModal} - data={issueToDelete} - onSubmit={async () => { - mutateMyIssues(); - }} - /> - )} - {areFiltersApplied && ( - <> -
- - setFilters({ - labels: null, - priority: null, - state_group: null, - start_date: null, - target_date: null, - }) - } - /> -
- {
} - - )} - {/* , - text: "New Issue", - onClick: () => { - const e = new KeyboardEvent("keydown", { - key: "c", - }); - document.dispatchEvent(e); - }, - }, - }} - handleOnDragEnd={handleOnDragEnd} - handleIssueAction={handleIssueAction} - openIssuesListModal={openIssuesListModal ? openIssuesListModal : null} - removeIssue={null} - disableAddIssueOption={disableAddIssueOption} - trashBox={trashBox} - setTrashBox={setTrashBox} - viewProps={{ - displayFilters, - groupedIssues, - isEmpty, - mutateIssues: mutateMyIssues, - params, - properties, - }} - /> */} - - ); -}; diff --git a/web/components/onboarding/workspace.tsx b/web/components/onboarding/workspace.tsx index b0de35d78..8cafdde43 100644 --- a/web/components/onboarding/workspace.tsx +++ b/web/components/onboarding/workspace.tsx @@ -48,7 +48,6 @@ export const Workspace: React.FC = ({ finishOnboarding, stepChange, updat onSubmit={completeStep} defaultValues={defaultValues} setDefaultValues={setDefaultValues} - user={user} primaryButtonText={{ loading: "Creating...", default: "Continue", diff --git a/web/components/profile/index.ts b/web/components/profile/index.ts index 247a10dc6..2573da1b2 100644 --- a/web/components/profile/index.ts +++ b/web/components/profile/index.ts @@ -1,6 +1,5 @@ export * from "./overview"; export * from "./navbar"; -export * from "./profile-issues-view-options"; export * from "./profile-issues-view"; export * from "./sidebar"; diff --git a/web/components/profile/profile-issues-view-options.tsx b/web/components/profile/profile-issues-view-options.tsx deleted file mode 100644 index 95caa002d..000000000 --- a/web/components/profile/profile-issues-view-options.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import React from "react"; - -import { useRouter } from "next/router"; - -// headless ui -import { Popover, Transition } from "@headlessui/react"; -// hooks -import useProfileIssues from "hooks/use-profile-issues"; -import useEstimateOption from "hooks/use-estimate-option"; -// components -import { MyIssuesSelectFilters } from "components/issues"; -// ui -import { CustomMenu, ToggleSwitch, Tooltip } from "@plane/ui"; -// icons -import { ChevronDown, Kanban, List } from "lucide-react"; -// helpers -import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; -import { checkIfArraysHaveSameElements } from "helpers/array.helper"; -// types -import { Properties, TIssueLayouts } from "types"; -// constants -import { ISSUE_GROUP_BY_OPTIONS, ISSUE_ORDER_BY_OPTIONS, ISSUE_FILTER_OPTIONS } from "constants/issue"; - -const issueViewOptions: { type: TIssueLayouts; Icon: any }[] = [ - { - type: "list", - Icon: List, - }, - { - type: "kanban", - Icon: Kanban, - }, -]; - -export const ProfileIssuesViewOptions: React.FC = () => { - const router = useRouter(); - const { workspaceSlug, userId } = router.query; - - const { displayFilters, setDisplayFilters, filters, displayProperties, setProperties, setFilters } = useProfileIssues( - workspaceSlug?.toString(), - userId?.toString() - ); - - const { isEstimateActive } = useEstimateOption(); - - if ( - !router.pathname.includes("assigned") && - !router.pathname.includes("created") && - !router.pathname.includes("subscribed") - ) - return null; - - return ( -
-
- {issueViewOptions.map((option) => ( - {replaceUnderscoreIfSnakeCase(option.type)} Layout} - position="bottom" - > - - - ))} -
- { - const key = option.key as keyof typeof filters; - - if (key === "start_date" || key === "target_date") { - const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value); - - setFilters({ - [key]: valueExists ? null : option.value, - }); - } else { - const valueExists = filters[key]?.includes(option.value); - - if (valueExists) - setFilters({ - [option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value), - }); - else - setFilters({ - [option.key]: [...((filters[key] ?? []) as any[]), option.value], - }); - } - }} - direction="left" - height="rg" - /> - - {({ open }) => ( - <> - - Display - - - - -
-
- {displayFilters?.layout !== "calendar" && displayFilters?.layout !== "spreadsheet" && ( - <> -
-

Group by

-
- option.key === displayFilters?.group_by) - ?.title ?? "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {ISSUE_GROUP_BY_OPTIONS.map((option) => { - if (displayFilters?.layout === "kanban" && option.key === null) return null; - if (option.key === "state" || option.key === "created_by" || option.key === "assignees") - return null; - - return ( - setDisplayFilters({ group_by: option.key })} - > - {option.title} - - ); - })} - -
-
-
-

Order by

-
- option.key === displayFilters?.order_by) - ?.title ?? "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {ISSUE_ORDER_BY_OPTIONS.map((option) => { - if (displayFilters?.group_by === "priority" && option.key === "priority") return null; - if (option.key === "sort_order") return null; - - return ( - { - setDisplayFilters({ order_by: option.key }); - }} - > - {option.title} - - ); - })} - -
-
- - )} -
-

Issue type

-
- - {ISSUE_FILTER_OPTIONS.find((option) => option.key === displayFilters?.type)?.title ?? - "Select"} - - } - className="!w-full" - buttonClassName="w-full" - > - {ISSUE_FILTER_OPTIONS.map((option) => ( - - setDisplayFilters({ - type: option.key, - }) - } - > - {option.title} - - ))} - -
-
- - {displayFilters?.layout !== "calendar" && displayFilters?.layout !== "spreadsheet" && ( - <> -
-

Show empty states

-
- - setDisplayFilters({ - show_empty_groups: !displayFilters?.show_empty_groups, - }) - } - /> -
-
- - )} -
- -
-

Display Properties

-
- {displayProperties && - Object.keys(displayProperties).map((key) => { - if (key === "estimate" && !isEstimateActive) return null; - - if ( - displayFilters?.layout === "spreadsheet" && - (key === "attachment_count" || key === "link" || key === "sub_issue_count") - ) - return null; - - if ( - displayFilters?.layout !== "spreadsheet" && - (key === "created_on" || key === "updated_on") - ) - return null; - - return ( - - ); - })} -
-
-
-
-
- - )} -
-
- ); -}; diff --git a/web/components/workspace/confirm-workspace-member-remove.tsx b/web/components/workspace/confirm-workspace-member-remove.tsx index 2cf3c27af..4b057f1de 100644 --- a/web/components/workspace/confirm-workspace-member-remove.tsx +++ b/web/components/workspace/confirm-workspace-member-remove.tsx @@ -1,29 +1,37 @@ import React, { useState } from "react"; -// headless ui +import { observer } from "mobx-react-lite"; import { Dialog, Transition } from "@headlessui/react"; -// icons import { AlertTriangle } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // ui import { Button } from "@plane/ui"; type Props = { isOpen: boolean; onClose: () => void; - handleDelete: () => void; + onSubmit: () => Promise; data?: any; }; -const ConfirmWorkspaceMemberRemove: React.FC = ({ isOpen, onClose, data, handleDelete }) => { - const [isDeleteLoading, setIsDeleteLoading] = useState(false); +export const ConfirmWorkspaceMemberRemove: React.FC = observer((props) => { + const { isOpen, onClose, data, onSubmit } = props; + + const [isRemoving, setIsRemoving] = useState(false); + + const { user: userStore } = useMobxStore(); + const user = userStore.currentUser; const handleClose = () => { onClose(); - setIsDeleteLoading(false); + setIsRemoving(false); }; const handleDeletion = async () => { - setIsDeleteLoading(true); - handleDelete(); + setIsRemoving(true); + + await onSubmit(); + handleClose(); }; @@ -61,14 +69,21 @@ const ConfirmWorkspaceMemberRemove: React.FC = ({ isOpen, onClose, data,
- Remove {data?.display_name}? + {user?.id === data?.memberId ? "Leave workspace?" : `Remove ${data?.display_name}?`}
-

- Are you sure you want to remove member-{" "} - {data?.display_name}? They will no longer have access to - this workspace. This action cannot be undone. -

+ {user?.id === data?.memberId ? ( +

+ Are you sure you want to leave the workspace? You will no longer have access to this + workspace. This action cannot be undone. +

+ ) : ( +

+ Are you sure you want to remove member-{" "} + {data?.display_name}? They will no longer have access to + this workspace. This action cannot be undone. +

+ )}
@@ -77,8 +92,8 @@ const ConfirmWorkspaceMemberRemove: React.FC = ({ isOpen, onClose, data, - @@ -88,6 +103,4 @@ const ConfirmWorkspaceMemberRemove: React.FC = ({ isOpen, onClose, data, ); -}; - -export default ConfirmWorkspaceMemberRemove; +}); diff --git a/web/components/workspace/create-workspace-form.tsx b/web/components/workspace/create-workspace-form.tsx index 658e0cedb..584292422 100644 --- a/web/components/workspace/create-workspace-form.tsx +++ b/web/components/workspace/create-workspace-form.tsx @@ -1,7 +1,10 @@ import { Dispatch, SetStateAction, useEffect, useState, FC } from "react"; -import { mutate } from "swr"; import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; +import { mutate } from "swr"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // services import { WorkspaceService } from "services/workspace.service"; // hooks @@ -9,7 +12,7 @@ import useToast from "hooks/use-toast"; // ui import { Button, CustomSelect, Input } from "@plane/ui"; // types -import { IUser, IWorkspace } from "types"; +import { IWorkspace } from "types"; // fetch-keys import { USER_WORKSPACES } from "constants/fetch-keys"; // constants @@ -23,7 +26,6 @@ type Props = { organization_size: string; }; setDefaultValues: Dispatch>; - user: IUser | undefined; secondaryButton?: React.ReactNode; primaryButtonText?: { loading: string; @@ -49,23 +51,27 @@ const restrictedUrls = [ const workspaceService = new WorkspaceService(); -export const CreateWorkspaceForm: FC = ({ - onSubmit, - defaultValues, - setDefaultValues, - user, - secondaryButton, - primaryButtonText = { - loading: "Creating...", - default: "Create Workspace", - }, -}) => { +export const CreateWorkspaceForm: FC = observer((props) => { + const { + onSubmit, + defaultValues, + setDefaultValues, + secondaryButton, + primaryButtonText = { + loading: "Creating...", + default: "Create Workspace", + }, + } = props; + const [slugError, setSlugError] = useState(false); const [invalidSlug, setInvalidSlug] = useState(false); - const { setToastAlert } = useToast(); const router = useRouter(); + const { workspace: workspaceStore } = useMobxStore(); + + const { setToastAlert } = useToast(); + const { handleSubmit, control, @@ -81,8 +87,8 @@ export const CreateWorkspaceForm: FC = ({ if (res.status === true && !restrictedUrls.includes(formData.slug)) { setSlugError(false); - await workspaceService - .createWorkspace(formData, user) + await workspaceStore + .createWorkspace(formData) .then(async (res) => { setToastAlert({ type: "success", @@ -157,7 +163,7 @@ export const CreateWorkspaceForm: FC = ({
-
+
{window && window.location.host}/ = ({ onChange={onChange} label={ ORGANIZATION_SIZE.find((c) => c === value) ?? ( - Select organization size + Select organization size ) } + buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" input width="w-full" > @@ -232,4 +239,4 @@ export const CreateWorkspaceForm: FC = ({
); -}; +}); diff --git a/web/components/workspace/delete-workspace-modal.tsx b/web/components/workspace/delete-workspace-modal.tsx index 14ab211ce..4dcbebafb 100644 --- a/web/components/workspace/delete-workspace-modal.tsx +++ b/web/components/workspace/delete-workspace-modal.tsx @@ -1,31 +1,22 @@ import React from "react"; - import { useRouter } from "next/router"; - -import { mutate } from "swr"; - -// react-hook-form +import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; -// headless ui import { Dialog, Transition } from "@headlessui/react"; -// services -import { WorkspaceService } from "services/workspace.service"; +import { AlertTriangle } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useToast from "hooks/use-toast"; -// icons -import { AlertTriangle } from "lucide-react"; // ui import { Button, Input } from "@plane/ui"; // types -import type { IUser, IWorkspace } from "types"; -// fetch-keys -import { USER_WORKSPACES } from "constants/fetch-keys"; +import type { IWorkspace } from "types"; type Props = { isOpen: boolean; data: IWorkspace | null; onClose: () => void; - user: IUser | undefined; }; const defaultValues = { @@ -33,12 +24,13 @@ const defaultValues = { confirmDelete: "", }; -// services -const workspaceService = new WorkspaceService(); +export const DeleteWorkspaceModal: React.FC = observer((props) => { + const { isOpen, data, onClose } = props; -export const DeleteWorkspaceModal: React.FC = ({ isOpen, data, onClose, user }) => { const router = useRouter(); + const { workspace: workspaceStore } = useMobxStore(); + const { setToastAlert } = useToast(); const { @@ -63,15 +55,13 @@ export const DeleteWorkspaceModal: React.FC = ({ isOpen, data, onClose, u const onSubmit = async () => { if (!data || !canDelete) return; - await workspaceService - .deleteWorkspace(data.slug, user) + await workspaceStore + .deleteWorkspace(data.slug) .then(() => { handleClose(); router.push("/"); - mutate(USER_WORKSPACES, (prevData) => prevData?.filter((workspace) => workspace.id !== data.id)); - setToastAlert({ type: "success", title: "Success!", @@ -196,4 +186,4 @@ export const DeleteWorkspaceModal: React.FC = ({ isOpen, data, onClose, u ); -}; +}); diff --git a/web/components/workspace/index.ts b/web/components/workspace/index.ts index 9fe2934b0..332ea6c60 100644 --- a/web/components/workspace/index.ts +++ b/web/components/workspace/index.ts @@ -1,13 +1,17 @@ +export * from "./settings"; export * from "./views"; export * from "./activity-graph"; export * from "./completed-issues-graph"; +export * from "./confirm-workspace-member-remove"; export * from "./create-workspace-form"; export * from "./delete-workspace-modal"; export * from "./help-section"; export * from "./issues-list"; export * from "./issues-pie-chart"; export * from "./issues-stats"; +export * from "./member-select"; +export * from "./send-workspace-invitation-modal"; export * from "./sidebar-dropdown"; export * from "./sidebar-menu"; export * from "./sidebar-quick-action"; -export * from "./member-select"; +export * from "./single-invitation"; diff --git a/web/components/workspace/send-workspace-invitation-modal.tsx b/web/components/workspace/send-workspace-invitation-modal.tsx index 1fa4f002a..abeacd43d 100644 --- a/web/components/workspace/send-workspace-invitation-modal.tsx +++ b/web/components/workspace/send-workspace-invitation-modal.tsx @@ -14,14 +14,15 @@ import { Plus, X } from "lucide-react"; import { IUser } from "types"; // constants import { ROLE } from "constants/workspace"; +// fetch-keys import { WORKSPACE_INVITATIONS } from "constants/fetch-keys"; type Props = { isOpen: boolean; - setIsOpen: React.Dispatch>; - workspace_slug: string; + onClose: () => void; + workspaceSlug: string; user: IUser | undefined; - onSuccess: () => void; + onSuccess?: () => Promise; }; type EmailRole = { @@ -44,8 +45,9 @@ const defaultValues: FormValues = { const workspaceService = new WorkspaceService(); -const SendWorkspaceInvitationModal: React.FC = (props) => { - const { isOpen, setIsOpen, workspace_slug, user, onSuccess } = props; +export const SendWorkspaceInvitationModal: React.FC = (props) => { + const { isOpen, onClose, workspaceSlug, user, onSuccess } = props; + const { control, reset, @@ -61,42 +63,38 @@ const SendWorkspaceInvitationModal: React.FC = (props) => { const { setToastAlert } = useToast(); const handleClose = () => { - setIsOpen(false); + onClose(); + const timeout = setTimeout(() => { reset(defaultValues); clearTimeout(timeout); - }, 500); + }, 350); }; const onSubmit = async (formData: FormValues) => { - if (!workspace_slug) return; - - const payload = { ...formData }; + if (!workspaceSlug) return; await workspaceService - .inviteWorkspace(workspace_slug, payload, user) + .inviteWorkspace(workspaceSlug, formData, user) .then(async () => { - setIsOpen(false); + if (onSuccess) await onSuccess(); + handleClose(); + setToastAlert({ type: "success", title: "Success!", message: "Invitations sent successfully.", }); - onSuccess(); }) - .catch((err) => { + .catch((err) => setToastAlert({ type: "error", title: "Error!", - message: `${err.error}`, - }); - console.log(err); - }) - .finally(() => { - reset(defaultValues); - mutate(WORKSPACE_INVITATIONS); - }); + message: `${err.error ?? "Something went wrong. Please try again."}`, + }) + ) + .finally(() => mutate(WORKSPACE_INVITATIONS)); }; const appendField = () => { @@ -104,9 +102,7 @@ const SendWorkspaceInvitationModal: React.FC = (props) => { }; useEffect(() => { - if (fields.length === 0) { - append([{ email: "", role: 15 }]); - } + if (fields.length === 0) append([{ email: "", role: 15 }]); }, [fields, append]); return ( @@ -249,5 +245,3 @@ const SendWorkspaceInvitationModal: React.FC = (props) => { ); }; - -export default SendWorkspaceInvitationModal; diff --git a/web/components/workspace/settings/index.ts b/web/components/workspace/settings/index.ts new file mode 100644 index 000000000..fb7aa7526 --- /dev/null +++ b/web/components/workspace/settings/index.ts @@ -0,0 +1,3 @@ +export * from "./members-list-item"; +export * from "./members-list"; +export * from "./workspace-details"; diff --git a/web/components/workspace/settings/members-list-item.tsx b/web/components/workspace/settings/members-list-item.tsx new file mode 100644 index 000000000..df2defd5b --- /dev/null +++ b/web/components/workspace/settings/members-list-item.tsx @@ -0,0 +1,202 @@ +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// services +import { WorkspaceService } from "services/workspace.service"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { ConfirmWorkspaceMemberRemove } from "components/workspace"; +// ui +import { CustomSelect, Tooltip } from "@plane/ui"; +// icons +import { ChevronDown, XCircle } from "lucide-react"; +// constants +import { ROLE } from "constants/workspace"; + +type Props = { + member: { + id: string; + memberId: string; + avatar: string; + first_name: string; + last_name: string; + email: string | undefined; + display_name: string; + role: 5 | 10 | 15 | 20; + status: boolean; + member: boolean; + accountCreated: boolean; + }; +}; + +// services +const workspaceService = new WorkspaceService(); + +export const WorkspaceMembersListItem: React.FC = (props) => { + const { member } = props; + + const [removeMemberModal, setRemoveMemberModal] = useState(false); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const { workspace: workspaceStore, user: userStore } = useMobxStore(); + + const user = userStore.workspaceMemberInfo; + const isAdmin = userStore.workspaceMemberInfo?.role === 20; + + const handleRemoveMember = async () => { + if (!workspaceSlug) return; + + if (member.member) + await workspaceStore.removeMember(workspaceSlug.toString(), member.id).catch((err) => { + const error = err?.error; + setToastAlert({ + type: "error", + title: "Error", + message: error || "Something went wrong", + }); + }); + else + await workspaceService + .deleteWorkspaceInvitations(workspaceSlug.toString(), member.id) + .then(() => { + setToastAlert({ + type: "success", + title: "Success", + message: "Member removed successfully", + }); + }) + .catch((err) => { + const error = err?.error; + + setToastAlert({ + type: "error", + title: "Error", + message: error || "Something went wrong", + }); + }); + }; + + if (!user) return null; + + return ( + <> + setRemoveMemberModal(false)} + data={member} + onSubmit={handleRemoveMember} + /> +
+
+ {member.avatar && member.avatar !== "" ? ( + + + {member.display_name + + + ) : ( + + + {(member.email ?? member.display_name ?? "?")[0]} + + + )} +
+ {member.member ? ( + + + {member.first_name} {member.last_name} + + + ) : ( +

{member.display_name || member.email}

+ )} +

{member.email ?? member.display_name}

+
+
+
+ {!member?.status && ( +
+

Pending

+
+ )} + {member?.status && !member?.accountCreated && ( +
+

Account not created

+
+ )} + + + {ROLE[member.role as keyof typeof ROLE]} + + {member.memberId !== user.member && ( + + + + )} +
+ } + value={member.role} + onChange={(value: 5 | 10 | 15 | 20 | undefined) => { + if (!workspaceSlug) return; + + workspaceStore + .updateMember(workspaceSlug.toString(), member.id, { + role: value, + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "An error occurred while updating member role. Please try again.", + }); + }); + }} + disabled={ + member.memberId === user.member || !member.status || (user.role !== 20 && user.role < member.role) + } + placement="bottom-end" + > + {Object.keys(ROLE).map((key) => { + if (user.role !== 20 && user.role < parseInt(key)) return null; + + return ( + + <>{ROLE[parseInt(key) as keyof typeof ROLE]} + + ); + })} + + {isAdmin && ( + + + + )} +
+
+ + ); +}; diff --git a/web/components/workspace/settings/members-list.tsx b/web/components/workspace/settings/members-list.tsx new file mode 100644 index 000000000..02c9bd6e0 --- /dev/null +++ b/web/components/workspace/settings/members-list.tsx @@ -0,0 +1,75 @@ +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// services +import { WorkspaceService } from "services/workspace.service"; +// components +import { WorkspaceMembersListItem } from "components/workspace"; +// ui +import { Loader } from "@plane/ui"; + +const workspaceService = new WorkspaceService(); + +export const WorkspaceMembersList: React.FC = observer(() => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { workspace: workspaceStore, user: userStore } = useMobxStore(); + + const workspaceMembers = workspaceStore.workspaceMembers; + const user = userStore.workspaceMemberInfo; + + const { data: workspaceInvitations } = useSWR( + workspaceSlug ? `WORKSPACE_INVITATIONS_${workspaceSlug.toString()}` : null, + workspaceSlug ? () => workspaceService.workspaceInvitations(workspaceSlug.toString()) : null + ); + + const members = [ + ...(workspaceInvitations?.map((item) => ({ + id: item.id, + memberId: item.id, + avatar: "", + first_name: item.email, + last_name: "", + email: item.email, + display_name: item.email, + role: item.role, + status: item.accepted, + member: false, + accountCreated: item.accepted, + })) || []), + ...(workspaceMembers?.map((item) => ({ + id: item.id, + memberId: item.member?.id, + avatar: item.member?.avatar, + first_name: item.member?.first_name, + last_name: item.member?.last_name, + email: item.member?.email, + display_name: item.member?.display_name, + role: item.role, + status: true, + member: true, + accountCreated: true, + })) || []), + ]; + + if (!workspaceMembers || !workspaceInvitations || !user) + return ( + + + + + + + ); + + return ( +
+ {members.length > 0 + ? members.map((member) => ) + : null} +
+ ); +}); diff --git a/web/components/workspace/settings/workspace-details.tsx b/web/components/workspace/settings/workspace-details.tsx new file mode 100644 index 000000000..517a0f058 --- /dev/null +++ b/web/components/workspace/settings/workspace-details.tsx @@ -0,0 +1,306 @@ +import { useEffect, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Controller, useForm } from "react-hook-form"; +import { Disclosure, Transition } from "@headlessui/react"; +import { ChevronDown, ChevronUp, Pencil } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// services +import { FileService } from "services/file.service"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { DeleteWorkspaceModal } from "components/workspace"; +import { ImageUploadModal } from "components/core"; +// ui +import { Button, CustomSelect, Input, Spinner } from "@plane/ui"; +// types +import { IWorkspace } from "types"; +// constants +import { ORGANIZATION_SIZE } from "constants/workspace"; + +const defaultValues: Partial = { + name: "", + url: "", + organization_size: "2-10", + logo: null, +}; + +// services +const fileService = new FileService(); + +export const WorkspaceDetails: React.FC = observer(() => { + const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState(false); + const [isImageUploading, setIsImageUploading] = useState(false); + const [isImageRemoving, setIsImageRemoving] = useState(false); + const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); + + const { workspace: workspaceStore, user: userStore } = useMobxStore(); + const activeWorkspace = workspaceStore.currentWorkspace; + + const { setToastAlert } = useToast(); + + const { + handleSubmit, + control, + reset, + watch, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { ...defaultValues, ...activeWorkspace }, + }); + + const onSubmit = async (formData: IWorkspace) => { + if (!activeWorkspace) return; + + const payload: Partial = { + logo: formData.logo, + name: formData.name, + organization_size: formData.organization_size, + }; + + await workspaceStore + .updateWorkspace(activeWorkspace.slug, payload) + .then(() => + setToastAlert({ + title: "Success", + type: "success", + message: "Workspace updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + const handleDelete = (url: string | null | undefined) => { + if (!activeWorkspace || !url) return; + + setIsImageRemoving(true); + + fileService.deleteFile(activeWorkspace.id, url).then(() => { + workspaceStore + .updateWorkspace(activeWorkspace.slug, { logo: "" }) + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Workspace picture removed successfully.", + }); + setIsImageUploadModalOpen(false); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "There was some error in deleting your profile picture. Please try again.", + }); + }) + .finally(() => setIsImageRemoving(false)); + }); + }; + + useEffect(() => { + if (activeWorkspace) reset({ ...activeWorkspace }); + }, [activeWorkspace, reset]); + + const isAdmin = userStore.workspaceMemberInfo?.role === 20; + + if (!activeWorkspace) + return ( +
+ +
+ ); + + return ( + <> + setDeleteWorkspaceModal(false)} + data={activeWorkspace} + /> + setIsImageUploadModalOpen(false)} + isRemoving={isImageRemoving} + handleDelete={() => handleDelete(activeWorkspace?.logo)} + onSuccess={(imageUrl) => { + setIsImageUploading(true); + setValue("logo", imageUrl); + setIsImageUploadModalOpen(false); + handleSubmit(onSubmit)().then(() => setIsImageUploading(false)); + }} + value={watch("logo")} + /> +
+
+
+ +
+
+

{watch("name")}

+ {`${ + typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "") + }/${activeWorkspace.slug}`} +
+ +
+
+
+ +
+
+
+

Workspace Name

+ ( + + )} + /> +
+ +
+

Company Size

+ ( + c === value) ?? "Select organization size"} + width="w-full" + buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" + input + disabled={!isAdmin} + > + {ORGANIZATION_SIZE.map((item) => ( + + {item} + + ))} + + )} + /> +
+ +
+

Workspace URL

+ ( + + )} + /> +
+
+ +
+ +
+
+ {isAdmin && ( + + {({ open }) => ( +
+ + Delete Workspace + {/* */} + {open ? : } + + + + +
+ + The danger zone of the workspace delete page is a critical area that requires careful + consideration and attention. When deleting a workspace, all of the data and resources within + that workspace will be permanently removed and cannot be recovered. + +
+ +
+
+
+
+
+ )} +
+ )} +
+ + ); +}); diff --git a/web/contexts/workspace-member.context.tsx b/web/contexts/workspace-member.context.tsx index d312751fe..e08e09399 100644 --- a/web/contexts/workspace-member.context.tsx +++ b/web/contexts/workspace-member.context.tsx @@ -26,6 +26,7 @@ type Props = { // services const workspaceService = new WorkspaceService(); +// TODO: remove this context export const WorkspaceMemberProvider: React.FC = (props) => { const { children } = props; @@ -40,7 +41,7 @@ export const WorkspaceMemberProvider: React.FC = (props) => { const loading = !memberDetails && !error; return ( - + {children} ); diff --git a/web/hooks/my-issues/use-my-issues-filter.tsx b/web/hooks/my-issues/use-my-issues-filter.tsx index f9b54b8ce..06fe5ef5e 100644 --- a/web/hooks/my-issues/use-my-issues-filter.tsx +++ b/web/hooks/my-issues/use-my-issues-filter.tsx @@ -6,7 +6,7 @@ import { WorkspaceService } from "services/workspace.service"; import { IIssueDisplayFilterOptions, IIssueFilterOptions, - IWorkspaceMember, + IWorkspaceMemberMe, IWorkspaceViewProps, Properties, } from "types"; @@ -66,7 +66,7 @@ const useMyIssuesFilters = (workspaceSlug: string | undefined) => { const oldData = { ...myWorkspace }; - mutate( + mutate( WORKSPACE_MEMBERS_ME(workspaceSlug.toString()), (prevData) => { if (!prevData) return; diff --git a/web/layouts/auth-layout/workspace-wrapper.tsx b/web/layouts/auth-layout/workspace-wrapper.tsx index e1ae8e4db..e46515bf7 100644 --- a/web/layouts/auth-layout/workspace-wrapper.tsx +++ b/web/layouts/auth-layout/workspace-wrapper.tsx @@ -12,6 +12,8 @@ export interface IWorkspaceAuthWrapper { children: ReactNode; } +const HIGHER_ROLES = [20, 15]; + export const WorkspaceAuthWrapper: FC = observer((props) => { const { children } = props; // store @@ -22,7 +24,7 @@ export const WorkspaceAuthWrapper: FC = observer((props) // fetching all workspaces useSWR(`USER_WORKSPACES_LIST`, () => workspaceStore.fetchWorkspaces()); // fetching user workspace information - useSWR( + const { data: workspaceMemberInfo } = useSWR( workspaceSlug ? `WORKSPACE_MEMBERS_ME_${workspaceSlug}` : null, workspaceSlug ? () => userStore.fetchUserWorkspaceInfo(workspaceSlug.toString()) : null ); @@ -33,8 +35,12 @@ export const WorkspaceAuthWrapper: FC = observer((props) ); // fetch workspace members useSWR( - workspaceSlug ? `WORKSPACE_MEMBERS_${workspaceSlug}` : null, - workspaceSlug ? () => workspaceStore.fetchWorkspaceMembers(workspaceSlug.toString()) : null + workspaceSlug && workspaceMemberInfo && HIGHER_ROLES.includes(workspaceMemberInfo.role) + ? `WORKSPACE_MEMBERS_${workspaceSlug}` + : null, + workspaceSlug && workspaceMemberInfo && HIGHER_ROLES.includes(workspaceMemberInfo.role) + ? () => workspaceStore.fetchWorkspaceMembers(workspaceSlug.toString()) + : null ); // fetch workspace labels useSWR( diff --git a/web/pages/[workspaceSlug]/settings/index.tsx b/web/pages/[workspaceSlug]/settings/index.tsx index 5ccd4ed9e..580c45942 100644 --- a/web/pages/[workspaceSlug]/settings/index.tsx +++ b/web/pages/[workspaceSlug]/settings/index.tsx @@ -1,362 +1,18 @@ -import React, { useEffect, useState } from "react"; - -import { useRouter } from "next/router"; - -import useSWR, { mutate } from "swr"; - -// react-hook-form -import { Controller, useForm } from "react-hook-form"; -// services -import { WorkspaceService } from "services/workspace.service"; -import { FileService } from "services/file.service"; -// hooks -import useToast from "hooks/use-toast"; -import useUserAuth from "hooks/use-user-auth"; // layouts import { AppLayout } from "layouts/app-layout"; import { WorkspaceSettingLayout } from "layouts/setting-layout"; // components -import { ImageUploadModal } from "components/core"; -import { DeleteWorkspaceModal } from "components/workspace"; import { WorkspaceSettingHeader } from "components/headers"; -// ui -import { Disclosure, Transition } from "@headlessui/react"; -import { Button, CustomSelect, Input, Spinner } from "@plane/ui"; -// icons -import { ChevronDown, ChevronUp, Pencil } from "lucide-react"; +import { WorkspaceDetails } from "components/workspace"; // types -import type { IWorkspace } from "types"; import type { NextPage } from "next"; -// fetch-keys -import { WORKSPACE_DETAILS, USER_WORKSPACES, WORKSPACE_MEMBERS_ME } from "constants/fetch-keys"; -// constants -import { ORGANIZATION_SIZE } from "constants/workspace"; -const defaultValues: Partial = { - name: "", - url: "", - organization_size: "2-10", - logo: null, -}; - -// services -const workspaceService = new WorkspaceService(); -const fileService = new FileService(); - -const WorkspaceSettings: NextPage = () => { - const [isOpen, setIsOpen] = useState(false); - const [isImageUploading, setIsImageUploading] = useState(false); - const [isImageRemoving, setIsImageRemoving] = useState(false); - const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { user } = useUserAuth(); - - const { data: memberDetails } = useSWR( - workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug.toString()) : null, - workspaceSlug ? () => workspaceService.workspaceMemberMe(workspaceSlug.toString()) : null - ); - - const { setToastAlert } = useToast(); - - const { data: activeWorkspace } = useSWR(workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null, () => - workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null - ); - - const { - handleSubmit, - control, - reset, - watch, - setValue, - formState: { errors, isSubmitting }, - } = useForm({ - defaultValues: { ...defaultValues, ...activeWorkspace }, - }); - - useEffect(() => { - if (activeWorkspace) reset({ ...activeWorkspace }); - }, [activeWorkspace, reset]); - - const onSubmit = async (formData: IWorkspace) => { - if (!activeWorkspace) return; - - const payload: Partial = { - logo: formData.logo, - name: formData.name, - organization_size: formData.organization_size, - }; - - await workspaceService - .updateWorkspace(activeWorkspace.slug, payload, user) - .then((res) => { - mutate(USER_WORKSPACES, (prevData) => - prevData?.map((workspace) => (workspace.id === res.id ? res : workspace)) - ); - mutate(WORKSPACE_DETAILS(workspaceSlug as string), (prevData) => { - if (!prevData) return prevData; - - return { - ...prevData, - logo: formData.logo, - }; - }); - setToastAlert({ - title: "Success", - type: "success", - message: "Workspace updated successfully", - }); - }) - .catch((err) => console.error(err)); - }; - - const handleDelete = (url: string | null | undefined) => { - if (!activeWorkspace || !url) return; - - setIsImageRemoving(true); - - fileService.deleteFile(activeWorkspace.id, url).then(() => { - workspaceService - .updateWorkspace(activeWorkspace.slug, { logo: "" }, user) - .then((res) => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Workspace picture removed successfully.", - }); - mutate(USER_WORKSPACES, (prevData) => - prevData?.map((workspace) => (workspace.id === res.id ? res : workspace)) - ); - mutate(WORKSPACE_DETAILS(workspaceSlug as string), (prevData) => { - if (!prevData) return prevData; - - return { - ...prevData, - logo: "", - }; - }); - setIsImageUploadModalOpen(false); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "There was some error in deleting your profile picture. Please try again.", - }); - }) - .finally(() => setIsImageRemoving(false)); - }); - }; - - const isAdmin = memberDetails?.role === 20; - - return ( - }> - - setIsImageUploadModalOpen(false)} - isRemoving={isImageRemoving} - handleDelete={() => handleDelete(activeWorkspace?.logo)} - onSuccess={(imageUrl) => { - setIsImageUploading(true); - setValue("logo", imageUrl); - setIsImageUploadModalOpen(false); - handleSubmit(onSubmit)().then(() => setIsImageUploading(false)); - }} - value={watch("logo")} - /> - { - setIsOpen(false); - }} - data={activeWorkspace ?? null} - user={user} - /> - {activeWorkspace ? ( -
-
-
- -
-
-

{watch("name")}

- {`${ - typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "") - }/${activeWorkspace.slug}`} -
- -
-
-
- -
-
-
-

Workspace Name

- ( - - )} - /> -
- -
-

Company Size

- ( - c === value) ?? "Select organization size"} - width="w-full" - input - disabled={!isAdmin} - > - {ORGANIZATION_SIZE?.map((item) => ( - - {item} - - ))} - - )} - /> -
- -
-

Workspace URL

- ( - - )} - /> -
-
- -
- -
-
- {isAdmin && ( - - {({ open }) => ( -
- - Delete Workspace - {/* */} - {open ? : } - - - - -
- - The danger zone of the workspace delete page is a critical area that requires careful - consideration and attention. When deleting a workspace, all of the data and resources within - that workspace will be permanently removed and cannot be recovered. - -
- -
-
-
-
-
- )} -
- )} -
- ) : ( -
- -
- )} -
-
- ); -}; +const WorkspaceSettings: NextPage = () => ( + }> + + + + +); export default WorkspaceSettings; diff --git a/web/pages/[workspaceSlug]/settings/members.tsx b/web/pages/[workspaceSlug]/settings/members.tsx index 4c055b687..7855f01c8 100644 --- a/web/pages/[workspaceSlug]/settings/members.tsx +++ b/web/pages/[workspaceSlug]/settings/members.tsx @@ -1,309 +1,45 @@ import { useState } from "react"; - -import Link from "next/link"; import { useRouter } from "next/router"; - -import useSWR from "swr"; - -// services -import { WorkspaceService } from "services/workspace.service"; // hooks -import useToast from "hooks/use-toast"; import useUser from "hooks/use-user"; -import useWorkspaceMembers from "hooks/use-workspace-members"; // layouts import { AppLayout } from "layouts/app-layout"; import { WorkspaceSettingLayout } from "layouts/setting-layout"; // components -import ConfirmWorkspaceMemberRemove from "components/workspace/confirm-workspace-member-remove"; -import SendWorkspaceInvitationModal from "components/workspace/send-workspace-invitation-modal"; import { WorkspaceSettingHeader } from "components/headers"; +import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "components/workspace"; // ui -import { Button, CustomMenu, CustomSelect, Loader } from "@plane/ui"; -// icons -import { ChevronDown, X } from "lucide-react"; +import { Button } from "@plane/ui"; // types import type { NextPage } from "next"; -// fetch-keys -import { WORKSPACE_INVITATION_WITH_EMAIL, WORKSPACE_MEMBERS_WITH_EMAIL } from "constants/fetch-keys"; -// constants -import { ROLE } from "constants/workspace"; -// helper - -// services -const workspaceService = new WorkspaceService(); const MembersSettings: NextPage = () => { - const [selectedRemoveMember, setSelectedRemoveMember] = useState(null); - const [selectedInviteRemoveMember, setSelectedInviteRemoveMember] = useState(null); const [inviteModal, setInviteModal] = useState(false); const router = useRouter(); const { workspaceSlug } = router.query; - const { setToastAlert } = useToast(); - const { user } = useUser(); - const { isOwner } = useWorkspaceMembers(workspaceSlug?.toString(), Boolean(workspaceSlug)); - - const { data: workspaceMembers, mutate: mutateMembers } = useSWR( - workspaceSlug ? WORKSPACE_MEMBERS_WITH_EMAIL(workspaceSlug.toString()) : null, - workspaceSlug ? () => workspaceService.workspaceMembersWithEmail(workspaceSlug.toString()) : null - ); - - const { data: workspaceInvitations, mutate: mutateInvitations } = useSWR( - workspaceSlug ? WORKSPACE_INVITATION_WITH_EMAIL(workspaceSlug.toString()) : null, - workspaceSlug ? () => workspaceService.workspaceInvitationsWithEmail(workspaceSlug.toString()) : null - ); - - const members = [ - ...(workspaceInvitations?.map((item) => ({ - id: item.id, - memberId: item.id, - avatar: "", - first_name: item.email, - last_name: "", - email: item.email, - display_name: item.email, - role: item.role, - status: item.accepted, - member: false, - accountCreated: item?.accepted ? false : true, - })) || []), - ...(workspaceMembers?.map((item) => ({ - id: item.id, - memberId: item.member?.id, - avatar: item.member?.avatar, - first_name: item.member?.first_name, - last_name: item.member?.last_name, - email: item.member?.email, - display_name: item.member?.display_name, - role: item.role, - status: true, - member: true, - accountCreated: true, - })) || []), - ]; - - const currentUser = workspaceMembers?.find((item) => item.member?.id === user?.id); - - const handleInviteModalSuccess = () => { - mutateInvitations(); - }; - return ( }> - { - setSelectedRemoveMember(null); - setSelectedInviteRemoveMember(null); - }} - data={ - selectedRemoveMember - ? members.find((item) => item.id === selectedRemoveMember) - : selectedInviteRemoveMember - ? members.find((item) => item.id === selectedInviteRemoveMember) - : null - } - handleDelete={async () => { - if (!workspaceSlug) return; - if (selectedRemoveMember) { - workspaceService - .deleteWorkspaceMember(workspaceSlug as string, selectedRemoveMember) - .catch((err) => { - const error = err?.error; - setToastAlert({ - type: "error", - title: "Error", - message: error || "Something went wrong", - }); - }) - .finally(() => { - mutateMembers((prevData: any) => prevData?.filter((item: any) => item.id !== selectedRemoveMember)); - }); - } - if (selectedInviteRemoveMember) { - mutateInvitations( - (prevData: any) => prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember), - false - ); - workspaceService - .deleteWorkspaceInvitations(workspaceSlug as string, selectedInviteRemoveMember) - .then(() => { - setToastAlert({ - type: "success", - title: "Success", - message: "Member removed successfully", - }); - }) - .catch((err) => { - const error = err?.error; - setToastAlert({ - type: "error", - title: "Error", - message: error || "Something went wrong", - }); - }) - .finally(() => { - mutateInvitations(); - }); - } - setSelectedRemoveMember(null); - setSelectedInviteRemoveMember(null); - }} - /> - + {workspaceSlug && ( + setInviteModal(false)} + workspaceSlug={workspaceSlug.toString()} + user={user} + /> + )}
-
+

Members

- {!workspaceMembers || !workspaceInvitations ? ( - - - - - - - ) : ( -
- {members.length > 0 - ? members.map((member) => ( -
-
- {member.avatar && member.avatar !== "" ? ( - - - {member.display_name - - - ) : member.display_name || member.email ? ( - - - {(member.display_name || member.email)?.charAt(0)} - - - ) : ( -
- ? -
- )} -
- {member.member ? ( - - - - {member.first_name} {member.last_name} - - ({member.display_name}) - - - ) : ( -

{member.display_name || member.email}

- )} - {isOwner &&

{member.email}

} -
-
-
- {!member?.status && ( -
-

Pending

-
- )} - {member?.status && !member?.accountCreated && ( -
-

Account not created

-
- )} - - - {ROLE[member.role as keyof typeof ROLE]} - - {member.memberId !== user?.id && } -
- } - value={member.role} - onChange={(value: 5 | 10 | 15 | 20 | undefined) => { - if (!workspaceSlug) return; - - mutateMembers( - (prevData: any) => - prevData?.map((m: any) => (m.id === member.id ? { ...m, role: value } : m)), - false - ); - - workspaceService - .updateWorkspaceMember(workspaceSlug?.toString(), member.id, { - role: value, - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "An error occurred while updating member role. Please try again.", - }); - }); - }} - disabled={ - member.memberId === currentUser?.member.id || - !member.status || - (currentUser && currentUser.role !== 20 && currentUser.role < member.role) - } - > - {Object.keys(ROLE).map((key) => { - if (currentUser && currentUser.role !== 20 && currentUser.role < parseInt(key)) return null; - - return ( - - <>{ROLE[parseInt(key) as keyof typeof ROLE]} - - ); - })} - - - { - if (member.member) { - setSelectedRemoveMember(member.id); - } else { - setSelectedInviteRemoveMember(member.id); - } - }} - > - - - - {user?.id === member.memberId ? "Leave" : "Remove member"} - - - -
-
- )) - : null} -
- )} +
diff --git a/web/pages/create-workspace/index.tsx b/web/pages/create-workspace/index.tsx index 81048940b..370ee7352 100644 --- a/web/pages/create-workspace/index.tsx +++ b/web/pages/create-workspace/index.tsx @@ -68,7 +68,6 @@ const CreateWorkspace: NextPage = () => { onSubmit={onSubmit} defaultValues={defaultValues} setDefaultValues={setDefaultValues} - user={user} />
diff --git a/web/services/workspace.service.ts b/web/services/workspace.service.ts index 3d97eca9c..6d2f9cdf7 100644 --- a/web/services/workspace.service.ts +++ b/web/services/workspace.service.ts @@ -6,6 +6,7 @@ import { API_BASE_URL } from "helpers/common.helper"; // types import { IWorkspace, + IWorkspaceMemberMe, IWorkspaceMember, IWorkspaceMemberInvitation, ILastActiveWorkspaceDetails, @@ -139,15 +140,7 @@ export class WorkspaceService extends APIService { }); } - async workspaceMembersWithEmail(workspaceSlug: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/members/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async workspaceMemberMe(workspaceSlug: string): Promise { + async workspaceMemberMe(workspaceSlug: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`) .then((response) => response?.data) .catch((error) => { @@ -191,14 +184,6 @@ export class WorkspaceService extends APIService { }); } - async workspaceInvitationsWithEmail(workspaceSlug: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/invitations/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - async getWorkspaceInvitation(invitationId: string): Promise { return this.get(`/api/users/me/invitations/${invitationId}/`, { headers: {} }) .then((response) => response?.data) diff --git a/web/store/user.store.ts b/web/store/user.store.ts index 42024a775..cbb861288 100644 --- a/web/store/user.store.ts +++ b/web/store/user.store.ts @@ -6,7 +6,7 @@ import { UserService } from "services/user.service"; import { WorkspaceService } from "services/workspace.service"; // interfaces import { IUser, IUserSettings } from "types/users"; -import { IWorkspaceMember, IProjectMember } from "types"; +import { IWorkspaceMemberMe, IProjectMember } from "types"; export interface IUserStore { loader: boolean; @@ -17,7 +17,7 @@ export interface IUserStore { dashboardInfo: any; - workspaceMemberInfo: any; + workspaceMemberInfo: IWorkspaceMemberMe | null; hasPermissionToWorkspace: boolean | null; projectMemberInfo: IProjectMember | null; @@ -27,7 +27,7 @@ export interface IUserStore { fetchCurrentUser: () => Promise; fetchCurrentUserSettings: () => Promise; - fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise; + fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise; fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise; fetchUserDashboardInfo: (workspaceSlug: string, month: number) => Promise; @@ -45,7 +45,7 @@ class UserStore implements IUserStore { dashboardInfo: any = null; - workspaceMemberInfo: any = null; + workspaceMemberInfo: IWorkspaceMemberMe | null = null; hasPermissionToWorkspace: boolean | null = null; projectMemberInfo: IProjectMember | null = null; diff --git a/web/store/workspace/workspace.store.ts b/web/store/workspace/workspace.store.ts index 9b27d255c..28e901928 100644 --- a/web/store/workspace/workspace.store.ts +++ b/web/store/workspace/workspace.store.ts @@ -26,6 +26,15 @@ export interface IWorkspaceStore { fetchWorkspaceLabels: (workspaceSlug: string) => Promise; fetchWorkspaceMembers: (workspaceSlug: string) => Promise; + // workspace write operations + createWorkspace: (data: Partial) => Promise; + updateWorkspace: (workspaceSlug: string, data: Partial) => Promise; + deleteWorkspace: (workspaceSlug: string) => Promise; + + // members write operations + updateMember: (workspaceSlug: string, memberId: string, data: Partial) => Promise; + removeMember: (workspaceSlug: string, memberId: string) => Promise; + // computed currentWorkspace: IWorkspace | null; workspaceLabels: IIssueLabels[] | null; @@ -72,6 +81,15 @@ export class WorkspaceStore implements IWorkspaceStore { fetchWorkspaceLabels: action, fetchWorkspaceMembers: action, + // workspace write operations + createWorkspace: action, + updateWorkspace: action, + deleteWorkspace: action, + + // members write operations + updateMember: action, + removeMember: action, + // computed currentWorkspace: computed, workspaceLabels: computed, @@ -189,7 +207,6 @@ export class WorkspaceStore implements IWorkspaceStore { * fetch workspace members using workspace slug * @param workspaceSlug */ - fetchWorkspaceMembers = async (workspaceSlug: string) => { try { runInAction(() => { @@ -214,4 +231,174 @@ export class WorkspaceStore implements IWorkspaceStore { }); } }; + + /** + * create workspace using the workspace data + * @param data + */ + createWorkspace = async (data: Partial) => { + try { + runInAction(() => { + this.loader = true; + this.error = null; + }); + + const user = this.rootStore.user.currentUser ?? undefined; + + const response = await this.workspaceService.createWorkspace(data, user); + + runInAction(() => { + this.loader = false; + this.error = null; + this.workspaces = [...this.workspaces, response]; + }); + + return response; + } catch (error) { + runInAction(() => { + this.loader = false; + this.error = error; + }); + + throw error; + } + }; + + /** + * update workspace using the workspace slug and new workspace data + * @param workspaceSlug + * @param data + */ + updateWorkspace = async (workspaceSlug: string, data: Partial) => { + const newWorkspaces = this.workspaces?.map((w) => (w.slug === workspaceSlug ? { ...w, ...data } : w)); + + try { + runInAction(() => { + this.loader = true; + this.error = null; + }); + + const user = this.rootStore.user.currentUser ?? undefined; + + const response = await this.workspaceService.updateWorkspace(workspaceSlug, data, user); + + runInAction(() => { + this.loader = false; + this.error = null; + this.workspaces = newWorkspaces; + }); + + return response; + } catch (error) { + runInAction(() => { + this.loader = false; + this.error = error; + }); + + throw error; + } + }; + + /** + * delete workspace using the workspace slug + * @param workspaceSlug + */ + deleteWorkspace = async (workspaceSlug: string) => { + const newWorkspaces = this.workspaces?.filter((w) => w.slug !== workspaceSlug); + + try { + runInAction(() => { + this.loader = true; + this.error = null; + }); + + const user = this.rootStore.user.currentUser ?? undefined; + + await this.workspaceService.deleteWorkspace(workspaceSlug, user); + + runInAction(() => { + this.loader = false; + this.error = null; + this.workspaces = newWorkspaces; + }); + } catch (error) { + runInAction(() => { + this.loader = false; + this.error = error; + }); + + throw error; + } + }; + + /** + * update workspace member using workspace slug and member id and data + * @param workspaceSlug + * @param memberId + * @param data + */ + updateMember = async (workspaceSlug: string, memberId: string, data: Partial) => { + const members = this.members?.[workspaceSlug]; + members?.map((m) => (m.id === memberId ? { ...m, ...data } : m)); + + try { + runInAction(() => { + this.loader = true; + this.error = null; + }); + + await this.workspaceService.updateWorkspaceMember(workspaceSlug, memberId, data); + + runInAction(() => { + this.loader = false; + this.error = null; + this.members = { + ...this.members, + [workspaceSlug]: members, + }; + }); + } catch (error) { + runInAction(() => { + this.loader = false; + this.error = error; + }); + + throw error; + } + }; + + /** + * remove workspace member using workspace slug and member id + * @param workspaceSlug + * @param memberId + */ + removeMember = async (workspaceSlug: string, memberId: string) => { + const members = this.members?.[workspaceSlug]; + members?.filter((m) => m.id !== memberId); + + try { + runInAction(() => { + this.loader = true; + this.error = null; + }); + + await this.workspaceService.deleteWorkspaceMember(workspaceSlug, memberId); + + runInAction(() => { + this.loader = false; + this.error = null; + this.members = { + ...this.members, + [workspaceSlug]: members, + }; + }); + } catch (error) { + runInAction(() => { + this.loader = false; + this.error = error; + }); + + throw error; + } + }; } diff --git a/web/store/workspace/workspace_filters.store.ts b/web/store/workspace/workspace_filters.store.ts index d33a56057..048ee6b07 100644 --- a/web/store/workspace/workspace_filters.store.ts +++ b/web/store/workspace/workspace_filters.store.ts @@ -9,7 +9,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, - IWorkspaceMember, + IWorkspaceMemberMe, IWorkspaceViewProps, TIssueParams, } from "types"; @@ -25,7 +25,7 @@ export interface IWorkspaceFilterStore { workspaceDisplayProperties: IIssueDisplayProperties; // actions - fetchUserWorkspaceFilters: (workspaceSlug: string) => Promise; + fetchUserWorkspaceFilters: (workspaceSlug: string) => Promise; updateWorkspaceFilters: (workspaceSlug: string, filterToUpdate: Partial) => Promise; // computed diff --git a/web/types/users.d.ts b/web/types/users.d.ts index 6ffeedf8b..252f4fe80 100644 --- a/web/types/users.d.ts +++ b/web/types/users.d.ts @@ -56,6 +56,7 @@ export interface IUserLite { avatar: string; created_at: Date; display_name: string; + email?: string; first_name: string; readonly id: string; is_bot: boolean; diff --git a/web/types/workspace.d.ts b/web/types/workspace.d.ts index 66e1e1273..004c1ad58 100644 --- a/web/types/workspace.d.ts +++ b/web/types/workspace.d.ts @@ -1,4 +1,4 @@ -import type { IProjectMember, IUser, IUserMemberLite, IWorkspaceViewProps } from "types"; +import type { IProjectMember, IUser, IUserLite, IUserMemberLite, IWorkspaceViewProps } from "types"; export interface IWorkspace { readonly id: string; @@ -56,16 +56,29 @@ export type Properties = { }; export interface IWorkspaceMember { - readonly id: string; - workspace: IWorkspace; - member: IUserMemberLite; - role: 5 | 10 | 15 | 20; company_role: string | null; - view_props: IWorkspaceViewProps; created_at: Date; - updated_at: Date; created_by: string; + id: string; + member: IUserLite; + role: 5 | 10 | 15 | 20; + updated_at: Date; updated_by: string; + workspace: IWorkspaceLite; +} + +export interface IWorkspaceMemberMe { + company_role: string | null; + created_at: Date; + created_by: string; + default_props: IWorkspaceViewProps; + id: string; + member: string; + role: 5 | 10 | 15 | 20; + updated_at: Date; + updated_by: string; + view_props: IWorkspaceViewProps; + workspace: string; } export interface ILastActiveWorkspaceDetails {