chore: user auth layer (#749)

* chore: use estimate points hook created

* chore: user auth layer

* fix: build error
This commit is contained in:
Aaryan Khandelwal 2023-04-08 18:05:54 +05:30 committed by GitHub
parent 3fe32606a9
commit 1026ae3eb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 603 additions and 487 deletions

View File

@ -21,9 +21,10 @@ import useToast from "hooks/use-toast";
import { import {
ViewAssigneeSelect, ViewAssigneeSelect,
ViewDueDateSelect, ViewDueDateSelect,
ViewEstimateSelect,
ViewPrioritySelect, ViewPrioritySelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues/view-select"; } from "components/issues";
// ui // ui
import { ContextMenu, CustomMenu } from "components/ui"; import { ContextMenu, CustomMenu } from "components/ui";
// icons // icons
@ -48,6 +49,7 @@ import {
MODULE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
import useEstimateOption from "hooks/use-estimate-option";
type Props = { type Props = {
type?: string; type?: string;
@ -90,6 +92,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
const { orderBy, params } = useIssuesView(); const { orderBy, params } = useIssuesView();
const { estimateValue } = useEstimateOption(issue.estimate_point);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@ -342,6 +346,14 @@ export const SingleBoardIssue: React.FC<Props> = ({
selfPositioned selfPositioned
/> />
)} )}
{properties.estimate && (
<ViewEstimateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
selfPositioned
/>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,44 +1,41 @@
import React, { useState } from "react"; import React, { useState } from "react";
// ui
import { CustomMenu, PrimaryButton } from "components/ui";
// types
import { IEstimate, IProject } from "types";
//icons
import { PencilIcon, TrashIcon, SquaresPlusIcon, ListBulletIcon } from "@heroicons/react/24/outline";
import useSWR, { mutate } from "swr";
import useToast from "hooks/use-toast";
import estimatesService from "services/estimates.service";
import projectService from "services/project.service";
import { EstimatePointsModal } from "./estimate-points-modal";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ESTIMATE_POINTS_LIST } from "constants/fetch-keys"; import useSWR from "swr";
import { PlusIcon } from "components/icons";
interface IEstimatePoints { // services
key: string; import estimatesService from "services/estimates.service";
value: string; import projectService from "services/project.service";
} // hooks
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// components
import { EstimatePointsModal } from "components/estimates";
// ui
import { CustomMenu } from "components/ui";
//icons
import {
PencilIcon,
TrashIcon,
SquaresPlusIcon,
ListBulletIcon,
} from "@heroicons/react/24/outline";
// types
import { IEstimate, IProject } from "types";
// fetch-keys
import { ESTIMATE_POINTS_LIST } from "constants/fetch-keys";
type Props = { type Props = {
estimate: IEstimate; estimate: IEstimate;
editEstimate: (estimate: IEstimate) => void; editEstimate: (estimate: IEstimate) => void;
handleEstimateDelete: (estimateId: string) => void; handleEstimateDelete: (estimateId: string) => void;
activeEstimate: IEstimate | null;
setActiveEstimate: React.Dispatch<React.SetStateAction<IEstimate | null>>;
}; };
export const SingleEstimate: React.FC<Props> = ({ export const SingleEstimate: React.FC<Props> = ({
estimate, estimate,
editEstimate, editEstimate,
handleEstimateDelete, handleEstimateDelete,
activeEstimate,
setActiveEstimate,
}) => { }) => {
const [isEstimatePointsModalOpen, setIsEstimatePointsModalOpen] = useState(false); const [isEstimatePointsModalOpen, setIsEstimatePointsModalOpen] = useState(false);
@ -47,6 +44,8 @@ export const SingleEstimate: React.FC<Props> = ({
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { projectDetails, mutateProjectDetails } = useProjectDetails();
const { data: estimatePoints } = useSWR( const { data: estimatePoints } = useSWR(
workspaceSlug && projectId ? ESTIMATE_POINTS_LIST(estimate.id) : null, workspaceSlug && projectId ? ESTIMATE_POINTS_LIST(estimate.id) : null,
workspaceSlug && projectId workspaceSlug && projectId
@ -59,12 +58,19 @@ export const SingleEstimate: React.FC<Props> = ({
: null : null
); );
const handleActiveEstimate = async () => { const handleUseEstimate = async () => {
if (!workspaceSlug || !projectId || !estimate) return; if (!workspaceSlug || !projectId) return;
const payload: Partial<IProject> = {
const payload = {
estimate: estimate.id, estimate: estimate.id,
}; };
setActiveEstimate(estimate);
mutateProjectDetails((prevData) => {
if (!prevData) return prevData;
return { ...prevData, estimate: estimate.id };
}, false);
await projectService await projectService
.updateProject(workspaceSlug as string, projectId as string, payload) .updateProject(workspaceSlug as string, projectId as string, payload)
.catch(() => { .catch(() => {
@ -77,56 +83,63 @@ export const SingleEstimate: React.FC<Props> = ({
}; };
return ( return (
<div className="divide-y"> <>
<EstimatePointsModal <EstimatePointsModal
isOpen={isEstimatePointsModalOpen} isOpen={isEstimatePointsModalOpen}
estimate={estimate} estimate={estimate}
onClose={() => setIsEstimatePointsModalOpen(false)} onClose={() => setIsEstimatePointsModalOpen(false)}
/> />
<div className="gap-2 space-y-3 my-3 bg-white"> <div className="gap-2 py-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="items-start"> <div>
<h6 className="font-medium text-base w-[40vw] truncate">{estimate.name}</h6> <h6 className="flex items-center gap-2 font-medium text-base w-[40vw] truncate">
{estimate.name}
{projectDetails?.estimate && projectDetails?.estimate === estimate.id && (
<span className="capitalize px-2 py-0.5 text-xs rounded bg-green-100 text-green-500">
In use
</span>
)}
</h6>
<p className="font-sm text-gray-400 font-normal text-[14px] w-[40vw] truncate"> <p className="font-sm text-gray-400 font-normal text-[14px] w-[40vw] truncate">
{estimate.description} {estimate.description}
</p> </p>
</div> </div>
<div className="flex items-center gap-8"> <CustomMenu ellipsis>
<CustomMenu ellipsis> {projectDetails?.estimate && projectDetails?.estimate !== estimate.id && (
<CustomMenu.MenuItem onClick={handleActiveEstimate}> <CustomMenu.MenuItem onClick={handleUseEstimate}>
<span className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<SquaresPlusIcon className="h-4 w-4" /> <SquaresPlusIcon className="h-3.5 w-3.5" />
<span>Use estimate</span> <span>Use estimate</span>
</span> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setIsEstimatePointsModalOpen(true)}> )}
<span className="flex items-center justify-start gap-2"> <CustomMenu.MenuItem onClick={() => setIsEstimatePointsModalOpen(true)}>
<ListBulletIcon className="h-4 w-4" /> <div className="flex items-center justify-start gap-2">
{estimatePoints?.length === 8 ? "Update points" : "Create points"} <ListBulletIcon className="h-3.5 w-3.5" />
</span> <span>{estimatePoints?.length === 8 ? "Update points" : "Create points"}</span>
</CustomMenu.MenuItem> </div>
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={() => { <CustomMenu.MenuItem
editEstimate(estimate); onClick={() => {
}} editEstimate(estimate);
> }}
<span className="flex items-center justify-start gap-2"> >
<PencilIcon className="h-4 w-4" /> <div className="flex items-center justify-start gap-2">
<span>Edit estimate</span> <PencilIcon className="h-3.5 w-3.5" />
</span> <span>Edit estimate</span>
</CustomMenu.MenuItem> </div>
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={() => { <CustomMenu.MenuItem
handleEstimateDelete(estimate.id); onClick={() => {
}} handleEstimateDelete(estimate.id);
> }}
<span className="flex items-center justify-start gap-2"> >
<TrashIcon className="h-4 w-4" /> <div className="flex items-center justify-start gap-2">
<span>Delete estimate</span> <TrashIcon className="h-3.5 w-3.5" />
</span> <span>Delete estimate</span>
</CustomMenu.MenuItem> </div>
</CustomMenu> </CustomMenu.MenuItem>
</div> </CustomMenu>
</div> </div>
{estimatePoints && estimatePoints.length > 0 ? ( {estimatePoints && estimatePoints.length > 0 ? (
<div className="flex gap-2"> <div className="flex gap-2">
@ -140,11 +153,11 @@ export const SingleEstimate: React.FC<Props> = ({
{estimatePoints.length > 0 && ")"} {estimatePoints.length > 0 && ")"}
</div> </div>
) : ( ) : (
<div> <div>
<p className= " text-sm text-gray-300">No estimate points</p> <p className=" text-sm text-gray-300">No estimate points</p>
</div> </div>
)} )}
</div> </div>
</div> </>
); );
}; };

View File

@ -1,5 +1,6 @@
export * from "./comment"; export * from "./comment";
export * from "./sidebar-select"; export * from "./sidebar-select";
export * from "./view-select";
export * from "./activity"; export * from "./activity";
export * from "./delete-issue-modal"; export * from "./delete-issue-modal";
export * from "./description-form"; export * from "./description-form";

View File

@ -0,0 +1,69 @@
import React from "react";
// ui
import { CustomSelect, Tooltip } from "components/ui";
// icons
import { getPriorityIcon } from "components/icons/priority-icon";
// types
import { IIssue } from "types";
// constants
import { PRIORITIES } from "constants/project";
// services
import trackEventServices from "services/track-event.service";
import useEstimateOption from "hooks/use-estimate-option";
type Props = {
issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>) => void;
position?: "left" | "right";
selfPositioned?: boolean;
isNotAllowed: boolean;
};
export const ViewEstimateSelect: React.FC<Props> = ({
issue,
partialUpdateIssue,
position = "left",
selfPositioned = false,
isNotAllowed,
}) => {
const { isEstimateActive, estimatePoints, estimateValue } = useEstimateOption(
issue.estimate_point
);
return (
<CustomSelect
value={issue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data, state: issue.state, target_date: issue.target_date });
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug: issue.workspace_detail.slug,
workspaceId: issue.workspace_detail.id,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_PRIORITY"
);
}}
label={
<Tooltip tooltipHeading="Estimate" tooltipContent={estimateValue}>
<>{estimateValue}</>
</Tooltip>
}
maxHeight="md"
noChevron
disabled={isNotAllowed}
position={position}
selfPositioned={selfPositioned}
>
{estimatePoints?.map((estimate) => (
<CustomSelect.Option key={estimate.id} value={estimate.key} className="capitalize">
<>{estimate.value}</>
</CustomSelect.Option>
))}
</CustomSelect>
);
};

View File

@ -1,4 +1,5 @@
export * from "./assignee"; export * from "./assignee";
export * from "./due-date"; export * from "./due-date";
export * from "./estimate";
export * from "./priority"; export * from "./priority";
export * from "./state"; export * from "./state";

View File

@ -1,13 +1,11 @@
import React, { createContext, ReactElement } from "react"; import React, { createContext, ReactElement } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR, { KeyedMutator } from "swr"; import useSWR, { KeyedMutator } from "swr";
// services // services
import userService from "services/user.service"; import userService from "services/user.service";
// constants // constants
import { CURRENT_USER } from "constants/fetch-keys"; import { CURRENT_USER } from "constants/fetch-keys";
// types // types
import type { IUser } from "types"; import type { IUser } from "types";
@ -22,18 +20,11 @@ interface IUserContextProps {
export const UserContext = createContext<IUserContextProps>({} as IUserContextProps); export const UserContext = createContext<IUserContextProps>({} as IUserContextProps);
export const UserProvider = ({ children }: { children: ReactElement }) => { export const UserProvider = ({ children }: { children: ReactElement }) => {
const router = useRouter();
// API to fetch user information // API to fetch user information
const { data, error, mutate } = useSWR<IUser>(CURRENT_USER, () => userService.currentUser(), { const { data, error, mutate } = useSWR<IUser>(CURRENT_USER, () => userService.currentUser(), {
shouldRetryOnError: false, shouldRetryOnError: false,
}); });
if (error) {
router.push("/signin");
return null;
}
return ( return (
<UserContext.Provider <UserContext.Provider
value={{ value={{

View File

@ -0,0 +1,49 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import estimatesService from "services/estimates.service";
// hooks
import useProjectDetails from "hooks/use-project-details";
// fetch-keys
import { ESTIMATE_POINTS_LIST } from "constants/fetch-keys";
const useEstimateOption = (estimateKey?: number) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { projectDetails } = useProjectDetails();
const { data: estimatePoints, error: estimatePointsError } = useSWR(
workspaceSlug && projectId && projectDetails && projectDetails?.estimate
? ESTIMATE_POINTS_LIST(projectDetails.estimate as string)
: null,
workspaceSlug && projectId && projectDetails && projectDetails.estimate
? () =>
estimatesService.getEstimatesPointsList(
workspaceSlug as string,
projectId as string,
projectDetails.estimate
)
: null
);
const estimateValue: any =
(estimateKey && estimatePoints?.find((e) => e.key === estimateKey)?.value) ?? "None";
useEffect(() => {
if (estimatePointsError?.status === 404) router.push("/404");
else if (estimatePointsError) router.push("/error");
}, [estimatePointsError, router]);
return {
isEstimateActive: projectDetails?.estimate ? true : false,
estimatePoints,
estimateValue,
};
};
export default useEstimateOption;

View File

@ -15,6 +15,7 @@ const initialValues: Properties = {
priority: false, priority: false,
state: true, state: true,
sub_issue_count: false, sub_issue_count: false,
estimate: false,
}; };
const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => { const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
@ -90,6 +91,7 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
priority: properties.priority, priority: properties.priority,
state: properties.state, state: properties.state,
sub_issue_count: properties.sub_issue_count, sub_issue_count: properties.sub_issue_count,
estimate: properties.estimate,
}; };
return [newProperties, updateIssueProperties] as const; return [newProperties, updateIssueProperties] as const;

View File

@ -24,6 +24,7 @@ const initialValues: Properties = {
priority: false, priority: false,
state: true, state: true,
sub_issue_count: false, sub_issue_count: false,
estimate: false,
}; };
// TODO: Refactor this logic // TODO: Refactor this logic

View File

@ -0,0 +1,42 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import projectService from "services/project.service";
// fetch-keys
import { PROJECT_DETAILS } from "constants/fetch-keys";
const useProjectDetails = () => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
data: projectDetails,
error: projectDetailsError,
mutate: mutateProjectDetails,
} = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
useEffect(() => {
if (projectDetailsError?.status === 404) {
router.push("/404");
} else if (projectDetailsError) {
router.push("/error");
}
}, [projectDetailsError, router]);
return {
projectDetails,
projectDetailsError,
mutateProjectDetails,
};
};
export default useProjectDetails;

View File

@ -1,40 +1,11 @@
import { useContext, useEffect } from "react"; import { useContext } from "react";
import { useRouter } from "next/router";
// context // context
import { UserContext } from "contexts/user.context"; import { UserContext } from "contexts/user.context";
interface useUserOptions { const useUser = () => {
redirectTo?: string;
}
const useUser = (options: useUserOptions = {}) => {
// props
const { redirectTo = null } = options;
// context // context
const contextData = useContext(UserContext); const contextData = useContext(UserContext);
// router
const router = useRouter();
/**
* Checks for redirect url and user details from the API.
* if the user is not authenticated, user will be redirected
* to the provided redirectTo route.
*/
useEffect(() => {
if (!contextData?.user || !redirectTo) return;
if (!contextData?.user) {
if (redirectTo) {
router?.pathname !== redirectTo && router.push(redirectTo);
}
router?.pathname !== "/signin" && router.push("/signin");
}
if (contextData?.user) {
if (redirectTo) {
router?.pathname !== redirectTo && router.push(redirectTo);
}
}
}, [contextData?.user, redirectTo, router]);
return { ...contextData }; return { ...contextData };
}; };

View File

@ -5,13 +5,12 @@ import { useRouter } from "next/router";
// contexts // contexts
import { useProjectMyMembership, ProjectMemberProvider } from "contexts/project-member.context"; import { useProjectMyMembership, ProjectMemberProvider } from "contexts/project-member.context";
// hooks
import useUser from "hooks/use-user";
// layouts // layouts
import Container from "layouts/container"; import Container from "layouts/container";
import AppHeader from "layouts/app-layout/app-header"; import AppHeader from "layouts/app-layout/app-header";
import AppSidebar from "layouts/app-layout/app-sidebar"; import AppSidebar from "layouts/app-layout/app-sidebar";
import SettingsNavbar from "layouts/settings-navbar"; import SettingsNavbar from "layouts/settings-navbar";
import { WorkspaceAuthorizationLayout } from "./workspace-authorization-wrapper";
// components // components
import { NotAuthorizedView, JoinProject } from "components/auth-screens"; import { NotAuthorizedView, JoinProject } from "components/auth-screens";
import { CommandPalette } from "components/command-palette"; import { CommandPalette } from "components/command-palette";
@ -59,83 +58,87 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const user = useUser();
const { loading, error, memberRole: memberType } = useProjectMyMembership(); const { loading, error, memberRole: memberType } = useProjectMyMembership();
const settingsLayout = router.pathname.includes("/settings"); const settingsLayout = router.pathname.includes("/settings");
return ( return (
<Container meta={meta}> <WorkspaceAuthorizationLayout>
<CommandPalette /> <Container meta={meta}>
<div className="flex h-screen w-full overflow-x-hidden"> <CommandPalette />
<AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} /> <div className="flex h-screen w-full overflow-x-hidden">
{loading ? ( <AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
<div className="container h-screen flex justify-center items-center p-4 text-2xl font-semibold"> {loading ? (
<p>Loading...</p> <div className="container h-screen flex justify-center items-center p-4 text-2xl font-semibold">
</div> <p>Loading...</p>
) : error?.status === 401 || error?.status === 403 ? (
<JoinProject />
) : error?.status === 404 ? (
<div className="container h-screen grid place-items-center">
<div className="text-center space-y-4">
<p className="text-2xl font-semibold">No such project exist. Create one?</p>
<PrimaryButton
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "p" });
document.dispatchEvent(e);
}}
>
Create project
</PrimaryButton>
</div> </div>
</div> ) : error?.status === 401 || error?.status === 403 ? (
) : settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? ( <JoinProject />
<NotAuthorizedView ) : error?.status === 404 ? (
actionButton={ <div className="container h-screen grid place-items-center">
<Link href={`/${workspaceSlug}/projects/${projectId}/issues`}> <div className="text-center space-y-4">
<a> <p className="text-2xl font-semibold">No such project exist. Create one?</p>
<PrimaryButton className="flex items-center gap-1"> <PrimaryButton
<LayerDiagonalIcon height={16} width={16} color="white" /> Go to issues onClick={() => {
</PrimaryButton> const e = new KeyboardEvent("keydown", { key: "p" });
</a> document.dispatchEvent(e);
</Link> }}
} >
type="project" Create project
/> </PrimaryButton>
) : ( </div>
<main className="flex h-screen w-full min-w-0 flex-col overflow-y-auto"> </div>
{!noHeader && ( ) : settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? (
<AppHeader <NotAuthorizedView
breadcrumbs={breadcrumbs} actionButton={
left={left} <Link href={`/${workspaceSlug}/projects/${projectId}/issues`}>
right={right} <a>
setToggleSidebar={setToggleSidebar} <PrimaryButton className="flex items-center gap-1">
/> <LayerDiagonalIcon height={16} width={16} color="white" /> Go to issues
)} </PrimaryButton>
<div </a>
className={`flex w-full flex-grow flex-col ${ </Link>
noPadding ? "" : settingsLayout ? "p-8 lg:px-28" : "p-8" }
} ${ type="project"
bg === "primary" ? "bg-primary" : bg === "secondary" ? "bg-secondary" : "bg-primary" />
}`} ) : (
> <main className="flex h-screen w-full min-w-0 flex-col overflow-y-auto">
{settingsLayout && ( {!noHeader && (
<div className="mb-12 space-y-6"> <AppHeader
<div> breadcrumbs={breadcrumbs}
<h3 className="text-3xl font-semibold">Project Settings</h3> left={left}
<p className="mt-1 text-gray-600"> right={right}
This information will be displayed to every member of the project. setToggleSidebar={setToggleSidebar}
</p> />
</div>
<SettingsNavbar />
</div>
)} )}
{children} <div
</div> className={`flex w-full flex-grow flex-col ${
</main> noPadding ? "" : settingsLayout ? "p-8 lg:px-28" : "p-8"
)} } ${
</div> bg === "primary"
</Container> ? "bg-primary"
: bg === "secondary"
? "bg-secondary"
: "bg-primary"
}`}
>
{settingsLayout && (
<div className="mb-12 space-y-6">
<div>
<h3 className="text-3xl font-semibold">Project Settings</h3>
<p className="mt-1 text-gray-600">
This information will be displayed to every member of the project.
</p>
</div>
<SettingsNavbar />
</div>
)}
{children}
</div>
</main>
)}
</div>
</Container>
</WorkspaceAuthorizationLayout>
); );
}; };

View File

@ -0,0 +1,28 @@
import useSWR from "swr";
import { CURRENT_USER } from "constants/fetch-keys";
import userService from "services/user.service";
import { useRouter } from "next/router";
type Props = {
children: React.ReactNode;
};
export const UserAuthorizationLayout: React.FC<Props> = ({ children }) => {
const router = useRouter();
const { data: currentUser, error } = useSWR(CURRENT_USER, () => userService.currentUser());
if (!currentUser && !error) {
return <div className="grid place-items-center h-screen">Loading...</div>;
}
if (error?.status === 401) {
const redirectTo = router.asPath;
router.push(`/signin?next=${redirectTo}`);
return null;
}
return <>{children}</>;
};

View File

@ -7,8 +7,6 @@ import useSWR from "swr";
// services // services
import workspaceServices from "services/workspace.service"; import workspaceServices from "services/workspace.service";
// hooks
import useUser from "hooks/use-user";
// layouts // layouts
import Container from "layouts/container"; import Container from "layouts/container";
import AppSidebar from "layouts/app-layout/app-sidebar"; import AppSidebar from "layouts/app-layout/app-sidebar";
@ -22,6 +20,7 @@ import { PrimaryButton } from "components/ui";
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
// fetch-keys // fetch-keys
import { WORKSPACE_MEMBERS_ME } from "constants/fetch-keys"; import { WORKSPACE_MEMBERS_ME } from "constants/fetch-keys";
import { UserAuthorizationLayout } from "./user-authorization-wrapper";
type Meta = { type Meta = {
title?: string | null; title?: string | null;
@ -58,8 +57,6 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const user = useUser();
const { data: workspaceMemberMe, error } = useSWR( const { data: workspaceMemberMe, error } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug as string) : null, workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug as string) : null,
workspaceSlug ? () => workspaceServices.workspaceMemberMe(workspaceSlug.toString()) : null, workspaceSlug ? () => workspaceServices.workspaceMemberMe(workspaceSlug.toString()) : null,
@ -99,60 +96,66 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
}; };
return ( return (
<Container meta={meta}> <UserAuthorizationLayout>
<CommandPalette /> <Container meta={meta}>
<div className="flex h-screen w-full overflow-x-hidden"> <CommandPalette />
<AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} /> <div className="flex h-screen w-full overflow-x-hidden">
{settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? ( <AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
<NotAuthorizedView {settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? (
actionButton={ <NotAuthorizedView
<Link href={`/${workspaceSlug}`}> actionButton={
<a> <Link href={`/${workspaceSlug}`}>
<PrimaryButton className="flex items-center gap-1"> <a>
<LayerDiagonalIcon height={16} width={16} color="white" /> Go to workspace <PrimaryButton className="flex items-center gap-1">
</PrimaryButton> <LayerDiagonalIcon height={16} width={16} color="white" /> Go to workspace
</a> </PrimaryButton>
</Link> </a>
} </Link>
type="workspace" }
/> type="workspace"
) : ( />
<main className="flex h-screen w-full min-w-0 flex-col overflow-y-auto"> ) : (
{!noHeader && ( <main className="flex h-screen w-full min-w-0 flex-col overflow-y-auto">
<AppHeader {!noHeader && (
breadcrumbs={breadcrumbs} <AppHeader
left={left} breadcrumbs={breadcrumbs}
right={right} left={left}
setToggleSidebar={setToggleSidebar} right={right}
/> setToggleSidebar={setToggleSidebar}
)} />
<div
className={`flex w-full flex-grow flex-col ${
noPadding ? "" : settingsLayout || profilePage ? "p-8 lg:px-28" : "p-8"
} ${
bg === "primary" ? "bg-primary" : bg === "secondary" ? "bg-secondary" : "bg-primary"
}`}
>
{(settingsLayout || profilePage) && (
<div className="mb-12 space-y-6">
<div>
<h3 className="text-3xl font-semibold">
{profilePage ? "Profile" : "Workspace"} Settings
</h3>
<p className="mt-1 text-gray-600">
{profilePage
? "This information will be visible to only you."
: "This information will be displayed to every member of the workspace."}
</p>
</div>
<SettingsNavbar profilePage={profilePage} />
</div>
)} )}
{children} <div
</div> className={`flex w-full flex-grow flex-col ${
</main> noPadding ? "" : settingsLayout || profilePage ? "p-8 lg:px-28" : "p-8"
)} } ${
</div> bg === "primary"
</Container> ? "bg-primary"
: bg === "secondary"
? "bg-secondary"
: "bg-primary"
}`}
>
{(settingsLayout || profilePage) && (
<div className="mb-12 space-y-6">
<div>
<h3 className="text-3xl font-semibold">
{profilePage ? "Profile" : "Workspace"} Settings
</h3>
<p className="mt-1 text-gray-600">
{profilePage
? "This information will be visible to only you."
: "This information will be displayed to every member of the workspace."}
</p>
</div>
<SettingsNavbar profilePage={profilePage} />
</div>
)}
{children}
</div>
</main>
)}
</div>
</Container>
</UserAuthorizationLayout>
); );
}; };

View File

@ -6,13 +6,12 @@ import useSWR, { mutate } from "swr";
// services // services
import estimatesService from "services/estimates.service"; import estimatesService from "services/estimates.service";
import projectService from "services/project.service"; // hooks
import useProjectDetails from "hooks/use-project-details";
// layouts // layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// components // components
import { CreateUpdateEstimateModal, SingleEstimate } from "components/estimates"; import { CreateUpdateEstimateModal, SingleEstimate } from "components/estimates";
//hooks //hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
@ -21,10 +20,10 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { PlusIcon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
// types // types
import { IEstimate, IProject } from "types"; import { IEstimate } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
// fetch-keys // fetch-keys
import { ESTIMATES_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; import { ESTIMATES_LIST } from "constants/fetch-keys";
const EstimatesSettings: NextPage = () => { const EstimatesSettings: NextPage = () => {
const [estimateFormOpen, setEstimateFormOpen] = useState(false); const [estimateFormOpen, setEstimateFormOpen] = useState(false);
@ -32,14 +31,14 @@ const EstimatesSettings: NextPage = () => {
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [estimateToUpdate, setEstimateToUpdate] = useState<IEstimate | undefined>(); const [estimateToUpdate, setEstimateToUpdate] = useState<IEstimate | undefined>();
const [activeEstimate, setActiveEstimate] = useState<IEstimate | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const scollToRef = useRef<HTMLDivElement>(null); const { projectDetails } = useProjectDetails();
const scrollToRef = useRef<HTMLDivElement>(null);
const { data: estimatesList } = useSWR<IEstimate[]>( const { data: estimatesList } = useSWR<IEstimate[]>(
workspaceSlug && projectId ? ESTIMATES_LIST(projectId as string) : null, workspaceSlug && projectId ? ESTIMATES_LIST(projectId as string) : null,
@ -74,13 +73,6 @@ const EstimatesSettings: NextPage = () => {
}); });
}; };
const { data: projectDetails } = useSWR<IProject>(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
return ( return (
<> <>
<ProjectAuthorizationWrapper <ProjectAuthorizationWrapper
@ -122,7 +114,6 @@ const EstimatesSettings: NextPage = () => {
</div> </div>
</div> </div>
</section> </section>
<hr className="h-[1px] w-full mt-4" />
{estimatesList && estimatesList.length > 0 && ( {estimatesList && estimatesList.length > 0 && (
<section className="mt-4 divide-y px-6 mb-8 rounded-xl border bg-white"> <section className="mt-4 divide-y px-6 mb-8 rounded-xl border bg-white">
<> <>
@ -131,8 +122,6 @@ const EstimatesSettings: NextPage = () => {
<SingleEstimate <SingleEstimate
key={estimate.id} key={estimate.id}
estimate={estimate} estimate={estimate}
activeEstimate={activeEstimate}
setActiveEstimate={setActiveEstimate}
editEstimate={(estimate) => editEstimate(estimate)} editEstimate={(estimate) => editEstimate(estimate)}
handleEstimateDelete={(estimateId) => removeEstimate(estimateId)} handleEstimateDelete={(estimateId) => removeEstimate(estimateId)}
/> />

View File

@ -3,14 +3,13 @@ import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Image from "next/image"; import Image from "next/image";
// constants
import { requiredAuth } from "lib/auth";
// layouts // layouts
import DefaultLayout from "layouts/default-layout"; import DefaultLayout from "layouts/default-layout";
import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper";
// images // images
import Logo from "public/onboarding/logo.svg"; import Logo from "public/onboarding/logo.svg";
// types // types
import type { NextPage, NextPageContext } from "next"; import type { NextPage } from "next";
// constants // constants
import { CreateWorkspaceForm } from "components/workspace"; import { CreateWorkspaceForm } from "components/workspace";
@ -23,42 +22,23 @@ const CreateWorkspace: NextPage = () => {
}; };
return ( return (
<DefaultLayout> <UserAuthorizationLayout>
<div className="grid h-full place-items-center p-5"> <DefaultLayout>
<div className="w-full space-y-4"> <div className="grid h-full place-items-center p-5">
<div className="mb-8 text-center"> <div className="w-full space-y-4">
<Image src={Logo} height="50" alt="Plane Logo" /> <div className="mb-8 text-center">
<Image src={Logo} height="50" alt="Plane Logo" />
</div>
<CreateWorkspaceForm
defaultValues={defaultValues}
setDefaultValues={() => {}}
onSubmit={(res) => router.push(`/${res.slug}`)}
/>
</div> </div>
<CreateWorkspaceForm
defaultValues={defaultValues}
setDefaultValues={() => {}}
onSubmit={(res) => router.push(`/${res.slug}`)}
/>
</div> </div>
</div> </DefaultLayout>
</DefaultLayout> </UserAuthorizationLayout>
); );
}; };
export const getServerSideProps = async (ctx: NextPageContext) => {
const user = await requiredAuth(ctx.req?.headers.cookie);
const redirectAfterSignIn = ctx.req?.url;
if (!user) {
return {
redirect: {
destination: `/signin?next=${redirectAfterSignIn}`,
permanent: false,
},
};
}
return {
props: {
user,
},
};
};
export default CreateWorkspace; export default CreateWorkspace;

View File

@ -1,10 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// lib
import { requiredAuth } from "lib/auth";
// services // services
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
// hooks // hooks
@ -12,6 +12,7 @@ import useUser from "hooks/use-user";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// layouts // layouts
import DefaultLayout from "layouts/default-layout"; import DefaultLayout from "layouts/default-layout";
import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper";
// components // components
import SingleInvitation from "components/workspace/single-invitation"; import SingleInvitation from "components/workspace/single-invitation";
// ui // ui
@ -19,7 +20,7 @@ import { Spinner, EmptySpace, EmptySpaceItem, SecondaryButton, PrimaryButton } f
// icons // icons
import { CubeIcon, PlusIcon } from "@heroicons/react/24/outline"; import { CubeIcon, PlusIcon } from "@heroicons/react/24/outline";
// types // types
import type { NextPage, NextPageContext } from "next"; import type { NextPage } from "next";
import type { IWorkspaceMemberInvitation } from "types"; import type { IWorkspaceMemberInvitation } from "types";
const OnBoard: NextPage = () => { const OnBoard: NextPage = () => {
@ -77,115 +78,96 @@ const OnBoard: NextPage = () => {
}; };
return ( return (
<DefaultLayout <UserAuthorizationLayout>
meta={{ <DefaultLayout
title: "Plane - Welcome to Plane", meta={{
description: title: "Plane - Welcome to Plane",
"Please fasten your seatbelts because we are about to take your productivity to the next level.", description:
}} "Please fasten your seatbelts because we are about to take your productivity to the next level.",
> }}
<div className="flex min-h-full flex-col items-center justify-center p-4 sm:p-0"> >
{user && ( <div className="flex min-h-full flex-col items-center justify-center p-4 sm:p-0">
<div className="mb-10 w-96 rounded-lg bg-indigo-100 p-2 text-theme"> {user && (
<p className="text-center text-sm">logged in as {user.email}</p> <div className="mb-10 w-96 rounded-lg bg-indigo-100 p-2 text-theme">
</div> <p className="text-center text-sm">logged in as {user.email}</p>
)}
<div className="w-full rounded-lg p-8 md:w-2/3 lg:w-1/3">
{invitations && workspaces ? (
invitations.length > 0 ? (
<div>
<h2 className="text-lg font-medium text-gray-900">Workspace Invitations</h2>
<p className="mt-1 text-sm text-gray-500">
Select invites that you want to accept.
</p>
<ul role="list" className="mt-6 divide-y divide-gray-200 border-t border-b">
{invitations.map((invitation) => (
<SingleInvitation
key={invitation.id}
invitation={invitation}
invitationsRespond={invitationsRespond}
handleInvitation={handleInvitation}
/>
))}
</ul>
<div className="mt-6 flex items-center gap-2">
<Link href="/">
<a className="w-full">
<SecondaryButton className="w-full">Go to Home</SecondaryButton>
</a>
</Link>
<PrimaryButton className="w-full" onClick={submitInvitations}>
Accept and Continue
</PrimaryButton>
</div>
</div>
) : workspaces && workspaces.length > 0 ? (
<div className="mt-3 flex flex-col gap-y-3">
<h2 className="mb-4 text-2xl font-medium">Your workspaces</h2>
{workspaces.map((workspace) => (
<Link key={workspace.id} href={workspace.slug}>
<a>
<div className="mb-2 flex items-center justify-between rounded border px-4 py-2">
<div className="flex items-center gap-x-2">
<CubeIcon className="h-5 w-5 text-gray-400" />
{workspace.name}
</div>
<div className="flex items-center gap-x-2">
<p className="text-sm">{workspace.owner.first_name}</p>
</div>
</div>
</a>
</Link>
))}
</div>
) : (
invitations.length === 0 &&
workspaces.length === 0 && (
<EmptySpace
title="You don't have any workspaces yet"
description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account."
>
<EmptySpaceItem
Icon={PlusIcon}
title={"Create your Workspace"}
action={() => {
router.push("/create-workspace");
}}
/>
</EmptySpace>
)
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div> </div>
)} )}
<div className="w-full rounded-lg p-8 md:w-2/3 lg:w-1/3">
{invitations && workspaces ? (
invitations.length > 0 ? (
<div>
<h2 className="text-lg font-medium text-gray-900">Workspace Invitations</h2>
<p className="mt-1 text-sm text-gray-500">
Select invites that you want to accept.
</p>
<ul role="list" className="mt-6 divide-y divide-gray-200 border-t border-b">
{invitations.map((invitation) => (
<SingleInvitation
key={invitation.id}
invitation={invitation}
invitationsRespond={invitationsRespond}
handleInvitation={handleInvitation}
/>
))}
</ul>
<div className="mt-6 flex items-center gap-2">
<Link href="/">
<a className="w-full">
<SecondaryButton className="w-full">Go to Home</SecondaryButton>
</a>
</Link>
<PrimaryButton className="w-full" onClick={submitInvitations}>
Accept and Continue
</PrimaryButton>
</div>
</div>
) : workspaces && workspaces.length > 0 ? (
<div className="mt-3 flex flex-col gap-y-3">
<h2 className="mb-4 text-2xl font-medium">Your workspaces</h2>
{workspaces.map((workspace) => (
<Link key={workspace.id} href={workspace.slug}>
<a>
<div className="mb-2 flex items-center justify-between rounded border px-4 py-2">
<div className="flex items-center gap-x-2">
<CubeIcon className="h-5 w-5 text-gray-400" />
{workspace.name}
</div>
<div className="flex items-center gap-x-2">
<p className="text-sm">{workspace.owner.first_name}</p>
</div>
</div>
</a>
</Link>
))}
</div>
) : (
invitations.length === 0 &&
workspaces.length === 0 && (
<EmptySpace
title="You don't have any workspaces yet"
description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account."
>
<EmptySpaceItem
Icon={PlusIcon}
title={"Create your Workspace"}
action={() => {
router.push("/create-workspace");
}}
/>
</EmptySpace>
)
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</div>
</div> </div>
</div> </DefaultLayout>
</DefaultLayout> </UserAuthorizationLayout>
); );
}; };
export const getServerSideProps = async (ctx: NextPageContext) => {
const user = await requiredAuth(ctx.req?.headers.cookie);
const redirectAfterSignIn = ctx.req?.url;
if (!user) {
return {
redirect: {
destination: `/signin?next=${redirectAfterSignIn}`,
permanent: false,
},
};
}
return {
props: {
user,
},
};
};
export default OnBoard; export default OnBoard;

View File

@ -3,14 +3,13 @@ import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// lib
import { requiredAuth } from "lib/auth";
// services // services
import userService from "services/user.service"; import userService from "services/user.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// layouts // layouts
import DefaultLayout from "layouts/default-layout"; import DefaultLayout from "layouts/default-layout";
import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper";
// components // components
import { InviteMembers, OnboardingCard, UserDetails, Workspace } from "components/onboarding"; import { InviteMembers, OnboardingCard, UserDetails, Workspace } from "components/onboarding";
// ui // ui
@ -20,7 +19,7 @@ import { ONBOARDING_CARDS } from "constants/workspace";
// images // images
import Logo from "public/onboarding/logo.svg"; import Logo from "public/onboarding/logo.svg";
// types // types
import type { NextPage, GetServerSidePropsContext } from "next"; import type { NextPage } from "next";
const Onboarding: NextPage = () => { const Onboarding: NextPage = () => {
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
@ -33,83 +32,64 @@ const Onboarding: NextPage = () => {
const { user } = useUser(); const { user } = useUser();
return ( return (
<DefaultLayout> <UserAuthorizationLayout>
<div className="grid h-full place-items-center p-5"> <DefaultLayout>
{step <= 3 ? ( <div className="grid h-full place-items-center p-5">
<div className="w-full"> {step <= 3 ? (
<div className="text-center mb-8"> <div className="w-full">
<Image src={Logo} height="50" alt="Plane Logo" /> <div className="text-center mb-8">
</div> <Image src={Logo} height="50" alt="Plane Logo" />
{step === 1 ? ( </div>
<UserDetails user={user} setStep={setStep} setUserRole={setUserRole} /> {step === 1 ? (
) : step === 2 ? ( <UserDetails user={user} setStep={setStep} setUserRole={setUserRole} />
<Workspace setStep={setStep} setWorkspace={setWorkspace} /> ) : step === 2 ? (
) : ( <Workspace setStep={setStep} setWorkspace={setWorkspace} />
<InviteMembers setStep={setStep} workspace={workspace} />
)}
</div>
) : (
<div className="flex w-full max-w-2xl flex-col gap-12">
<div className="flex flex-col items-center justify-center gap-7 rounded-[10px] bg-white px-14 py-10 text-center shadow-md">
{step === 4 ? (
<OnboardingCard data={ONBOARDING_CARDS.welcome} />
) : step === 5 ? (
<OnboardingCard data={ONBOARDING_CARDS.issue} />
) : step === 6 ? (
<OnboardingCard data={ONBOARDING_CARDS.cycle} />
) : step === 7 ? (
<OnboardingCard data={ONBOARDING_CARDS.module} />
) : ( ) : (
<OnboardingCard data={ONBOARDING_CARDS.commandMenu} /> <InviteMembers setStep={setStep} workspace={workspace} />
)} )}
<div className="mx-auto flex h-1/4 items-end lg:w-1/2"> </div>
<PrimaryButton ) : (
type="button" <div className="flex w-full max-w-2xl flex-col gap-12">
className="flex w-full items-center justify-center text-center " <div className="flex flex-col items-center justify-center gap-7 rounded-[10px] bg-white px-14 py-10 text-center shadow-md">
size="md" {step === 4 ? (
onClick={() => { <OnboardingCard data={ONBOARDING_CARDS.welcome} />
if (step === 8) { ) : step === 5 ? (
userService <OnboardingCard data={ONBOARDING_CARDS.issue} />
.updateUserOnBoard({ userRole }) ) : step === 6 ? (
.then(() => { <OnboardingCard data={ONBOARDING_CARDS.cycle} />
router.push("/"); ) : step === 7 ? (
}) <OnboardingCard data={ONBOARDING_CARDS.module} />
.catch((err) => { ) : (
console.log(err); <OnboardingCard data={ONBOARDING_CARDS.commandMenu} />
}); )}
} else setStep((prevData) => prevData + 1); <div className="mx-auto flex h-1/4 items-end lg:w-1/2">
}} <PrimaryButton
> type="button"
{step === 4 || step === 8 ? "Get Started" : "Next"} className="flex w-full items-center justify-center text-center "
</PrimaryButton> size="md"
onClick={() => {
if (step === 8) {
userService
.updateUserOnBoard({ userRole })
.then(() => {
router.push("/");
})
.catch((err) => {
console.log(err);
});
} else setStep((prevData) => prevData + 1);
}}
>
{step === 4 || step === 8 ? "Get Started" : "Next"}
</PrimaryButton>
</div>
</div> </div>
</div> </div>
</div> )}
)} </div>
</div> </DefaultLayout>
</DefaultLayout> </UserAuthorizationLayout>
); );
}; };
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const user = await requiredAuth(ctx.req?.headers.cookie);
const redirectAfterSignIn = ctx.resolvedUrl;
if (!user) {
return {
redirect: {
destination: `/signin?next=${redirectAfterSignIn}`,
permanent: false,
},
};
}
return {
props: {
user,
},
};
};
export default Onboarding; export default Onboarding;

View File

@ -30,12 +30,10 @@ class UserService extends APIService {
} }
async currentUser(): Promise<any> { async currentUser(): Promise<any> {
if (!this.getAccessToken()) return null;
return this.get("/api/users/me/") return this.get("/api/users/me/")
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
this.purgeAccessToken(); throw error?.response;
throw error?.response?.data;
}); });
} }

View File

@ -186,6 +186,7 @@ export type Properties = {
priority: boolean; priority: boolean;
state: boolean; state: boolean;
sub_issue_count: boolean; sub_issue_count: boolean;
estimate: boolean;
}; };
export interface IIssueLabels { export interface IIssueLabels {
@ -272,4 +273,4 @@ export interface IIssueAttachment {
updated_at: string; updated_at: string;
updated_by: string; updated_by: string;
workspace: string; workspace: string;
} }