dev: implemented MobX in workspace settings and create workspace form (#2561)

* dev: implement mobx store for workspace settings

* chore: workspace general settings mobx integration

* chore: workspace members settings mobx integration
This commit is contained in:
Aaryan Khandelwal 2023-10-30 20:38:50 +05:30 committed by GitHub
parent 050406b8a4
commit dcf81e28e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 934 additions and 1658 deletions

View File

@ -31,7 +31,7 @@ export const JiraImportUsers: FC = () => {
const { data: members } = useSWR( const { data: members } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_WITH_EMAIL(workspaceSlug?.toString() ?? "") : null, 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) => ({ const options = members?.map((member) => ({

View File

@ -1,6 +1,5 @@
export * from "./attachment"; export * from "./attachment";
export * from "./comment"; export * from "./comment";
export * from "./my-issues";
export * from "./sidebar-select"; export * from "./sidebar-select";
export * from "./view-select"; export * from "./view-select";
export * from "./activity"; export * from "./activity";

View File

@ -1,3 +0,0 @@
export * from "./my-issues-select-filters";
export * from "./my-issues-view-options";
export * from "./my-issues-view";

View File

@ -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<IIssueFilterOptions> | any;
onSelect: (option: any) => void;
direction?: "left" | "right";
height?: "sm" | "md" | "rg" | "lg";
};
const issueLabelService = new IssueLabelService();
export const MyIssuesSelectFilters: React.FC<Props> = ({ 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 && (
<DateFilterModal
title={dateFilterType.title}
field={dateFilterType.type}
filters={filters as IIssueFilterOptions}
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={onSelect}
/>
)} */}
<MultiLevelDropdown
label="Filters"
onSelect={onSelect}
direction={direction}
height={height}
options={[
{
id: "priority",
label: "Priority",
value: PRIORITIES,
hasChildren: true,
children: [
...PRIORITIES.map((priority) => ({
id: priority === null ? "null" : priority,
label: (
<div className="flex items-center gap-2 capitalize">
<PriorityIcon priority={priority} /> {priority ?? "None"}
</div>
),
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: (
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={key as TStateGroups} />
{GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]}
</div>
),
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: (
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
</div>
),
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: (
<button
onClick={() => {
setIsDateFilterModalOpen(true);
setDateFilterType({
title: "Start date",
type: "start_date",
});
}}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
{
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: (
<button
onClick={() => {
setIsDateFilterModalOpen(true);
setDateFilterType({
title: "Due date",
type: "target_date",
});
}}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
]}
/>
</>
);
};

View File

@ -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 (
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-100 duration-300 ${
displayFilters?.layout === option.type
? "bg-custom-sidebar-background-100 shadow-sm"
: "text-custom-sidebar-text-200"
}`}
onClick={() => {
setDisplayFilters({ layout: option.type });
if (option.type === "spreadsheet") router.push(`/${workspaceSlug}/workspace-views/all-issues`);
else router.push(`/${workspaceSlug}/workspace-views`);
}}
>
<option.Icon
sx={{
fontSize: 16,
}}
className={option.type === "spreadsheet" ? "h-4 w-4" : ""}
/>
</button>
</Tooltip>
))}
</div>
{showFilters && (
<MyIssuesSelectFilters
filters={filters}
onSelect={(option) => {
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"
/>
)}
</div>
);
};

View File

@ -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<Props> = () => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [preloadedData] = useState<(Partial<IIssue> & { 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<IIssue | null>(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 (
<>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
onSubmit={async () => {
mutateMyIssues();
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
onSubmit={async () => {
mutateMyIssues();
}}
/>
{issueToDelete && (
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
onSubmit={async () => {
mutateMyIssues();
}}
/>
)}
{areFiltersApplied && (
<>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
<FiltersList
filters={filtersToDisplay}
setFilters={setFilters}
labels={labels}
members={undefined}
states={undefined}
clearAllFilters={() =>
setFilters({
labels: null,
priority: null,
state_group: null,
start_date: null,
target_date: null,
})
}
/>
</div>
{<div className="mt-3 border-t border-custom-border-200" />}
</>
)}
{/* <AllViews
addIssueToDate={addIssueToDate}
addIssueToGroup={addIssueToGroup}
disableUserActions={disableUserActions}
dragDisabled={displayFilters?.group_by !== "priority"}
emptyState={{
title: filters.assignees
? "You don't have any issue assigned to you yet"
: filters.created_by
? "You have not created any issue yet."
: "You have not subscribed to any issue yet.",
description: "Keep track of your work in a single place.",
primaryButton: filters.subscriber
? undefined
: {
icon: <PlusIcon className="h-4 w-4" />,
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,
}}
/> */}
</>
);
};

View File

@ -48,7 +48,6 @@ export const Workspace: React.FC<Props> = ({ finishOnboarding, stepChange, updat
onSubmit={completeStep} onSubmit={completeStep}
defaultValues={defaultValues} defaultValues={defaultValues}
setDefaultValues={setDefaultValues} setDefaultValues={setDefaultValues}
user={user}
primaryButtonText={{ primaryButtonText={{
loading: "Creating...", loading: "Creating...",
default: "Continue", default: "Continue",

View File

@ -1,6 +1,5 @@
export * from "./overview"; export * from "./overview";
export * from "./navbar"; export * from "./navbar";
export * from "./profile-issues-view-options";
export * from "./profile-issues-view"; export * from "./profile-issues-view";
export * from "./sidebar"; export * from "./sidebar";

View File

@ -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 (
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} Layout</span>}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
displayFilters?.layout === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setDisplayFilters({ layout: option.type })}
>
<option.Icon className="h-3.5 w-3.5" />
</button>
</Tooltip>
))}
</div>
<MyIssuesSelectFilters
filters={filters}
onSelect={(option) => {
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"
/>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-custom-border-200 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
open ? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100" : "text-custom-sidebar-text-200"
}`}
>
Display
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
<div className="relative divide-y-2 divide-custom-border-200">
<div className="space-y-4 pb-3 text-xs">
{displayFilters?.layout !== "calendar" && displayFilters?.layout !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4>
<div className="w-28">
<CustomMenu
label={
displayFilters?.group_by === "project"
? "Project"
: ISSUE_GROUP_BY_OPTIONS.find((option) => 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 (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setDisplayFilters({ group_by: option.key })}
>
{option.title}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<div className="w-28">
<CustomMenu
label={
ISSUE_ORDER_BY_OPTIONS.find((option) => 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 (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setDisplayFilters({ order_by: option.key });
}}
>
{option.title}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
</>
)}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<div className="w-28">
<CustomMenu
label={
<span className="truncate">
{ISSUE_FILTER_OPTIONS.find((option) => option.key === displayFilters?.type)?.title ??
"Select"}
</span>
}
className="!w-full"
buttonClassName="w-full"
>
{ISSUE_FILTER_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setDisplayFilters({
type: option.key,
})
}
>
{option.title}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div>
{displayFilters?.layout !== "calendar" && displayFilters?.layout !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4>
<div className="w-28">
<ToggleSwitch
value={displayFilters?.show_empty_groups ?? true}
onChange={() =>
setDisplayFilters({
show_empty_groups: !displayFilters?.show_empty_groups,
})
}
/>
</div>
</div>
</>
)}
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
{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 (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
displayProperties[key as keyof Properties]
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
};

View File

@ -1,29 +1,37 @@
import React, { useState } from "react"; import React, { useState } from "react";
// headless ui import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// icons
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
handleDelete: () => void; onSubmit: () => Promise<void>;
data?: any; data?: any;
}; };
const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, handleDelete }) => { export const ConfirmWorkspaceMemberRemove: React.FC<Props> = observer((props) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const { isOpen, onClose, data, onSubmit } = props;
const [isRemoving, setIsRemoving] = useState(false);
const { user: userStore } = useMobxStore();
const user = userStore.currentUser;
const handleClose = () => { const handleClose = () => {
onClose(); onClose();
setIsDeleteLoading(false); setIsRemoving(false);
}; };
const handleDeletion = async () => { const handleDeletion = async () => {
setIsDeleteLoading(true); setIsRemoving(true);
handleDelete();
await onSubmit();
handleClose(); handleClose();
}; };
@ -61,14 +69,21 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Remove {data?.display_name}? {user?.id === data?.memberId ? "Leave workspace?" : `Remove ${data?.display_name}?`}
</Dialog.Title> </Dialog.Title>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-custom-text-200"> {user?.id === data?.memberId ? (
Are you sure you want to remove member-{" "} <p className="text-sm text-custom-text-200">
<span className="font-bold">{data?.display_name}</span>? They will no longer have access to Are you sure you want to leave the workspace? You will no longer have access to this
this workspace. This action cannot be undone. workspace. This action cannot be undone.
</p> </p>
) : (
<p className="text-sm text-custom-text-200">
Are you sure you want to remove member-{" "}
<span className="font-bold">{data?.display_name}</span>? They will no longer have access to
this workspace. This action cannot be undone.
</p>
)}
</div> </div>
</div> </div>
</div> </div>
@ -77,8 +92,8 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
<Button variant="neutral-primary" onClick={handleClose}> <Button variant="neutral-primary" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button variant="danger" onClick={handleDeletion} loading={isDeleteLoading}> <Button variant="danger" onClick={handleDeletion} loading={isRemoving}>
{isDeleteLoading ? "Removing..." : "Remove"} {isRemoving ? "Removing..." : "Remove"}
</Button> </Button>
</div> </div>
</Dialog.Panel> </Dialog.Panel>
@ -88,6 +103,4 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
); );
}; });
export default ConfirmWorkspaceMemberRemove;

View File

@ -1,7 +1,10 @@
import { Dispatch, SetStateAction, useEffect, useState, FC } from "react"; import { Dispatch, SetStateAction, useEffect, useState, FC } from "react";
import { mutate } from "swr";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { mutate } from "swr";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services // services
import { WorkspaceService } from "services/workspace.service"; import { WorkspaceService } from "services/workspace.service";
// hooks // hooks
@ -9,7 +12,7 @@ import useToast from "hooks/use-toast";
// ui // ui
import { Button, CustomSelect, Input } from "@plane/ui"; import { Button, CustomSelect, Input } from "@plane/ui";
// types // types
import { IUser, IWorkspace } from "types"; import { IWorkspace } from "types";
// fetch-keys // fetch-keys
import { USER_WORKSPACES } from "constants/fetch-keys"; import { USER_WORKSPACES } from "constants/fetch-keys";
// constants // constants
@ -23,7 +26,6 @@ type Props = {
organization_size: string; organization_size: string;
}; };
setDefaultValues: Dispatch<SetStateAction<any>>; setDefaultValues: Dispatch<SetStateAction<any>>;
user: IUser | undefined;
secondaryButton?: React.ReactNode; secondaryButton?: React.ReactNode;
primaryButtonText?: { primaryButtonText?: {
loading: string; loading: string;
@ -49,23 +51,27 @@ const restrictedUrls = [
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
export const CreateWorkspaceForm: FC<Props> = ({ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
onSubmit, const {
defaultValues, onSubmit,
setDefaultValues, defaultValues,
user, setDefaultValues,
secondaryButton, secondaryButton,
primaryButtonText = { primaryButtonText = {
loading: "Creating...", loading: "Creating...",
default: "Create Workspace", default: "Create Workspace",
}, },
}) => { } = props;
const [slugError, setSlugError] = useState(false); const [slugError, setSlugError] = useState(false);
const [invalidSlug, setInvalidSlug] = useState(false); const [invalidSlug, setInvalidSlug] = useState(false);
const { setToastAlert } = useToast();
const router = useRouter(); const router = useRouter();
const { workspace: workspaceStore } = useMobxStore();
const { setToastAlert } = useToast();
const { const {
handleSubmit, handleSubmit,
control, control,
@ -81,8 +87,8 @@ export const CreateWorkspaceForm: FC<Props> = ({
if (res.status === true && !restrictedUrls.includes(formData.slug)) { if (res.status === true && !restrictedUrls.includes(formData.slug)) {
setSlugError(false); setSlugError(false);
await workspaceService await workspaceStore
.createWorkspace(formData, user) .createWorkspace(formData)
.then(async (res) => { .then(async (res) => {
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -157,7 +163,7 @@ export const CreateWorkspaceForm: FC<Props> = ({
</div> </div>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<label htmlFor="workspaceUrl">Workspace URL</label> <label htmlFor="workspaceUrl">Workspace URL</label>
<div className="flex w-full items-center rounded-md border border-custom-border-200 px-3"> <div className="flex w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
<span className="whitespace-nowrap text-sm text-custom-text-200">{window && window.location.host}/</span> <span className="whitespace-nowrap text-sm text-custom-text-200">{window && window.location.host}/</span>
<Controller <Controller
control={control} control={control}
@ -200,9 +206,10 @@ export const CreateWorkspaceForm: FC<Props> = ({
onChange={onChange} onChange={onChange}
label={ label={
ORGANIZATION_SIZE.find((c) => c === value) ?? ( ORGANIZATION_SIZE.find((c) => c === value) ?? (
<span className="text-custom-text-200">Select organization size</span> <span className="text-custom-text-400">Select organization size</span>
) )
} }
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
input input
width="w-full" width="w-full"
> >
@ -232,4 +239,4 @@ export const CreateWorkspaceForm: FC<Props> = ({
</div> </div>
</form> </form>
); );
}; });

View File

@ -1,31 +1,22 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services import { AlertTriangle } from "lucide-react";
import { WorkspaceService } from "services/workspace.service"; // mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// icons
import { AlertTriangle } from "lucide-react";
// ui // ui
import { Button, Input } from "@plane/ui"; import { Button, Input } from "@plane/ui";
// types // types
import type { IUser, IWorkspace } from "types"; import type { IWorkspace } from "types";
// fetch-keys
import { USER_WORKSPACES } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
data: IWorkspace | null; data: IWorkspace | null;
onClose: () => void; onClose: () => void;
user: IUser | undefined;
}; };
const defaultValues = { const defaultValues = {
@ -33,12 +24,13 @@ const defaultValues = {
confirmDelete: "", confirmDelete: "",
}; };
// services export const DeleteWorkspaceModal: React.FC<Props> = observer((props) => {
const workspaceService = new WorkspaceService(); const { isOpen, data, onClose } = props;
export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, user }) => {
const router = useRouter(); const router = useRouter();
const { workspace: workspaceStore } = useMobxStore();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { const {
@ -63,15 +55,13 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
const onSubmit = async () => { const onSubmit = async () => {
if (!data || !canDelete) return; if (!data || !canDelete) return;
await workspaceService await workspaceStore
.deleteWorkspace(data.slug, user) .deleteWorkspace(data.slug)
.then(() => { .then(() => {
handleClose(); handleClose();
router.push("/"); router.push("/");
mutate<IWorkspace[]>(USER_WORKSPACES, (prevData) => prevData?.filter((workspace) => workspace.id !== data.id));
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -196,4 +186,4 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
); );
}; });

View File

@ -1,13 +1,17 @@
export * from "./settings";
export * from "./views"; export * from "./views";
export * from "./activity-graph"; export * from "./activity-graph";
export * from "./completed-issues-graph"; export * from "./completed-issues-graph";
export * from "./confirm-workspace-member-remove";
export * from "./create-workspace-form"; export * from "./create-workspace-form";
export * from "./delete-workspace-modal"; export * from "./delete-workspace-modal";
export * from "./help-section"; export * from "./help-section";
export * from "./issues-list"; export * from "./issues-list";
export * from "./issues-pie-chart"; export * from "./issues-pie-chart";
export * from "./issues-stats"; export * from "./issues-stats";
export * from "./member-select";
export * from "./send-workspace-invitation-modal";
export * from "./sidebar-dropdown"; export * from "./sidebar-dropdown";
export * from "./sidebar-menu"; export * from "./sidebar-menu";
export * from "./sidebar-quick-action"; export * from "./sidebar-quick-action";
export * from "./member-select"; export * from "./single-invitation";

View File

@ -14,14 +14,15 @@ import { Plus, X } from "lucide-react";
import { IUser } from "types"; import { IUser } from "types";
// constants // constants
import { ROLE } from "constants/workspace"; import { ROLE } from "constants/workspace";
// fetch-keys
import { WORKSPACE_INVITATIONS } from "constants/fetch-keys"; import { WORKSPACE_INVITATIONS } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; onClose: () => void;
workspace_slug: string; workspaceSlug: string;
user: IUser | undefined; user: IUser | undefined;
onSuccess: () => void; onSuccess?: () => Promise<void>;
}; };
type EmailRole = { type EmailRole = {
@ -44,8 +45,9 @@ const defaultValues: FormValues = {
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
const SendWorkspaceInvitationModal: React.FC<Props> = (props) => { export const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
const { isOpen, setIsOpen, workspace_slug, user, onSuccess } = props; const { isOpen, onClose, workspaceSlug, user, onSuccess } = props;
const { const {
control, control,
reset, reset,
@ -61,42 +63,38 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const handleClose = () => { const handleClose = () => {
setIsOpen(false); onClose();
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
reset(defaultValues); reset(defaultValues);
clearTimeout(timeout); clearTimeout(timeout);
}, 500); }, 350);
}; };
const onSubmit = async (formData: FormValues) => { const onSubmit = async (formData: FormValues) => {
if (!workspace_slug) return; if (!workspaceSlug) return;
const payload = { ...formData };
await workspaceService await workspaceService
.inviteWorkspace(workspace_slug, payload, user) .inviteWorkspace(workspaceSlug, formData, user)
.then(async () => { .then(async () => {
setIsOpen(false); if (onSuccess) await onSuccess();
handleClose(); handleClose();
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
message: "Invitations sent successfully.", message: "Invitations sent successfully.",
}); });
onSuccess();
}) })
.catch((err) => { .catch((err) =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: `${err.error}`, message: `${err.error ?? "Something went wrong. Please try again."}`,
}); })
console.log(err); )
}) .finally(() => mutate(WORKSPACE_INVITATIONS));
.finally(() => {
reset(defaultValues);
mutate(WORKSPACE_INVITATIONS);
});
}; };
const appendField = () => { const appendField = () => {
@ -104,9 +102,7 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
}; };
useEffect(() => { useEffect(() => {
if (fields.length === 0) { if (fields.length === 0) append([{ email: "", role: 15 }]);
append([{ email: "", role: 15 }]);
}
}, [fields, append]); }, [fields, append]);
return ( return (
@ -249,5 +245,3 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
</Transition.Root> </Transition.Root>
); );
}; };
export default SendWorkspaceInvitationModal;

View File

@ -0,0 +1,3 @@
export * from "./members-list-item";
export * from "./members-list";
export * from "./workspace-details";

View File

@ -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> = (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 (
<>
<ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal}
onClose={() => setRemoveMemberModal(false)}
data={member}
onSubmit={handleRemoveMember}
/>
<div className="group flex items-center justify-between px-3 py-4 hover:bg-custom-background-90">
<div className="flex items-center gap-x-4 gap-y-2">
{member.avatar && member.avatar !== "" ? (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="relative flex h-10 w-10 items-center justify-center rounded p-4 capitalize text-white">
<img
src={member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded"
alt={member.display_name || member.email}
/>
</a>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="relative flex h-10 w-10 items-center justify-center rounded p-4 capitalize bg-gray-700 text-white">
{(member.email ?? member.display_name ?? "?")[0]}
</a>
</Link>
)}
<div>
{member.member ? (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="text-sm font-medium">
{member.first_name} {member.last_name}
</a>
</Link>
) : (
<h4 className="text-sm cursor-default">{member.display_name || member.email}</h4>
)}
<p className="mt-0.5 text-xs text-custom-sidebar-text-300">{member.email ?? member.display_name}</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{!member?.status && (
<div className="flex items-center justify-center rounded bg-yellow-500/20 px-2.5 py-1 text-center text-xs text-yellow-500 font-medium">
<p>Pending</p>
</div>
)}
{member?.status && !member?.accountCreated && (
<div className="flex items-center justify-center rounded bg-blue-500/20 px-2.5 py-1 text-center text-xs text-blue-500 font-medium">
<p>Account not created</p>
</div>
)}
<CustomSelect
customButton={
<div className="flex item-center gap-1 px-2 py-0.5 rounded">
<span
className={`flex items-center text-xs font-medium rounded ${
member.memberId !== user.member ? "" : "text-custom-sidebar-text-400"
}`}
>
{ROLE[member.role as keyof typeof ROLE]}
</span>
{member.memberId !== user.member && (
<span className="grid place-items-center">
<ChevronDown className="h-3 w-3" />
</span>
)}
</div>
}
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 (
<CustomSelect.Option key={key} value={parseInt(key, 10)}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
{isAdmin && (
<Tooltip tooltipContent={member.memberId === user.member ? "Leave workspace" : "Remove member"}>
<button
type="button"
onClick={() => setRemoveMemberModal(true)}
className="opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
>
<XCircle className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={2} />
</button>
</Tooltip>
)}
</div>
</div>
</>
);
};

View File

@ -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 (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
);
return (
<div className="divide-y-[0.5px] divide-custom-border-200">
{members.length > 0
? members.map((member) => <WorkspaceMembersListItem key={member.id} member={member} />)
: null}
</div>
);
});

View File

@ -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<IWorkspace> = {
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<IWorkspace>({
defaultValues: { ...defaultValues, ...activeWorkspace },
});
const onSubmit = async (formData: IWorkspace) => {
if (!activeWorkspace) return;
const payload: Partial<IWorkspace> = {
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 (
<div className="grid place-items-center h-full w-full px-4 sm:px-0">
<Spinner />
</div>
);
return (
<>
<DeleteWorkspaceModal
isOpen={deleteWorkspaceModal}
onClose={() => setDeleteWorkspaceModal(false)}
data={activeWorkspace}
/>
<ImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => 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")}
/>
<div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex gap-5 items-center pb-7 border-b border-custom-border-200">
<div className="flex flex-col gap-1">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)} disabled={!isAdmin}>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<div className="relative mx-auto flex h-14 w-14">
<img
src={watch("logo")!}
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
alt="Workspace Logo"
/>
</div>
) : (
<div className="relative flex h-14 w-14 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
)}
</button>
</div>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold leading-6">{watch("name")}</h3>
<span className="text-sm tracking-tight">{`${
typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}</span>
<div className="flex item-center gap-2.5">
<button
className="flex items-center gap-1.5 text-xs text-left text-custom-primary-100 font-medium"
onClick={() => setIsImageUploadModalOpen(true)}
disabled={!isAdmin}
>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<>
<Pencil className="h-3 w-3" />
Edit logo
</>
) : (
"Upload logo"
)}
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-8 my-10">
<div className="grid grid-col grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Workspace Name</h4>
<Controller
control={control}
name="name"
rules={{
required: "Name is required",
maxLength: {
value: 80,
message: "Workspace name should not exceed 80 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Name"
className="rounded-md font-medium w-full"
disabled={!isAdmin}
/>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Company Size</h4>
<Controller
name="organization_size"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={ORGANIZATION_SIZE.find((c) => 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) => (
<CustomSelect.Option key={item} value={item}>
{item}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Workspace URL</h4>
<Controller
control={control}
name="url"
render={({ field: { onChange, ref } }) => (
<Input
id="url"
name="url"
type="url"
value={`${
typeof window !== "undefined" &&
window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
className="w-full"
disabled
/>
)}
/>
</div>
</div>
<div className="flex items-center justify-between py-2">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isAdmin}>
{isSubmitting ? "Updating..." : "Update Workspace"}
</Button>
</div>
</div>
{isAdmin && (
<Disclosure as="div" className="border-t border-custom-border-200">
{({ open }) => (
<div className="w-full">
<Disclosure.Button as="button" type="button" className="flex items-center justify-between w-full py-4">
<span className="text-lg tracking-tight">Delete Workspace</span>
{/* <Icon iconName={open ? "expand_less" : "expand_more"} className="!text-2xl" /> */}
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<span className="text-sm tracking-tight">
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.
</span>
<div>
<Button variant="danger" onClick={() => setDeleteWorkspaceModal(true)}>
Delete my workspace
</Button>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
)}
</div>
</>
);
});

View File

@ -26,6 +26,7 @@ type Props = {
// services // services
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
// TODO: remove this context
export const WorkspaceMemberProvider: React.FC<Props> = (props) => { export const WorkspaceMemberProvider: React.FC<Props> = (props) => {
const { children } = props; const { children } = props;
@ -40,7 +41,7 @@ export const WorkspaceMemberProvider: React.FC<Props> = (props) => {
const loading = !memberDetails && !error; const loading = !memberDetails && !error;
return ( return (
<WorkspaceMemberContext.Provider value={{ loading, memberDetails, error }}> <WorkspaceMemberContext.Provider value={{ loading, memberDetails: undefined, error }}>
{children} {children}
</WorkspaceMemberContext.Provider> </WorkspaceMemberContext.Provider>
); );

View File

@ -6,7 +6,7 @@ import { WorkspaceService } from "services/workspace.service";
import { import {
IIssueDisplayFilterOptions, IIssueDisplayFilterOptions,
IIssueFilterOptions, IIssueFilterOptions,
IWorkspaceMember, IWorkspaceMemberMe,
IWorkspaceViewProps, IWorkspaceViewProps,
Properties, Properties,
} from "types"; } from "types";
@ -66,7 +66,7 @@ const useMyIssuesFilters = (workspaceSlug: string | undefined) => {
const oldData = { ...myWorkspace }; const oldData = { ...myWorkspace };
mutate<IWorkspaceMember>( mutate<IWorkspaceMemberMe>(
WORKSPACE_MEMBERS_ME(workspaceSlug.toString()), WORKSPACE_MEMBERS_ME(workspaceSlug.toString()),
(prevData) => { (prevData) => {
if (!prevData) return; if (!prevData) return;

View File

@ -12,6 +12,8 @@ export interface IWorkspaceAuthWrapper {
children: ReactNode; children: ReactNode;
} }
const HIGHER_ROLES = [20, 15];
export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props) => { export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props) => {
const { children } = props; const { children } = props;
// store // store
@ -22,7 +24,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
// fetching all workspaces // fetching all workspaces
useSWR(`USER_WORKSPACES_LIST`, () => workspaceStore.fetchWorkspaces()); useSWR(`USER_WORKSPACES_LIST`, () => workspaceStore.fetchWorkspaces());
// fetching user workspace information // fetching user workspace information
useSWR( const { data: workspaceMemberInfo } = useSWR(
workspaceSlug ? `WORKSPACE_MEMBERS_ME_${workspaceSlug}` : null, workspaceSlug ? `WORKSPACE_MEMBERS_ME_${workspaceSlug}` : null,
workspaceSlug ? () => userStore.fetchUserWorkspaceInfo(workspaceSlug.toString()) : null workspaceSlug ? () => userStore.fetchUserWorkspaceInfo(workspaceSlug.toString()) : null
); );
@ -33,8 +35,12 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
); );
// fetch workspace members // fetch workspace members
useSWR( useSWR(
workspaceSlug ? `WORKSPACE_MEMBERS_${workspaceSlug}` : null, workspaceSlug && workspaceMemberInfo && HIGHER_ROLES.includes(workspaceMemberInfo.role)
workspaceSlug ? () => workspaceStore.fetchWorkspaceMembers(workspaceSlug.toString()) : null ? `WORKSPACE_MEMBERS_${workspaceSlug}`
: null,
workspaceSlug && workspaceMemberInfo && HIGHER_ROLES.includes(workspaceMemberInfo.role)
? () => workspaceStore.fetchWorkspaceMembers(workspaceSlug.toString())
: null
); );
// fetch workspace labels // fetch workspace labels
useSWR( useSWR(

View File

@ -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 // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/setting-layout"; import { WorkspaceSettingLayout } from "layouts/setting-layout";
// components // components
import { ImageUploadModal } from "components/core";
import { DeleteWorkspaceModal } from "components/workspace";
import { WorkspaceSettingHeader } from "components/headers"; import { WorkspaceSettingHeader } from "components/headers";
// ui import { WorkspaceDetails } from "components/workspace";
import { Disclosure, Transition } from "@headlessui/react";
import { Button, CustomSelect, Input, Spinner } from "@plane/ui";
// icons
import { ChevronDown, ChevronUp, Pencil } from "lucide-react";
// types // types
import type { IWorkspace } from "types";
import type { NextPage } from "next"; 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<IWorkspace> = { const WorkspaceSettings: NextPage = () => (
name: "", <AppLayout header={<WorkspaceSettingHeader title="General Settings" />}>
url: "", <WorkspaceSettingLayout>
organization_size: "2-10", <WorkspaceDetails />
logo: null, </WorkspaceSettingLayout>
}; </AppLayout>
);
// 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<IWorkspace>({
defaultValues: { ...defaultValues, ...activeWorkspace },
});
useEffect(() => {
if (activeWorkspace) reset({ ...activeWorkspace });
}, [activeWorkspace, reset]);
const onSubmit = async (formData: IWorkspace) => {
if (!activeWorkspace) return;
const payload: Partial<IWorkspace> = {
logo: formData.logo,
name: formData.name,
organization_size: formData.organization_size,
};
await workspaceService
.updateWorkspace(activeWorkspace.slug, payload, user)
.then((res) => {
mutate<IWorkspace[]>(USER_WORKSPACES, (prevData) =>
prevData?.map((workspace) => (workspace.id === res.id ? res : workspace))
);
mutate<IWorkspace>(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<IWorkspace[]>(USER_WORKSPACES, (prevData) =>
prevData?.map((workspace) => (workspace.id === res.id ? res : workspace))
);
mutate<IWorkspace>(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 (
<AppLayout header={<WorkspaceSettingHeader title="General Settings" />}>
<WorkspaceSettingLayout>
<ImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => 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")}
/>
<DeleteWorkspaceModal
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
}}
data={activeWorkspace ?? null}
user={user}
/>
{activeWorkspace ? (
<div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex gap-5 items-center pb-7 border-b border-custom-border-200">
<div className="flex flex-col gap-1">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)} disabled={!isAdmin}>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<div className="relative mx-auto flex h-14 w-14">
<img
src={watch("logo")!}
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
alt="Workspace Logo"
/>
</div>
) : (
<div className="relative flex h-14 w-14 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
)}
</button>
</div>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold leading-6">{watch("name")}</h3>
<span className="text-sm tracking-tight">{`${
typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}</span>
<div className="flex item-center gap-2.5">
<button
className="flex items-center gap-1.5 text-xs text-left text-custom-primary-100 font-medium"
onClick={() => setIsImageUploadModalOpen(true)}
disabled={!isAdmin}
>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<>
<Pencil className="h-3 w-3" />
Edit logo
</>
) : (
"Upload logo"
)}
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-8 my-10">
<div className="grid grid-col grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Workspace Name</h4>
<Controller
control={control}
name="name"
rules={{
required: "Name is required",
maxLength: {
value: 80,
message: "Workspace name should not exceed 80 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Name"
className="rounded-md font-medium w-full"
disabled={!isAdmin}
/>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Company Size</h4>
<Controller
name="organization_size"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={ORGANIZATION_SIZE.find((c) => c === value) ?? "Select organization size"}
width="w-full"
input
disabled={!isAdmin}
>
{ORGANIZATION_SIZE?.map((item) => (
<CustomSelect.Option key={item} value={item}>
{item}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Workspace URL</h4>
<Controller
control={control}
name="url"
render={({ field: { onChange, ref } }) => (
<Input
id="url"
name="url"
type="url"
value={`${
typeof window !== "undefined" &&
window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
className="w-full"
disabled
/>
)}
/>
</div>
</div>
<div className="flex items-center justify-between py-2">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isAdmin}>
{isSubmitting ? "Updating..." : "Update Workspace"}
</Button>
</div>
</div>
{isAdmin && (
<Disclosure as="div" className="border-t border-custom-border-400">
{({ open }) => (
<div className="w-full">
<Disclosure.Button
as="button"
type="button"
className="flex items-center justify-between w-full py-4"
>
<span className="text-xl tracking-tight">Delete Workspace</span>
{/* <Icon iconName={open ? "expand_less" : "expand_more"} className="!text-2xl" /> */}
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<span className="text-sm tracking-tight">
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.
</span>
<div>
<Button variant="danger" onClick={() => setIsOpen(true)}>
Delete my workspace
</Button>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
)}
</div>
) : (
<div className="flex items-center justify-center h-full w-full px-4 sm:px-0">
<Spinner />
</div>
)}
</WorkspaceSettingLayout>
</AppLayout>
);
};
export default WorkspaceSettings; export default WorkspaceSettings;

View File

@ -1,309 +1,45 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { WorkspaceService } from "services/workspace.service";
// hooks // hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
import useWorkspaceMembers from "hooks/use-workspace-members";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/setting-layout"; import { WorkspaceSettingLayout } from "layouts/setting-layout";
// components // 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 { WorkspaceSettingHeader } from "components/headers";
import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "components/workspace";
// ui // ui
import { Button, CustomMenu, CustomSelect, Loader } from "@plane/ui"; import { Button } from "@plane/ui";
// icons
import { ChevronDown, X } from "lucide-react";
// types // types
import type { NextPage } from "next"; 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 MembersSettings: NextPage = () => {
const [selectedRemoveMember, setSelectedRemoveMember] = useState<string | null>(null);
const [selectedInviteRemoveMember, setSelectedInviteRemoveMember] = useState<string | null>(null);
const [inviteModal, setInviteModal] = useState(false); const [inviteModal, setInviteModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const { user } = useUser(); 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 ( return (
<AppLayout header={<WorkspaceSettingHeader title="Members Settings" />}> <AppLayout header={<WorkspaceSettingHeader title="Members Settings" />}>
<WorkspaceSettingLayout> <WorkspaceSettingLayout>
<ConfirmWorkspaceMemberRemove {workspaceSlug && (
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)} <SendWorkspaceInvitationModal
onClose={() => { isOpen={inviteModal}
setSelectedRemoveMember(null); onClose={() => setInviteModal(false)}
setSelectedInviteRemoveMember(null); workspaceSlug={workspaceSlug.toString()}
}} user={user}
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);
}}
/>
<SendWorkspaceInvitationModal
isOpen={inviteModal}
setIsOpen={setInviteModal}
workspace_slug={workspaceSlug as string}
user={user}
onSuccess={handleInviteModalSuccess}
/>
<section className="pr-9 py-8 w-full overflow-y-auto"> <section className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center justify-between gap-4 py-3.5 border-b border-custom-border-200"> <div className="flex items-center justify-between gap-4 py-3.5 border-b-[0.5px] border-custom-border-200">
<h4 className="text-xl font-medium">Members</h4> <h4 className="text-xl font-medium">Members</h4>
<Button variant="primary" size="sm" onClick={() => setInviteModal(true)}> <Button variant="primary" size="sm" onClick={() => setInviteModal(true)}>
Add Member Add Member
</Button> </Button>
</div> </div>
{!workspaceMembers || !workspaceInvitations ? ( <WorkspaceMembersList />
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<div className="divide-y divide-custom-border-200">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="group flex items-center justify-between px-3.5 py-[18px]">
<div className="flex items-center gap-x-8 gap-y-2">
{member.avatar && member.avatar !== "" ? (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize text-white">
<img
src={member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
alt={member.display_name || member.email}
/>
</a>
</Link>
) : member.display_name || member.email ? (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize bg-gray-700 text-white">
{(member.display_name || member.email)?.charAt(0)}
</a>
</Link>
) : (
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize bg-gray-700 text-white">
?
</div>
)}
<div>
{member.member ? (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="text-sm">
<span>
{member.first_name} {member.last_name}
</span>
<span className="text-custom-text-300 text-sm ml-2">({member.display_name})</span>
</a>
</Link>
) : (
<h4 className="text-sm cursor-default">{member.display_name || member.email}</h4>
)}
{isOwner && <p className="mt-0.5 text-xs text-custom-sidebar-text-300">{member.email}</p>}
</div>
</div>
<div className="flex items-center gap-3 text-xs">
{!member?.status && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
<p>Pending</p>
</div>
)}
{member?.status && !member?.accountCreated && (
<div className="mr-2 flex items-center justify-center rounded-full bg-blue-500/20 px-2 py-1 text-center text-xs text-blue-500">
<p>Account not created</p>
</div>
)}
<CustomSelect
customButton={
<div className="flex item-center gap-1">
<span
className={`flex items-center text-sm font-medium ${
member.memberId !== user?.id ? "" : "text-custom-sidebar-text-400"
}`}
>
{ROLE[member.role as keyof typeof ROLE]}
</span>
{member.memberId !== user?.id && <ChevronDown className="h-4 w-4" />}
</div>
}
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 (
<CustomSelect.Option key={key} value={key}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) {
setSelectedRemoveMember(member.id);
} else {
setSelectedInviteRemoveMember(member.id);
}
}}
>
<span className="flex items-center justify-start gap-2">
<X className="h-4 w-4" />
<span> {user?.id === member.memberId ? "Leave" : "Remove member"}</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))
: null}
</div>
)}
</section> </section>
</WorkspaceSettingLayout> </WorkspaceSettingLayout>
</AppLayout> </AppLayout>

View File

@ -68,7 +68,6 @@ const CreateWorkspace: NextPage = () => {
onSubmit={onSubmit} onSubmit={onSubmit}
defaultValues={defaultValues} defaultValues={defaultValues}
setDefaultValues={setDefaultValues} setDefaultValues={setDefaultValues}
user={user}
/> />
</div> </div>
</div> </div>

View File

@ -6,6 +6,7 @@ import { API_BASE_URL } from "helpers/common.helper";
// types // types
import { import {
IWorkspace, IWorkspace,
IWorkspaceMemberMe,
IWorkspaceMember, IWorkspaceMember,
IWorkspaceMemberInvitation, IWorkspaceMemberInvitation,
ILastActiveWorkspaceDetails, ILastActiveWorkspaceDetails,
@ -139,15 +140,7 @@ export class WorkspaceService extends APIService {
}); });
} }
async workspaceMembersWithEmail(workspaceSlug: string): Promise<IWorkspaceMember[]> { async workspaceMemberMe(workspaceSlug: string): Promise<IWorkspaceMemberMe> {
return this.get(`/api/workspaces/${workspaceSlug}/members/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async workspaceMemberMe(workspaceSlug: string): Promise<IWorkspaceMember> {
return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`) return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
@ -191,14 +184,6 @@ export class WorkspaceService extends APIService {
}); });
} }
async workspaceInvitationsWithEmail(workspaceSlug: string): Promise<IWorkspaceMemberInvitation[]> {
return this.get(`/api/workspaces/${workspaceSlug}/invitations/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getWorkspaceInvitation(invitationId: string): Promise<IWorkspaceMemberInvitation> { async getWorkspaceInvitation(invitationId: string): Promise<IWorkspaceMemberInvitation> {
return this.get(`/api/users/me/invitations/${invitationId}/`, { headers: {} }) return this.get(`/api/users/me/invitations/${invitationId}/`, { headers: {} })
.then((response) => response?.data) .then((response) => response?.data)

View File

@ -6,7 +6,7 @@ import { UserService } from "services/user.service";
import { WorkspaceService } from "services/workspace.service"; import { WorkspaceService } from "services/workspace.service";
// interfaces // interfaces
import { IUser, IUserSettings } from "types/users"; import { IUser, IUserSettings } from "types/users";
import { IWorkspaceMember, IProjectMember } from "types"; import { IWorkspaceMemberMe, IProjectMember } from "types";
export interface IUserStore { export interface IUserStore {
loader: boolean; loader: boolean;
@ -17,7 +17,7 @@ export interface IUserStore {
dashboardInfo: any; dashboardInfo: any;
workspaceMemberInfo: any; workspaceMemberInfo: IWorkspaceMemberMe | null;
hasPermissionToWorkspace: boolean | null; hasPermissionToWorkspace: boolean | null;
projectMemberInfo: IProjectMember | null; projectMemberInfo: IProjectMember | null;
@ -27,7 +27,7 @@ export interface IUserStore {
fetchCurrentUser: () => Promise<IUser>; fetchCurrentUser: () => Promise<IUser>;
fetchCurrentUserSettings: () => Promise<IUserSettings>; fetchCurrentUserSettings: () => Promise<IUserSettings>;
fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMember>; fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>;
fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<IProjectMember>; fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<IProjectMember>;
fetchUserDashboardInfo: (workspaceSlug: string, month: number) => Promise<any>; fetchUserDashboardInfo: (workspaceSlug: string, month: number) => Promise<any>;
@ -45,7 +45,7 @@ class UserStore implements IUserStore {
dashboardInfo: any = null; dashboardInfo: any = null;
workspaceMemberInfo: any = null; workspaceMemberInfo: IWorkspaceMemberMe | null = null;
hasPermissionToWorkspace: boolean | null = null; hasPermissionToWorkspace: boolean | null = null;
projectMemberInfo: IProjectMember | null = null; projectMemberInfo: IProjectMember | null = null;

View File

@ -26,6 +26,15 @@ export interface IWorkspaceStore {
fetchWorkspaceLabels: (workspaceSlug: string) => Promise<void>; fetchWorkspaceLabels: (workspaceSlug: string) => Promise<void>;
fetchWorkspaceMembers: (workspaceSlug: string) => Promise<void>; fetchWorkspaceMembers: (workspaceSlug: string) => Promise<void>;
// workspace write operations
createWorkspace: (data: Partial<IWorkspace>) => Promise<IWorkspace>;
updateWorkspace: (workspaceSlug: string, data: Partial<IWorkspace>) => Promise<IWorkspace>;
deleteWorkspace: (workspaceSlug: string) => Promise<void>;
// members write operations
updateMember: (workspaceSlug: string, memberId: string, data: Partial<IWorkspaceMember>) => Promise<void>;
removeMember: (workspaceSlug: string, memberId: string) => Promise<void>;
// computed // computed
currentWorkspace: IWorkspace | null; currentWorkspace: IWorkspace | null;
workspaceLabels: IIssueLabels[] | null; workspaceLabels: IIssueLabels[] | null;
@ -72,6 +81,15 @@ export class WorkspaceStore implements IWorkspaceStore {
fetchWorkspaceLabels: action, fetchWorkspaceLabels: action,
fetchWorkspaceMembers: action, fetchWorkspaceMembers: action,
// workspace write operations
createWorkspace: action,
updateWorkspace: action,
deleteWorkspace: action,
// members write operations
updateMember: action,
removeMember: action,
// computed // computed
currentWorkspace: computed, currentWorkspace: computed,
workspaceLabels: computed, workspaceLabels: computed,
@ -189,7 +207,6 @@ export class WorkspaceStore implements IWorkspaceStore {
* fetch workspace members using workspace slug * fetch workspace members using workspace slug
* @param workspaceSlug * @param workspaceSlug
*/ */
fetchWorkspaceMembers = async (workspaceSlug: string) => { fetchWorkspaceMembers = async (workspaceSlug: string) => {
try { try {
runInAction(() => { runInAction(() => {
@ -214,4 +231,174 @@ export class WorkspaceStore implements IWorkspaceStore {
}); });
} }
}; };
/**
* create workspace using the workspace data
* @param data
*/
createWorkspace = async (data: Partial<IWorkspace>) => {
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<IWorkspace>) => {
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<IWorkspaceMember>) => {
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;
}
};
} }

View File

@ -9,7 +9,7 @@ import {
IIssueDisplayFilterOptions, IIssueDisplayFilterOptions,
IIssueDisplayProperties, IIssueDisplayProperties,
IIssueFilterOptions, IIssueFilterOptions,
IWorkspaceMember, IWorkspaceMemberMe,
IWorkspaceViewProps, IWorkspaceViewProps,
TIssueParams, TIssueParams,
} from "types"; } from "types";
@ -25,7 +25,7 @@ export interface IWorkspaceFilterStore {
workspaceDisplayProperties: IIssueDisplayProperties; workspaceDisplayProperties: IIssueDisplayProperties;
// actions // actions
fetchUserWorkspaceFilters: (workspaceSlug: string) => Promise<IWorkspaceMember>; fetchUserWorkspaceFilters: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>;
updateWorkspaceFilters: (workspaceSlug: string, filterToUpdate: Partial<IWorkspaceViewProps>) => Promise<void>; updateWorkspaceFilters: (workspaceSlug: string, filterToUpdate: Partial<IWorkspaceViewProps>) => Promise<void>;
// computed // computed

View File

@ -56,6 +56,7 @@ export interface IUserLite {
avatar: string; avatar: string;
created_at: Date; created_at: Date;
display_name: string; display_name: string;
email?: string;
first_name: string; first_name: string;
readonly id: string; readonly id: string;
is_bot: boolean; is_bot: boolean;

View File

@ -1,4 +1,4 @@
import type { IProjectMember, IUser, IUserMemberLite, IWorkspaceViewProps } from "types"; import type { IProjectMember, IUser, IUserLite, IUserMemberLite, IWorkspaceViewProps } from "types";
export interface IWorkspace { export interface IWorkspace {
readonly id: string; readonly id: string;
@ -56,16 +56,29 @@ export type Properties = {
}; };
export interface IWorkspaceMember { export interface IWorkspaceMember {
readonly id: string;
workspace: IWorkspace;
member: IUserMemberLite;
role: 5 | 10 | 15 | 20;
company_role: string | null; company_role: string | null;
view_props: IWorkspaceViewProps;
created_at: Date; created_at: Date;
updated_at: Date;
created_by: string; created_by: string;
id: string;
member: IUserLite;
role: 5 | 10 | 15 | 20;
updated_at: Date;
updated_by: string; 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 { export interface ILastActiveWorkspaceDetails {