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(
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) => ({

View File

@ -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";

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}
defaultValues={defaultValues}
setDefaultValues={setDefaultValues}
user={user}
primaryButtonText={{
loading: "Creating...",
default: "Continue",

View File

@ -1,6 +1,5 @@
export * from "./overview";
export * from "./navbar";
export * from "./profile-issues-view-options";
export * from "./profile-issues-view";
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";
// 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<void>;
data?: any;
};
const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, handleDelete }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
export const ConfirmWorkspaceMemberRemove: React.FC<Props> = 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<Props> = ({ isOpen, onClose, data,
</div>
<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">
Remove {data?.display_name}?
{user?.id === data?.memberId ? "Leave workspace?" : `Remove ${data?.display_name}?`}
</Dialog.Title>
<div className="mt-2">
{user?.id === data?.memberId ? (
<p className="text-sm text-custom-text-200">
Are you sure you want to leave the workspace? You will no longer have access to this
workspace. This action cannot be undone.
</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>
@ -77,8 +92,8 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
<Button variant="neutral-primary" onClick={handleClose}>
Cancel
</Button>
<Button variant="danger" onClick={handleDeletion} loading={isDeleteLoading}>
{isDeleteLoading ? "Removing..." : "Remove"}
<Button variant="danger" onClick={handleDeletion} loading={isRemoving}>
{isRemoving ? "Removing..." : "Remove"}
</Button>
</div>
</Dialog.Panel>
@ -88,6 +103,4 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
</Dialog>
</Transition.Root>
);
};
export default ConfirmWorkspaceMemberRemove;
});

View File

@ -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<SetStateAction<any>>;
user: IUser | undefined;
secondaryButton?: React.ReactNode;
primaryButtonText?: {
loading: string;
@ -49,23 +51,27 @@ const restrictedUrls = [
const workspaceService = new WorkspaceService();
export const CreateWorkspaceForm: FC<Props> = ({
export const CreateWorkspaceForm: FC<Props> = observer((props) => {
const {
onSubmit,
defaultValues,
setDefaultValues,
user,
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<Props> = ({
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<Props> = ({
</div>
<div className="space-y-1 text-sm">
<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>
<Controller
control={control}
@ -200,9 +206,10 @@ export const CreateWorkspaceForm: FC<Props> = ({
onChange={onChange}
label={
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
width="w-full"
>
@ -232,4 +239,4 @@ export const CreateWorkspaceForm: FC<Props> = ({
</div>
</form>
);
};
});

View File

@ -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<Props> = observer((props) => {
const { isOpen, data, onClose } = props;
export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, user }) => {
const router = useRouter();
const { workspace: workspaceStore } = useMobxStore();
const { setToastAlert } = useToast();
const {
@ -63,15 +55,13 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ 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<IWorkspace[]>(USER_WORKSPACES, (prevData) => prevData?.filter((workspace) => workspace.id !== data.id));
setToastAlert({
type: "success",
title: "Success!",
@ -196,4 +186,4 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
</Dialog>
</Transition.Root>
);
};
});

View File

@ -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";

View File

@ -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<React.SetStateAction<boolean>>;
workspace_slug: string;
onClose: () => void;
workspaceSlug: string;
user: IUser | undefined;
onSuccess: () => void;
onSuccess?: () => Promise<void>;
};
type EmailRole = {
@ -44,8 +45,9 @@ const defaultValues: FormValues = {
const workspaceService = new WorkspaceService();
const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
const { isOpen, setIsOpen, workspace_slug, user, onSuccess } = props;
export const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
const { isOpen, onClose, workspaceSlug, user, onSuccess } = props;
const {
control,
reset,
@ -61,42 +63,38 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (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);
message: `${err.error ?? "Something went wrong. Please try again."}`,
})
.finally(() => {
reset(defaultValues);
mutate(WORKSPACE_INVITATIONS);
});
)
.finally(() => mutate(WORKSPACE_INVITATIONS));
};
const appendField = () => {
@ -104,9 +102,7 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (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> = (props) => {
</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
const workspaceService = new WorkspaceService();
// TODO: remove this context
export const WorkspaceMemberProvider: React.FC<Props> = (props) => {
const { children } = props;
@ -40,7 +41,7 @@ export const WorkspaceMemberProvider: React.FC<Props> = (props) => {
const loading = !memberDetails && !error;
return (
<WorkspaceMemberContext.Provider value={{ loading, memberDetails, error }}>
<WorkspaceMemberContext.Provider value={{ loading, memberDetails: undefined, error }}>
{children}
</WorkspaceMemberContext.Provider>
);

View File

@ -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<IWorkspaceMember>(
mutate<IWorkspaceMemberMe>(
WORKSPACE_MEMBERS_ME(workspaceSlug.toString()),
(prevData) => {
if (!prevData) return;

View File

@ -12,6 +12,8 @@ export interface IWorkspaceAuthWrapper {
children: ReactNode;
}
const HIGHER_ROLES = [20, 15];
export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props) => {
const { children } = props;
// store
@ -22,7 +24,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = 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<IWorkspaceAuthWrapper> = 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(

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
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<IWorkspace> = {
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<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 (
const WorkspaceSettings: NextPage = () => (
<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>
)}
<WorkspaceDetails />
</WorkspaceSettingLayout>
</AppLayout>
);
};
export default WorkspaceSettings;

View File

@ -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<string | null>(null);
const [selectedInviteRemoveMember, setSelectedInviteRemoveMember] = useState<string | null>(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 (
<AppLayout header={<WorkspaceSettingHeader title="Members Settings" />}>
<WorkspaceSettingLayout>
<ConfirmWorkspaceMemberRemove
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)}
onClose={() => {
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 && (
<SendWorkspaceInvitationModal
isOpen={inviteModal}
setIsOpen={setInviteModal}
workspace_slug={workspaceSlug as string}
onClose={() => setInviteModal(false)}
workspaceSlug={workspaceSlug.toString()}
user={user}
onSuccess={handleInviteModalSuccess}
/>
)}
<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>
<Button variant="primary" size="sm" onClick={() => setInviteModal(true)}>
Add Member
</Button>
</div>
{!workspaceMembers || !workspaceInvitations ? (
<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>
)}
<WorkspaceMembersList />
</section>
</WorkspaceSettingLayout>
</AppLayout>

View File

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

View File

@ -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<IWorkspaceMember[]> {
return this.get(`/api/workspaces/${workspaceSlug}/members/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async workspaceMemberMe(workspaceSlug: string): Promise<IWorkspaceMember> {
async workspaceMemberMe(workspaceSlug: string): Promise<IWorkspaceMemberMe> {
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<IWorkspaceMemberInvitation[]> {
return this.get(`/api/workspaces/${workspaceSlug}/invitations/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getWorkspaceInvitation(invitationId: string): Promise<IWorkspaceMemberInvitation> {
return this.get(`/api/users/me/invitations/${invitationId}/`, { headers: {} })
.then((response) => response?.data)

View File

@ -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<IUser>;
fetchCurrentUserSettings: () => Promise<IUserSettings>;
fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMember>;
fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>;
fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<IProjectMember>;
fetchUserDashboardInfo: (workspaceSlug: string, month: number) => Promise<any>;
@ -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;

View File

@ -26,6 +26,15 @@ export interface IWorkspaceStore {
fetchWorkspaceLabels: (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
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<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,
IIssueDisplayProperties,
IIssueFilterOptions,
IWorkspaceMember,
IWorkspaceMemberMe,
IWorkspaceViewProps,
TIssueParams,
} from "types";
@ -25,7 +25,7 @@ export interface IWorkspaceFilterStore {
workspaceDisplayProperties: IIssueDisplayProperties;
// actions
fetchUserWorkspaceFilters: (workspaceSlug: string) => Promise<IWorkspaceMember>;
fetchUserWorkspaceFilters: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>;
updateWorkspaceFilters: (workspaceSlug: string, filterToUpdate: Partial<IWorkspaceViewProps>) => Promise<void>;
// computed

View File

@ -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;

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 {
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 {