removing trubo

This commit is contained in:
sriram veeraghanta 2023-04-21 19:30:36 -04:00
parent 33a904bc3e
commit 1538b99a28
543 changed files with 63022 additions and 0 deletions

11
app/.env.example Normal file
View File

@ -0,0 +1,11 @@
# Replace with your instance Public IP
# NEXT_PUBLIC_API_BASE_URL = "http://localhost"
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
NEXT_PUBLIC_GOOGLE_CLIENTID=""
NEXT_PUBLIC_GITHUB_APP_NAME=""
NEXT_PUBLIC_GITHUB_ID=""
NEXT_PUBLIC_SENTRY_DSN=""
NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_ENABLE_SENTRY=0
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
NEXT_PUBLIC_TRACK_EVENTS=0

15
app/.eslintrc.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
extends: ["next", "prettier"],
parser: "@typescript-eslint/parser",
plugins: ["react", "@typescript-eslint"],
rules: {
"@next/next/no-html-link-for-pages": "off",
"react/jsx-key": "off",
"prefer-const": "error",
"no-irregular-whitespace": "error",
"no-trailing-spaces": "error",
"no-duplicate-imports": "error",
"arrow-body-style": ["error", "as-needed"],
"react/self-closing-comp": ["error", { component: true, html: true }],
},
};

5
app/.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "es5"
}

13
app/Dockerfile.dev Normal file
View File

@ -0,0 +1,13 @@
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
COPY . .
RUN yarn install
EXPOSE 3000

30
app/Dockerfile.web Normal file
View File

@ -0,0 +1,30 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
# Install dependencies
RUN yarn install --frozen-lockfile
COPY . .
# build
RUN yarn build
FROM node:18-alpine AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 plane
RUN adduser --system --uid 1001 captain
USER captain
COPY --from=builder /app/next.config.js .
COPY --from=builder /app/package.json .
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
ENV NEXT_TELEMETRY_DISABLED 1
EXPOSE 3000

View File

@ -0,0 +1,199 @@
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
// ui
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// services
import authenticationService from "services/authentication.service";
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
// icons
// types
type EmailCodeFormValues = {
email: string;
key?: string;
token?: string;
};
export const EmailCodeForm = ({ onSuccess }: any) => {
const [codeSent, setCodeSent] = useState(false);
const [codeResent, setCodeResent] = useState(false);
const [isCodeResending, setIsCodeResending] = useState(false);
const [errorResendingCode, setErrorResendingCode] = useState(false);
const { setToastAlert } = useToast();
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
const {
register,
handleSubmit,
setError,
setValue,
getValues,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailCodeFormValues>({
defaultValues: {
email: "",
key: "",
token: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const isResendDisabled =
resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
const onSubmit = async ({ email }: EmailCodeFormValues) => {
setErrorResendingCode(false);
await authenticationService
.emailCode({ email })
.then((res) => {
setValue("key", res.key);
setCodeSent(true);
})
.catch((err) => {
setErrorResendingCode(true);
setToastAlert({
title: "Oops!",
type: "error",
message: err?.error,
});
});
};
const handleSignin = async (formData: EmailCodeFormValues) => {
await authenticationService
.magicSignIn(formData)
.then((response) => {
onSuccess(response);
})
.catch((error) => {
setToastAlert({
title: "Oops!",
type: "error",
message: error?.response?.data?.error ?? "Enter the correct code to sign in",
});
setError("token" as keyof EmailCodeFormValues, {
type: "manual",
message: error.error,
});
});
};
const emailOld = getValues("email");
useEffect(() => {
setErrorResendingCode(false);
}, [emailOld]);
return (
<>
<form className="space-y-5 py-5 px-5">
{(codeSent || codeResent) && (
<div className="rounded-md bg-green-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-5 w-5 text-green-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-green-800">
{codeResent
? "Please check your mail for new code."
: "Please check your mail for code."}
</p>
</div>
</div>
</div>
)}
<div>
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email ID is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email ID is not valid",
}}
error={errors.email}
placeholder="Enter your Email ID"
/>
</div>
{codeSent && (
<div>
<Input
id="token"
type="token"
name="token"
register={register}
validations={{
required: "Code is required",
}}
error={errors.token}
placeholder="Enter code"
/>
<button
type="button"
className={`mt-5 flex w-full justify-end text-xs outline-none ${
isResendDisabled ? "cursor-default text-gray-400" : "cursor-pointer text-brand-accent"
} `}
onClick={() => {
setIsCodeResending(true);
onSubmit({ email: getValues("email") }).then(() => {
setCodeResent(true);
setIsCodeResending(false);
setResendCodeTimer(30);
});
}}
disabled={isResendDisabled}
>
{resendCodeTimer > 0 ? (
<p className="text-right">
Didn{"'"}t receive code? Get new code in {resendCodeTimer} seconds.
</p>
) : isCodeResending ? (
"Sending code..."
) : errorResendingCode ? (
"Please try again later"
) : (
"Resend code"
)}
</button>
</div>
)}
<div>
{codeSent ? (
<PrimaryButton
type="submit"
className="w-full text-center"
size="md"
onClick={handleSubmit(handleSignin)}
loading={isSubmitting || (!isValid && isDirty)}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</PrimaryButton>
) : (
<PrimaryButton
className="w-full text-center"
size="md"
onClick={() => {
handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30);
});
}}
loading={isSubmitting || (!isValid && isDirty)}
>
{isSubmitting ? "Sending code..." : "Send code"}
</PrimaryButton>
)}
</div>
</form>
</>
);
};

View File

@ -0,0 +1,113 @@
import React from "react";
import Link from "next/link";
// react hook form
import { useForm } from "react-hook-form";
// services
import authenticationService from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, SecondaryButton } from "components/ui";
// types
type EmailPasswordFormValues = {
email: string;
password?: string;
medium?: string;
};
export const EmailPasswordForm = ({ onSuccess }: any) => {
const { setToastAlert } = useToast();
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailPasswordFormValues>({
defaultValues: {
email: "",
password: "",
medium: "email",
},
mode: "onChange",
reValidateMode: "onChange",
});
const onSubmit = (formData: EmailPasswordFormValues) => {
authenticationService
.emailLogin(formData)
.then((response) => {
onSuccess(response);
})
.catch((error) => {
console.log(error);
setToastAlert({
title: "Oops!",
type: "error",
message: "Enter the correct email address and password to sign in",
});
if (!error?.response?.data) return;
Object.keys(error.response.data).forEach((key) => {
const err = error.response.data[key];
console.log(err);
setError(key as keyof EmailPasswordFormValues, {
type: "manual",
message: Array.isArray(err) ? err.join(", ") : err,
});
});
});
};
return (
<>
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
<div>
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email ID is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email ID is not valid",
}}
error={errors.email}
placeholder="Enter your Email ID"
/>
</div>
<div className="mt-5">
<Input
id="password"
type="password"
name="password"
register={register}
validations={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password"
/>
</div>
<div className="mt-2 flex items-center justify-between">
<div className="ml-auto text-sm">
<Link href={"/forgot-password"}>
<a className="font-medium text-brand-accent hover:text-indigo-500">Forgot your password?</a>
</Link>
</div>
</div>
<div className="mt-5">
<SecondaryButton
type="submit"
className="w-full text-center"
loading={isSubmitting || (!isValid && isDirty)}
>
{isSubmitting ? "Signing in..." : "Sign In"}
</SecondaryButton>
</div>
</form>
</>
);
};

View File

@ -0,0 +1,24 @@
import { useState, FC } from "react";
import { KeyIcon } from "@heroicons/react/24/outline";
// components
import { EmailCodeForm, EmailPasswordForm } from "components/account";
export interface EmailSignInFormProps {
handleSuccess: () => void;
}
export const EmailSignInForm: FC<EmailSignInFormProps> = (props) => {
const { handleSuccess } = props;
// states
const [useCode, setUseCode] = useState(true);
return (
<>
{useCode ? (
<EmailCodeForm onSuccess={handleSuccess} />
) : (
<EmailPasswordForm onSuccess={handleSuccess} />
)}
</>
);
};

View File

@ -0,0 +1,47 @@
import { useEffect, useState, FC } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// images
import githubImage from "/public/logos/github-black.png";
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
export interface GithubLoginButtonProps {
handleSignIn: React.Dispatch<string>;
}
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
const { handleSignIn } = props;
// router
const {
query: { code },
} = useRouter();
// states
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
useEffect(() => {
if (code) {
handleSignIn(code.toString());
}
}, [code, handleSignIn]);
useEffect(() => {
const origin =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/signin` as any);
}, []);
return (
<div className="px-1 w-full">
<Link
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-brand-base p-2 text-sm font-medium text-gray-600 duration-300 hover:bg-gray-50">
<Image src={githubImage} height={22} width={22} color="#000" alt="GitHub Logo" />
<span>Sign In with Github</span>
</button>
</Link>
</div>
);
};

View File

@ -0,0 +1,53 @@
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
// next
import Script from "next/script";
export interface IGoogleLoginButton {
text?: string;
handleSignIn: React.Dispatch<any>;
styles?: CSSProperties;
}
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
const { handleSignIn } = props;
const googleSignInButton = useRef<HTMLDivElement>(null);
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => {
if (!googleSignInButton.current || gsiScriptLoaded) return;
window?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
callback: handleSignIn,
});
window?.google?.accounts.id.renderButton(
googleSignInButton.current,
{
type: "standard",
theme: "outline",
size: "large",
logo_alignment: "center",
width: "410",
text: "continue_with",
} as GsiButtonConfiguration // customization attributes
);
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
setGsiScriptLoaded(true);
}, [handleSignIn, gsiScriptLoaded]);
useEffect(() => {
if (window?.google?.accounts?.id) {
loadScript();
}
return () => {
window?.google?.accounts.id.cancel();
};
}, [loadScript]);
return (
<>
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
<div className="h-12" id="googleSignInButton" ref={googleSignInButton} />
</>
);
};

View File

@ -0,0 +1,5 @@
export * from "./google-login";
export * from "./email-code-form";
export * from "./email-password-form";
export * from "./github-login-button";
export * from "./email-signin-form";

View File

@ -0,0 +1,3 @@
export * from "./project";
export * from "./workspace";
export * from "./not-authorized-view";

View File

@ -0,0 +1,67 @@
import React from "react";
// next
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// layouts
import DefaultLayout from "layouts/default-layout";
// hooks
import useUser from "hooks/use-user";
// images
import ProjectNotAuthorizedImg from "public/auth/project-not-authorized.svg";
import WorkspaceNotAuthorizedImg from "public/auth/workspace-not-authorized.svg";
type Props = {
actionButton?: React.ReactNode;
type: "project" | "workspace";
};
export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
const { user } = useUser();
const { asPath: currentPath } = useRouter();
return (
<DefaultLayout
meta={{
title: "Plane - Not Authorized",
description: "You are not authorized to view this page",
}}
>
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
<div className="h-44 w-72">
<Image
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
height="176"
width="288"
alt="ProjectSettingImg"
/>
</div>
<h1 className="text-xl font-medium text-brand-base">
Oops! You are not authorized to view this page
</h1>
<div className="w-full text-base text-brand-secondary max-w-md ">
{user ? (
<p>
You have signed in as {user.email}. <br />
<Link href={`/signin?next=${currentPath}`}>
<a className="text-brand-base font-medium">Sign in</a>
</Link>{" "}
with different account that has access to this page.
</p>
) : (
<p>
You need to{" "}
<Link href={`/signin?next=${currentPath}`}>
<a className="text-brand-base font-medium">Sign in</a>
</Link>{" "}
with an account that has access to this page.
</p>
)}
</div>
{actionButton}
</div>
</DefaultLayout>
);
};

View File

@ -0,0 +1 @@
export * from "./join-project";

View File

@ -0,0 +1,68 @@
import { useState } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import projectService from "services/project.service";
// ui
import { PrimaryButton } from "components/ui";
// icons
import { AssignmentClipboardIcon } from "components/icons";
// images
import JoinProjectImg from "public/auth/project-not-authorized.svg";
// fetch-keys
import { USER_PROJECT_VIEW } from "constants/fetch-keys";
export const JoinProject: React.FC = () => {
const [isJoiningProject, setIsJoiningProject] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const handleJoin = () => {
if (!workspaceSlug || !projectId) return;
setIsJoiningProject(true);
projectService
.joinProject(workspaceSlug as string, {
project_ids: [projectId as string],
})
.then(async () => {
await mutate(USER_PROJECT_VIEW(projectId.toString()));
setIsJoiningProject(false);
})
.catch((err) => {
console.error(err);
setIsJoiningProject(false);
});
};
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
<div className="h-44 w-72">
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
</div>
<h1 className="text-xl font-medium text-gray-900">You are not a member of this project</h1>
<div className="w-full max-w-md text-base text-gray-500 ">
<p className="mx-auto w-full text-sm md:w-3/4">
You are not a member of this project, but you can join this project by clicking the button
below.
</p>
</div>
<div>
<PrimaryButton
className="flex items-center gap-1"
loading={isJoiningProject}
onClick={handleJoin}
>
<AssignmentClipboardIcon height={16} width={16} color="white" />
{isJoiningProject ? "Joining..." : "Click to join"}
</PrimaryButton>
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export * from "./not-a-member";

View File

@ -0,0 +1,44 @@
import Link from "next/link";
import { useRouter } from "next/router";
// layouts
import DefaultLayout from "layouts/default-layout";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
export const NotAWorkspaceMember = () => {
const router = useRouter();
return (
<DefaultLayout
meta={{
title: "Plane - Unauthorized User",
description: "Unauthorized user",
}}
>
<div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">Not Authorized!</h3>
<p className="text-sm text-gray-500 w-1/2 mx-auto">
You{"'"}re not a member of this workspace. Please contact the workspace admin to get
an invitation or check your pending invitations.
</p>
</div>
<div className="flex items-center gap-2 justify-center">
<Link href="/invitations">
<a>
<SecondaryButton>Check pending invites</SecondaryButton>
</a>
</Link>
<Link href="/create-workspace">
<a>
<PrimaryButton>Create new workspace</PrimaryButton>
</a>
</Link>
</div>
</div>
</div>
</DefaultLayout>
);
};

View File

@ -0,0 +1,60 @@
import * as React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// icons
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
type BreadcrumbsProps = {
children: any;
};
const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
const router = useRouter();
return (
<>
<div className="flex items-center">
<button
type="button"
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-brand-base text-center text-sm hover:bg-brand-surface-1"
onClick={() => router.back()}
>
<ArrowLeftIcon className="h-3 w-3" />
</button>
{children}
</div>
</>
);
};
type BreadcrumbItemProps = {
title: string;
link?: string;
icon?: any;
};
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) => (
<>
{link ? (
<Link href={link}>
<a className="border-r-2 border-brand-base px-3 text-sm">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon ?? null}
{title}
</p>
</a>
</Link>
) : (
<div className="max-w-64 px-3 text-sm">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon}
<span className="break-all">{title}</span>
</p>
</div>
)}
</>
);
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
export { Breadcrumbs, BreadcrumbItem };

View File

@ -0,0 +1,45 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
// cmdk
import { Command } from "cmdk";
import { THEMES_OBJ } from "constants/themes";
import { useTheme } from "next-themes";
import { SettingIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
};
export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
const [mounted, setMounted] = useState(false);
const { setTheme } = useTheme();
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<>
{THEMES_OBJ.map((theme) => (
<Command.Item
key={theme.value}
onSelect={() => {
setTheme(theme.value);
setIsPaletteOpen(false);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
{theme.label}
</div>
</Command.Item>
))}
</>
);
};

View File

@ -0,0 +1,108 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
// types
import { IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, PROJECT_MEMBERS } from "constants/fetch-keys";
// icons
import { CheckIcon } from "components/icons";
import projectService from "services/project.service";
import { Avatar } from "components/ui";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
};
export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const options =
members?.map(({ member }) => ({
value: member.id,
query:
(member.first_name && member.first_name !== "" ? member.first_name : member.email) +
" " +
member.last_name ?? "",
content: (
<>
<div className="flex items-center gap-2">
<Avatar user={member} />
{member.first_name && member.first_name !== "" ? member.first_name : member.email}
</div>
{issue.assignees.includes(member.id) && (
<div>
<CheckIcon className="h-3 w-3" />
</div>
)}
</>
),
})) ?? [];
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees_list: updatedAssignees });
setIsPaletteOpen(false);
};
return (
<>
{options.map((option) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
))}
</>
);
};

View File

@ -0,0 +1,74 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
// types
import { IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { PRIORITIES } from "constants/project";
// icons
import { CheckIcon, getPriorityIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
};
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueState = (priority: string | null) => {
submitChanges({ priority });
setIsPaletteOpen(false);
};
return (
<>
{PRIORITIES.map((priority) => (
<Command.Item
key={priority}
onSelect={() => handleIssueState(priority)}
className="focus:outline-none"
>
<div className="flex items-center space-x-3">
{getPriorityIcon(priority)}
<span className="capitalize">{priority ?? "None"}</span>
</div>
<div>{priority === issue.priority && <CheckIcon className="h-3 w-3" />}</div>
</Command.Item>
))}
</>
);
};

View File

@ -0,0 +1,95 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// ui
import { Spinner } from "components/ui";
// helpers
import { getStatesList } from "helpers/state.helper";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
// types
import { IIssue } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATE_LIST } from "constants/fetch-keys";
// icons
import { CheckIcon, getStateGroupIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
};
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: stateGroups, mutate: mutateIssueDetails } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutateIssueDetails();
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, mutateIssueDetails]
);
const handleIssueState = (stateId: string) => {
submitChanges({ state: stateId });
setIsPaletteOpen(false);
};
return (
<>
{states ? (
states.length > 0 ? (
states.map((state) => (
<Command.Item
key={state.id}
onSelect={() => handleIssueState(state.id)}
className="focus:outline-none"
>
<div className="flex items-center space-x-3">
{getStateGroupIcon(state.group, "16", "16", state.color)}
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <CheckIcon className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
};

View File

@ -0,0 +1,879 @@
import { useRouter } from "next/router";
import React, { useCallback, useEffect, useState } from "react";
import useSWR, { mutate } from "swr";
// icons
import {
ArrowRightIcon,
ChartBarIcon,
ChatBubbleOvalLeftEllipsisIcon,
DocumentTextIcon,
FolderPlusIcon,
LinkIcon,
MagnifyingGlassIcon,
RocketLaunchIcon,
Squares2X2Icon,
TrashIcon,
UserMinusIcon,
UserPlusIcon,
UsersIcon,
} from "@heroicons/react/24/outline";
import {
AssignmentClipboardIcon,
ContrastIcon,
DiscordIcon,
DocumentIcon,
GithubIcon,
LayerDiagonalIcon,
PeopleGroupIcon,
SettingIcon,
ViewListIcon,
} from "components/icons";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// cmdk
import { Command } from "cmdk";
// hooks
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
import useDebounce from "hooks/use-debounce";
// components
import {
ShortcutsModal,
ChangeIssueState,
ChangeIssuePriority,
ChangeIssueAssignee,
ChangeInterfaceTheme,
} from "components/command-palette";
import { BulkDeleteIssuesModal } from "components/core";
import { CreateUpdateCycleModal } from "components/cycles";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateModuleModal } from "components/modules";
import { CreateProjectModal } from "components/project";
import { CreateUpdateViewModal } from "components/views";
import { CreateUpdatePageModal } from "components/pages";
import { Spinner } from "components/ui";
// helpers
import {
capitalizeFirstLetter,
copyTextToClipboard,
replaceUnderscoreIfSnakeCase,
} from "helpers/string.helper";
// services
import issuesService from "services/issues.service";
import workspaceService from "services/workspace.service";
// types
import { IIssue, IWorkspaceSearchResults } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
export const CommandPalette: React.FC = () => {
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false);
const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false);
const [isCreateViewModalOpen, setIsCreateViewModalOpen] = useState(false);
const [isCreateModuleModalOpen, setIsCreateModuleModalOpen] = useState(false);
const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = React.useState<string>("");
const [results, setResults] = useState<IWorkspaceSearchResults>({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
const [resultsCount, setResultsCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const [placeholder, setPlaceholder] = React.useState("Type a command or search...");
const [pages, setPages] = React.useState<string[]>([]);
const page = pages[pages.length - 1];
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { user } = useUser();
const { setToastAlert } = useToast();
const { toggleCollapsed } = useTheme();
const { data: issueDetails } = useSWR<IIssue | undefined>(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null
);
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
mutate(ISSUE_DETAILS(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueAssignees = (assignee: string) => {
if (!issueDetails) return;
setIsPaletteOpen(false);
const updatedAssignees = issueDetails.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees_list: updatedAssignees });
};
const copyIssueUrlToClipboard = useCallback(() => {
if (!router.query.issueId) return;
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
}, [router, setToastAlert]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const singleShortcutKeys = ["p", "v", "d", "h", "q", "m"];
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
if (!key) return;
const keyPressed = key.toLowerCase();
if (
!(e.target instanceof HTMLTextAreaElement) &&
!(e.target instanceof HTMLInputElement) &&
!(e.target as Element).classList?.contains("remirror-editor")
) {
if ((ctrlKey || metaKey) && keyPressed === "k") {
e.preventDefault();
setIsPaletteOpen(true);
} else if ((ctrlKey || metaKey) && keyPressed === "c") {
if (altKey) {
e.preventDefault();
copyIssueUrlToClipboard();
}
} else if (keyPressed === "c") {
e.preventDefault();
setIsIssueModalOpen(true);
} else if ((ctrlKey || metaKey) && keyPressed === "b") {
e.preventDefault();
toggleCollapsed();
} else if (key === "Delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
} else if (
singleShortcutKeys.includes(keyPressed) &&
(ctrlKey || metaKey || altKey || shiftKey)
) {
e.preventDefault();
} else if (keyPressed === "p") {
setIsProjectModalOpen(true);
} else if (keyPressed === "v") {
setIsCreateViewModalOpen(true);
} else if (keyPressed === "d") {
setIsCreateUpdatePageModalOpen(true);
} else if (keyPressed === "h") {
setIsShortcutsModalOpen(true);
} else if (keyPressed === "q") {
setIsCreateCycleModalOpen(true);
} else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
}
}
},
[toggleCollapsed, copyIssueUrlToClipboard]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
useEffect(
() => {
if (!workspaceSlug || !projectId) return;
setIsLoading(true);
// this is done prevent subsequent api request
// or searchTerm has not been updated within last 500ms.
if (debouncedSearchTerm) {
setIsSearching(true);
workspaceService
.searchWorkspace(workspaceSlug as string, projectId as string, debouncedSearchTerm)
.then((results) => {
setResults(results);
const count = Object.keys(results.results).reduce(
(accumulator, key) => (results.results as any)[key].length + accumulator,
0
);
setResultsCount(count);
})
.finally(() => {
setIsLoading(false);
setIsSearching(false);
});
} else {
setResults({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
setIsLoading(false);
setIsSearching(false);
}
},
[debouncedSearchTerm, workspaceSlug, projectId] // Only call effect if debounced search term changes
);
if (!user) return null;
const createNewWorkspace = () => {
setIsPaletteOpen(false);
router.push("/create-workspace");
};
const createNewProject = () => {
setIsPaletteOpen(false);
setIsProjectModalOpen(true);
};
const createNewIssue = () => {
setIsPaletteOpen(false);
setIsIssueModalOpen(true);
};
const createNewCycle = () => {
setIsPaletteOpen(false);
setIsCreateCycleModalOpen(true);
};
const createNewView = () => {
setIsPaletteOpen(false);
setIsCreateViewModalOpen(true);
};
const createNewPage = () => {
setIsPaletteOpen(false);
setIsCreateUpdatePageModalOpen(true);
};
const createNewModule = () => {
setIsPaletteOpen(false);
setIsCreateModuleModalOpen(true);
};
const deleteIssue = () => {
setIsPaletteOpen(false);
setDeleteIssueModal(true);
};
const goToSettings = (path: string = "") => {
setIsPaletteOpen(false);
router.push(`/${workspaceSlug}/settings/${path}`);
};
return (
<>
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
{workspaceSlug && (
<CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} />
)}
{projectId && (
<>
<CreateUpdateCycleModal
isOpen={isCreateCycleModalOpen}
handleClose={() => setIsCreateCycleModalOpen(false)}
/>
<CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen}
setIsOpen={setIsCreateModuleModalOpen}
/>
<CreateUpdateViewModal
handleClose={() => setIsCreateViewModalOpen(false)}
isOpen={isCreateViewModalOpen}
/>
<CreateUpdatePageModal
isOpen={isCreateUpdatePageModalOpen}
handleClose={() => setIsCreateUpdatePageModalOpen(false)}
/>
</>
)}
{issueId && issueDetails && (
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueDetails}
/>
)}
<CreateUpdateIssueModal
isOpen={isIssueModalOpen}
handleClose={() => setIsIssueModalOpen(false)}
/>
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssuesModalOpen}
setIsOpen={setIsBulkDeleteIssuesModalOpen}
/>
<Transition.Root
show={isPaletteOpen}
afterLeave={() => {
setSearchTerm("");
}}
as={React.Fragment}
>
<Dialog as="div" className="relative z-30" onClose={() => setIsPaletteOpen(false)}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-brand-base divide-opacity-10 rounded-xl bg-brand-surface-2 border-brand-base border shadow-2xl transition-all">
<Command
filter={(value, search) => {
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
onKeyDown={(e) => {
// when seach is empty and page is undefined
// when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) {
setIsPaletteOpen(false);
}
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
e.preventDefault();
setPages((pages) => pages.slice(0, -1));
setPlaceholder("Type a command or search...");
}
}}
>
{issueId && issueDetails && (
<div className="flex p-3">
<p className="overflow-hidden truncate rounded-md bg-brand-surface-1 p-1 px-2 text-xs font-medium text-brand-secondary">
{issueDetails.project_detail?.identifier}-{issueDetails.sequence_id}{" "}
{issueDetails?.name}
</p>
</div>
)}
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-secondary"
aria-hidden="true"
/>
<Command.Input
className="w-full border-0 border-b border-brand-base bg-transparent p-4 pl-11 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => {
setSearchTerm(e);
}}
autoFocus
/>
</div>
<Command.List className="max-h-96 overflow-scroll p-2">
{!isLoading &&
resultsCount === 0 &&
searchTerm !== "" &&
debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-brand-secondary">
No results found.
</div>
)}
{(isLoading || isSearching) && (
<Command.Loading>
<div className="flex h-full w-full items-center justify-center py-8">
<Spinner />
</div>
</Command.Loading>
)}
{debouncedSearchTerm !== "" && (
<>
{Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
if (section.length > 0) {
return (
<Command.Group
heading={capitalizeFirstLetter(replaceUnderscoreIfSnakeCase(key))}
key={key}
>
{section.map((item: any) => {
let path = "";
let value = item.name;
let Icon: any = ArrowRightIcon;
if (key === "workspace") {
path = `/${item.slug}`;
Icon = FolderPlusIcon;
} else if (key == "project") {
path = `/${item.workspace__slug}/projects/${item.id}/issues`;
Icon = AssignmentClipboardIcon;
} else if (key === "issue") {
path = `/${item.workspace__slug}/projects/${item.project_id}/issues/${item.id}`;
// user can search id-num idnum or issue name
value = `${item.project__identifier}-${item.sequence_id} ${item.project__identifier}${item.sequence_id} ${item.name}`;
Icon = LayerDiagonalIcon;
} else if (key === "issue_view") {
path = `/${item.workspace__slug}/projects/${item.project_id}/views/${item.id}`;
Icon = ViewListIcon;
} else if (key === "module") {
path = `/${item.workspace__slug}/projects/${item.project_id}/modules/${item.id}`;
Icon = PeopleGroupIcon;
} else if (key === "page") {
path = `/${item.workspace__slug}/projects/${item.project_id}/pages/${item.id}`;
Icon = DocumentTextIcon;
} else if (key === "cycle") {
path = `/${item.workspace__slug}/projects/${item.project_id}/cycles/${item.id}`;
Icon = ContrastIcon;
}
return (
<Command.Item
key={item.id}
onSelect={() => {
router.push(path);
setIsPaletteOpen(false);
}}
value={value}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-brand-secondary">
<Icon
className="h-4 w-4 text-brand-secondary"
color="#6b7280"
/>
<p className="block flex-1 truncate">{item.name}</p>
</div>
</Command.Item>
);
})}
</Command.Group>
);
}
})}
</>
)}
{!page && (
<>
{issueId && (
<>
<Command.Item
onSelect={() => {
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<Squares2X2Icon className="h-4 w-4 text-brand-secondary" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<ChartBarIcon className="h-4 w-4 text-brand-secondary" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<UsersIcon className="h-4 w-4 text-brand-secondary" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(user.id);
setSearchTerm("");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
{issueDetails?.assignees.includes(user.id) ? (
<>
<UserMinusIcon className="h-4 w-4 text-brand-secondary" />
Un-assign from me
</>
) : (
<>
<UserPlusIcon className="h-4 w-4 text-brand-secondary" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-brand-secondary">
<TrashIcon className="h-4 w-4 text-brand-secondary" />
Delete issue
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
copyIssueUrlToClipboard();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<LinkIcon className="h-4 w-4 text-brand-secondary" />
Copy issue URL to clipboard
</div>
</Command.Item>
</>
)}
<Command.Group heading="Issue">
<Command.Item
onSelect={createNewIssue}
className="focus:bg-brand-surface-2"
>
<div className="flex items-center gap-2 text-brand-secondary">
<LayerDiagonalIcon className="h-4 w-4" color="#6b7280" />
Create new issue
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
{workspaceSlug && (
<Command.Group heading="Project">
<Command.Item
onSelect={createNewProject}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<AssignmentClipboardIcon className="h-4 w-4" color="#6b7280" />
Create new project
</div>
<kbd>P</kbd>
</Command.Item>
</Command.Group>
)}
{projectId && (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={createNewCycle}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<ContrastIcon className="h-4 w-4" color="#6b7280" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={createNewModule}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<PeopleGroupIcon className="h-4 w-4" color="#6b7280" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item onSelect={createNewView} className="focus:outline-none">
<div className="flex items-center gap-2 text-brand-secondary">
<ViewListIcon className="h-4 w-4" color="#6b7280" />
Create new view
</div>
<kbd>V</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Page">
<Command.Item onSelect={createNewPage} className="focus:outline-none">
<div className="flex items-center gap-2 text-gray-700">
<DocumentTextIcon className="h-4 w-4" color="#6b7280" />
Create new page
</div>
<kbd>D</kbd>
</Command.Item>
</Command.Group>
</>
)}
<Command.Group heading="Workspace Settings">
<Command.Item
onSelect={() => {
setPlaceholder("Search workspace settings...");
setSearchTerm("");
setPages([...pages, "settings"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4" color="#6b7280" />
Search settings...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Account">
<Command.Item
onSelect={createNewWorkspace}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<FolderPlusIcon className="h-4 w-4 text-brand-secondary" />
Create new workspace
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change interface theme...");
setSearchTerm("");
setPages([...pages, "change-interface-theme"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Change interface theme...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", {
key: "h",
});
document.dispatchEvent(e);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<RocketLaunchIcon className="h-4 w-4 text-brand-secondary" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<DocumentIcon className="h-4 w-4 text-brand-secondary" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<DiscordIcon className="h-4 w-4" color="#6b7280" />
Join our Discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open(
"https://github.com/makeplane/plane/issues/new/choose",
"_blank"
);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<GithubIcon className="h-4 w-4" color="#6b7280" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
(window as any).$crisp.push(["do", "chat:open"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<ChatBubbleOvalLeftEllipsisIcon className="h-4 w-4 text-brand-secondary" />
Chat with us
</div>
</Command.Item>
</Command.Group>
</>
)}
{page === "settings" && workspaceSlug && (
<>
<Command.Item
onSelect={() => goToSettings()}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
General
</div>
</Command.Item>
<Command.Item
onSelect={() => goToSettings("members")}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Members
</div>
</Command.Item>
<Command.Item
onSelect={() => goToSettings("billing")}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Billings and Plans
</div>
</Command.Item>
<Command.Item
onSelect={() => goToSettings("integrations")}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Integrations
</div>
</Command.Item>
<Command.Item
onSelect={() => goToSettings("import-export")}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Import/Export
</div>
</Command.Item>
</>
)}
{page === "change-issue-state" && issueDetails && (
<>
<ChangeIssueState
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
/>
</>
)}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
/>
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
/>
)}
{page === "change-interface-theme" && (
<ChangeInterfaceTheme setIsPaletteOpen={setIsPaletteOpen} />
)}
</Command.List>
</Command>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
);
};

View File

@ -0,0 +1,6 @@
export * from "./command-pallette";
export * from "./shortcuts-modal";
export * from "./change-issue-state";
export * from "./change-issue-priority";
export * from "./change-issue-assignee";
export * from "./change-interface-theme";

View File

@ -0,0 +1,198 @@
import React, { useEffect, useState } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// icons
import { XMarkIcon } from "@heroicons/react/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { MacCommandIcon } from "components/icons";
// ui
import { Input } from "components/ui";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const shortcuts = [
{
title: "Navigation",
shortcuts: [
{ keys: "Ctrl,K", description: "To open navigator" },
{ keys: "↑", description: "Move up" },
{ keys: "↓", description: "Move down" },
{ keys: "←", description: "Move left" },
{ keys: "→", description: "Move right" },
{ keys: "Enter", description: "Select" },
{ keys: "Esc", description: "Close" },
],
},
{
title: "Common",
shortcuts: [
{ keys: "P", description: "To create project" },
{ keys: "C", description: "To create issue" },
{ keys: "Q", description: "To create cycle" },
{ keys: "M", description: "To create module" },
{ keys: "V", description: "To create view" },
{ keys: "D", description: "To create page" },
{ keys: "Delete", description: "To bulk delete issues" },
{ keys: "H", description: "To open shortcuts guide" },
{
keys: "Ctrl,Alt,C",
description: "To copy issue url when on issue detail page",
},
],
},
];
const allShortcuts = shortcuts.map((i) => i.shortcuts).flat(1);
export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const [query, setQuery] = useState("");
const filteredShortcuts = allShortcuts.filter((shortcut) =>
shortcut.description.toLowerCase().includes(query.trim().toLowerCase()) || query === ""
? true
: false
);
useEffect(() => {
if (!isOpen) setQuery("");
}, [isOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={setIsOpen}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-brand-surface-2 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-brand-surface-2 p-5">
<div className="sm:flex sm:items-start">
<div className="flex w-full flex-col gap-y-4 text-center sm:text-left">
<Dialog.Title
as="h3"
className="flex justify-between text-lg font-medium leading-6 text-brand-base"
>
<span>Keyboard Shortcuts</span>
<span>
<button type="button" onClick={() => setIsOpen(false)}>
<XMarkIcon
className="h-6 w-6 text-gray-400 hover:text-brand-secondary"
aria-hidden="true"
/>
</button>
</span>
</Dialog.Title>
<div>
<div className="flex w-full items-center justify-start gap-1 rounded border-[0.6px] border-brand-base bg-brand-surface-1 px-3 py-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-brand-secondary" />
<Input
className="w-full border-none bg-transparent py-1 px-2 text-xs text-brand-secondary focus:outline-none"
id="search"
name="search"
type="text"
placeholder="Search for shortcuts"
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<div className="flex w-full flex-col gap-y-3">
{query.trim().length > 0 ? (
filteredShortcuts.length > 0 ? (
filteredShortcuts.map((shortcut) => (
<div key={shortcut.keys} className="flex w-full flex-col">
<div className="flex flex-col gap-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-brand-secondary">{shortcut.description}</p>
<div className="flex items-center gap-x-2.5">
{shortcut.keys.split(",").map((key, index) => (
<span key={index} className="flex items-center gap-1">
{key === "Ctrl" ? (
<span className="flex h-full items-center rounded-sm border border-brand-base bg-brand-surface-1 p-2">
<MacCommandIcon />
</span>
) : (
<kbd className="rounded-sm border border-brand-base bg-brand-surface-1 px-2 py-1 text-sm font-medium text-gray-800">
{key === "Ctrl" ? <MacCommandIcon /> : key}
</kbd>
)}
</span>
))}
</div>
</div>
</div>
</div>
))
) : (
<div className="flex flex-col gap-y-3">
<p className="text-sm text-brand-secondary">
No shortcuts found for{" "}
<span className="font-semibold italic">
{`"`}
{query}
{`"`}
</span>
</p>
</div>
)
) : (
shortcuts.map(({ title, shortcuts }) => (
<div key={title} className="flex w-full flex-col">
<p className="mb-4 font-medium">{title}</p>
<div className="flex flex-col gap-y-3">
{shortcuts.map(({ keys, description }, index) => (
<div key={index} className="flex items-center justify-between">
<p className="text-sm text-brand-secondary">{description}</p>
<div className="flex items-center gap-x-2.5">
{keys.split(",").map((key, index) => (
<span key={index} className="flex items-center gap-1">
{key === "Ctrl" ? (
<span className="flex h-full items-center rounded-sm border border-brand-base text-brand-secondary bg-brand-surface-1 p-2">
<MacCommandIcon />
</span>
) : (
<kbd className="rounded-sm border border-brand-base bg-brand-surface-1 px-2 py-1 text-sm font-medium text-brand-secondary">
{key === "Ctrl" ? <MacCommandIcon /> : key}
</kbd>
)}
</span>
))}
</div>
</div>
))}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,106 @@
// hooks
import useProjectIssuesView from "hooks/use-issues-view";
// components
import { SingleBoard } from "components/core/board-view/single-board";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IState, UserAuth } from "types";
import { getStateGroupIcon } from "components/icons";
type Props = {
type: "issue" | "cycle" | "module";
states: IState[] | undefined;
addIssueToState: (groupTitle: string) => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
handleTrashBox: (isDragging: boolean) => void;
removeIssue: ((bridgeId: string) => void) | null;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const AllBoards: React.FC<Props> = ({
type,
states,
addIssueToState,
makeIssueCopy,
handleEditIssue,
openIssuesListModal,
handleDeleteIssue,
handleTrashBox,
removeIssue,
isCompleted = false,
userAuth,
}) => {
const {
groupedByIssues,
groupByProperty: selectedGroup,
showEmptyGroups,
} = useProjectIssuesView();
return (
<>
{groupedByIssues ? (
<div className="horizontal-scroll-enable flex h-[calc(100vh-140px)] gap-x-4">
{Object.keys(groupedByIssues).map((singleGroup, index) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
return (
<SingleBoard
key={index}
type={type}
currentState={currentState}
groupTitle={singleGroup}
handleEditIssue={handleEditIssue}
makeIssueCopy={makeIssueCopy}
addIssueToState={() => addIssueToState(singleGroup)}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={openIssuesListModal ?? null}
handleTrashBox={handleTrashBox}
removeIssue={removeIssue}
isCompleted={isCompleted}
userAuth={userAuth}
/>
);
})}
{!showEmptyGroups && (
<div className="h-full w-96 flex-shrink-0 space-y-3 p-1">
<h2 className="text-lg font-semibold">Hidden groups</h2>
<div className="space-y-3">
{Object.keys(groupedByIssues).map((singleGroup, index) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (groupedByIssues[singleGroup].length === 0)
return (
<div
key={index}
className="flex items-center justify-between gap-2 rounded bg-brand-surface-1 p-2 shadow"
>
<div className="flex items-center gap-2">
{currentState &&
getStateGroupIcon(currentState.group, "16", "16", currentState.color)}
<h4 className="text-sm capitalize">
{selectedGroup === "state"
? addSpaceIfCamelCase(currentState?.name ?? "")
: addSpaceIfCamelCase(singleGroup)}
</h4>
</div>
<span className="text-xs text-brand-secondary">0</span>
</div>
);
})}
</div>
</div>
)}
</div>
) : null}
</>
);
};

View File

@ -0,0 +1,181 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import issuesService from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useIssuesView from "hooks/use-issues-view";
// component
import { Avatar } from "components/ui";
// icons
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssueLabels, IState } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
currentState?: IState | null;
groupTitle: string;
addIssueToState: () => void;
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
isCompleted?: boolean;
};
export const BoardHeader: React.FC<Props> = ({
currentState,
groupTitle,
addIssueToState,
isCollapsed,
setIsCollapsed,
isCompleted = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView();
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
let bgColor = "#000000";
if (selectedGroup === "state") bgColor = currentState?.color ?? "#000000";
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle);
switch (selectedGroup) {
case "state":
title = addSpaceIfCamelCase(currentState?.name ?? "");
break;
case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
break;
}
return title;
};
const getGroupIcon = () => {
let icon;
switch (selectedGroup) {
case "state":
icon = currentState && getStateGroupIcon(currentState.group, "16", "16", bgColor);
break;
case "priority":
icon = getPriorityIcon(groupTitle, "text-lg");
break;
case "labels":
const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
icon = (
<span
className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: labelColor }}
/>
);
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
icon = <Avatar user={member} height="24px" width="24px" fontSize="12px" />;
break;
}
return icon;
};
return (
<div
className={`flex items-center justify-between px-1 ${
!isCollapsed ? "flex-col rounded-md bg-brand-surface-1" : ""
}`}
>
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
<div
className={`flex cursor-pointer items-center gap-x-3 ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
}`}
>
<span className="flex items-center">{getGroupIcon()}</span>
<h2
className="text-lg font-semibold capitalize"
style={{
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
}}
>
{getGroupTitle()}
</h2>
<span
className={`${
isCollapsed ? "ml-0.5" : ""
} min-w-[2.5rem] rounded-full bg-brand-surface-2 py-1 text-center text-xs`}
>
{groupedByIssues?.[groupTitle].length ?? 0}
</span>
</div>
</div>
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 text-brand-secondary outline-none duration-300 hover:bg-brand-surface-2"
onClick={() => {
setIsCollapsed((prevData) => !prevData);
}}
>
{isCollapsed ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-4 w-4" />
)}
</button>
{!isCompleted && (
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 text-brand-secondary outline-none duration-300 hover:bg-brand-surface-2"
onClick={addIssueToState}
>
<PlusIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,4 @@
export * from "./all-boards";
export * from "./board-header";
export * from "./single-board";
export * from "./single-issue";

View File

@ -0,0 +1,190 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/router";
// react-beautiful-dnd
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useIssuesProperties from "hooks/use-issue-properties";
// components
import { BoardHeader, SingleBoardIssue } from "components/core";
// ui
import { CustomMenu } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { IIssue, IState, UserAuth } from "types";
type Props = {
type?: "issue" | "cycle" | "module";
currentState?: IState | null;
groupTitle: string;
handleEditIssue: (issue: IIssue) => void;
makeIssueCopy: (issue: IIssue) => void;
addIssueToState: () => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
handleTrashBox: (isDragging: boolean) => void;
removeIssue: ((bridgeId: string) => void) | null;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const SingleBoard: React.FC<Props> = ({
type,
currentState,
groupTitle,
handleEditIssue,
makeIssueCopy,
addIssueToState,
handleDeleteIssue,
openIssuesListModal,
handleTrashBox,
removeIssue,
isCompleted = false,
userAuth,
}) => {
// collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true);
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssuesView();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
useEffect(() => {
if (currentState?.group === "completed" || currentState?.group === "cancelled")
setIsCollapsed(false);
}, [currentState]);
return (
<div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96"}`}>
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
<BoardHeader
addIssueToState={addIssueToState}
currentState={currentState}
groupTitle={groupTitle}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
isCompleted={isCompleted}
/>
{isCollapsed && (
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`relative h-full overflow-y-auto p-1 ${
snapshot.isDraggingOver ? "bg-brand-base/20" : ""
} ${!isCollapsed ? "hidden" : "block"}`}
ref={provided.innerRef}
{...provided.droppableProps}
>
{orderBy !== "sort_order" && (
<>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-brand-surface-1 opacity-50`}
/>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-brand-base p-2 text-xs`}
>
This board is ordered by{" "}
{replaceUnderscoreIfSnakeCase(
orderBy ? (orderBy[0] === "-" ? orderBy.slice(1) : orderBy) : "created_at"
)}
</div>
</>
)}
{groupedByIssues?.[groupTitle].map((issue, index) => (
<Draggable
key={issue.id}
draggableId={issue.id}
index={index}
isDragDisabled={
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "labels"
}
>
{(provided, snapshot) => (
<SingleBoardIssue
key={index}
provided={provided}
snapshot={snapshot}
type={type}
index={index}
selectedGroup={selectedGroup}
issue={issue}
groupTitle={groupTitle}
properties={properties}
editIssue={() => handleEditIssue(issue)}
makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue}
handleTrashBox={handleTrashBox}
removeIssue={() => {
if (removeIssue && issue.bridge_id) removeIssue(issue.bridge_id);
}}
isCompleted={isCompleted}
userAuth={userAuth}
/>
)}
</Draggable>
))}
<span
style={{
display: orderBy === "sort_order" ? "inline" : "none",
}}
>
{provided.placeholder}
</span>
{type === "issue" ? (
<button
type="button"
className="flex items-center gap-2 font-medium text-brand-accent outline-none"
onClick={addIssueToState}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
!isCompleted && (
<CustomMenu
customButton={
<button
type="button"
className="flex items-center gap-2 font-medium text-brand-accent outline-none"
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
}
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)
)}
</div>
)}
</StrictModeDroppable>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,416 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-beautiful-dnd
import {
DraggableProvided,
DraggableStateSnapshot,
DraggingStyle,
NotDraggingStyle,
} from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useToast from "hooks/use-toast";
// components
import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewEstimateSelect,
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues";
// ui
import { ContextMenu, CustomMenu, Tooltip } from "components/ui";
// icons
import {
ClipboardDocumentCheckIcon,
LinkIcon,
PencilIcon,
TrashIcon,
XMarkIcon,
ArrowTopRightOnSquareIcon,
PaperClipIcon,
} from "@heroicons/react/24/outline";
// helpers
import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { IIssue, Properties, TIssueGroupByOptions, UserAuth } from "types";
// fetch-keys
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
} from "constants/fetch-keys";
type Props = {
type?: string;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue;
properties: Properties;
groupTitle?: string;
index: number;
selectedGroup: TIssueGroupByOptions;
editIssue: () => void;
makeIssueCopy: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
handleTrashBox: (isDragging: boolean) => void;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const SingleBoardIssue: React.FC<Props> = ({
type,
provided,
snapshot,
issue,
properties,
index,
selectedGroup,
editIssue,
makeIssueCopy,
removeIssue,
groupTitle,
handleDeleteIssue,
handleTrashBox,
isCompleted = false,
userAuth,
}) => {
// context menu
const [contextMenu, setContextMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
const { orderBy, params } = useIssuesView();
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (cycleId)
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
(prevData) =>
handleIssuesMutation(
formData,
groupTitle ?? "",
selectedGroup,
index,
orderBy,
prevData
),
false
);
else if (moduleId)
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
MODULE_ISSUES_WITH_PARAMS(moduleId as string),
(prevData) =>
handleIssuesMutation(
formData,
groupTitle ?? "",
selectedGroup,
index,
orderBy,
prevData
),
false
);
else {
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
(prevData) => {
if (!prevData) return prevData;
return handleIssuesMutation(
formData,
groupTitle ?? "",
selectedGroup,
index,
orderBy,
prevData
);
},
false
);
}
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then(() => {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
mutate(CYCLE_DETAILS(cycleId as string));
} else if (moduleId) {
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
mutate(MODULE_DETAILS(moduleId as string));
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
})
.catch((error) => {
console.log(error);
});
},
[
workspaceSlug,
projectId,
cycleId,
moduleId,
issue,
groupTitle,
index,
selectedGroup,
orderBy,
params,
]
);
const getStyle = (
style: DraggingStyle | NotDraggingStyle | undefined,
snapshot: DraggableStateSnapshot
) => {
if (orderBy === "sort_order") return style;
if (!snapshot.isDragging) return {};
if (!snapshot.isDropAnimating) return style;
return {
...style,
transitionDuration: `0.001s`,
};
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
useEffect(() => {
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
}, [snapshot, handleTrashBox]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
return (
<>
<ContextMenu
position={contextMenuPosition}
title="Quick actions"
isOpen={contextMenu}
setIsOpen={setContextMenu}
>
{!isNotAllowed && (
<>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
Edit issue
</ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
Delete issue
</ContextMenu.Item>
</>
)}
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</ContextMenu.Item>
<a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
</a>
</ContextMenu>
<div
className={`mb-3 rounded bg-brand-base shadow ${
snapshot.isDragging ? "border-2 border-brand-accent shadow-lg" : ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getStyle(provided.draggableProps.style, snapshot)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu(true);
setContextMenuPosition({ x: e.pageX, y: e.pageY });
}}
>
<div className="group/card relative select-none p-3.5">
{!isNotAllowed && (
<div className="z-1 absolute top-1.5 right-1.5 opacity-0 group-hover/card:opacity-100">
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<div className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
<span>Remove from {type}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue Link</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a>
{properties.key && (
<div className="mb-2.5 text-xs font-medium text-brand-secondary">
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
)}
<h5
className="break-all text-sm group-hover:text-brand-accent"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{truncateText(issue.name, 100)}
</h5>
</a>
</Link>
<div className="relative mt-2.5 flex flex-wrap items-center gap-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
selfPositioned
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
selfPositioned
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded-md border border-brand-base px-3 py-1.5 text-xs shadow-sm">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && issue.label_details.length > 0 && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<div
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</div>
))}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
tooltipPosition="left"
selfPositioned
/>
)}
{properties.estimate && (
<ViewEstimateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
selfPositioned
/>
)}
{properties.link && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
<div className="flex items-center gap-1 text-gray-500">
<LinkIcon className="h-3.5 w-3.5 text-gray-500" />
{issue.link_count}
</div>
</Tooltip>
</div>
)}
{properties.attachment_count && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}>
<div className="flex items-center gap-1 text-gray-500">
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45 text-gray-500" />
{issue.attachment_count}
</div>
</Tooltip>
</div>
)}
</div>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,221 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react hook form
import { SubmitHandler, useForm } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import issuesServices from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { DangerButton, SecondaryButton } from "components/ui";
// icons
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// types
import { IIssue } from "types";
// fetch keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type FormInput = {
delete_issue_ids: string[];
};
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const [query, setQuery] = useState("");
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
: null
);
const { setToastAlert } = useToast();
const {
handleSubmit,
watch,
reset,
setValue,
formState: { isSubmitting },
} = useForm<FormInput>({
defaultValues: {
delete_issue_ids: [],
},
});
const filteredIssues: IIssue[] =
query === ""
? issues ?? []
: issues?.filter(
(issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}`
.toLowerCase()
.includes(query.toLowerCase())
) ?? [];
const handleClose = () => {
setIsOpen(false);
setQuery("");
reset();
};
const handleDelete: SubmitHandler<FormInput> = async (data) => {
if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids];
if (workspaceSlug && projectId) {
await issuesServices
.bulkDeleteIssues(workspaceSlug as string, projectId as string, {
issue_ids: data.delete_issue_ids,
})
.then((res) => {
setToastAlert({
title: "Success",
type: "success",
message: res.message,
});
handleClose();
})
.catch((e) => {
console.log(e);
});
}
};
return (
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-brand-surface-2 shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<form>
<Combobox
onChange={(val: string) => {
const selectedIssues = watch("delete_issue_ids");
if (selectedIssues.includes(val))
setValue(
"delete_issue_ids",
selectedIssues.filter((i) => i !== val)
);
else setValue("delete_issue_ids", [...selectedIssues, val]);
}}
>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40"
aria-hidden="true"
/>
<input
type="text"
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-brand-base">
Select issues to delete
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="div"
value={issue.id}
className={({ active }) =>
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-brand-base" : ""
}`
}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={watch("delete_issue_ids").includes(issue.id)}
readOnly
/>
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-brand-secondary">
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</Combobox.Option>
))}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-brand-secondary">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>.
</h3>
</div>
)}
</Combobox.Options>
</Combobox>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleSubmit(handleDelete)} loading={isSubmitting}>
{isSubmitting ? "Deleting..." : "Delete selected issues"}
</DangerButton>
</div>
)}
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,511 @@
import React, { useState } from "react";
import useSWR, { mutate } from "swr";
import Link from "next/link";
import { useRouter } from "next/router";
// helper
import { renderDateFormat } from "helpers/date-time.helper";
import {
startOfWeek,
lastDayOfWeek,
eachDayOfInterval,
weekDayInterval,
formatDate,
getCurrentWeekStartDate,
getCurrentWeekEndDate,
subtractMonths,
addMonths,
updateDateWithYear,
updateDateWithMonth,
isSameMonth,
isSameYear,
subtract7DaysToDate,
addSevenDaysToDate,
} from "helpers/calendar.helper";
// ui
import { Popover, Transition } from "@headlessui/react";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CustomMenu, Spinner } from "components/ui";
// icon
import {
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
PlusIcon,
} from "@heroicons/react/24/outline";
// hooks
import useIssuesView from "hooks/use-issues-view";
// services
import issuesService from "services/issues.service";
import cyclesService from "services/cycles.service";
// fetch key
import {
CYCLE_CALENDAR_ISSUES,
MODULE_CALENDAR_ISSUES,
PROJECT_CALENDAR_ISSUES,
} from "constants/fetch-keys";
// type
import { IIssue } from "types";
// constant
import { monthOptions, yearOptions } from "constants/calendar";
import modulesService from "services/modules.service";
type Props = {
addIssueToDate: (date: string) => void;
};
interface ICalendarRange {
startDate: Date;
endDate: Date;
}
export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
const [showWeekEnds, setShowWeekEnds] = useState<boolean>(false);
const [currentDate, setCurrentDate] = useState<Date>(new Date());
const [isMonthlyView, setIsMonthlyView] = useState<boolean>(true);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { params } = useIssuesView();
const [calendarDateRange, setCalendarDateRange] = useState<ICalendarRange>({
startDate: startOfWeek(currentDate),
endDate: lastDayOfWeek(currentDate),
});
const targetDateFilter = {
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat(
calendarDateRange.endDate
)};before`,
};
const { data: projectCalendarIssues } = useSWR(
workspaceSlug && projectId ? PROJECT_CALENDAR_ISSUES(projectId as string) : null,
workspaceSlug && projectId
? () =>
issuesService.getIssuesWithParams(workspaceSlug as string, projectId as string, {
...params,
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat(
calendarDateRange.endDate
)};before`,
group_by: null,
})
: null
);
const { data: cycleCalendarIssues } = useSWR(
workspaceSlug && projectId && cycleId
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
: null,
workspaceSlug && projectId && cycleId
? () =>
cyclesService.getCycleIssuesWithParams(
workspaceSlug as string,
projectId as string,
cycleId as string,
{
...params,
target_date: `${renderDateFormat(
calendarDateRange.startDate
)};after,${renderDateFormat(calendarDateRange.endDate)};before`,
group_by: null,
}
)
: null
);
const { data: moduleCalendarIssues } = useSWR(
workspaceSlug && projectId && moduleId
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
: null,
workspaceSlug && projectId && moduleId
? () =>
modulesService.getModuleIssuesWithParams(
workspaceSlug as string,
projectId as string,
moduleId as string,
{
...params,
target_date: `${renderDateFormat(
calendarDateRange.startDate
)};after,${renderDateFormat(calendarDateRange.endDate)};before`,
group_by: null,
}
)
: null
);
const totalDate = eachDayOfInterval({
start: calendarDateRange.startDate,
end: calendarDateRange.endDate,
});
const onlyWeekDays = weekDayInterval({
start: calendarDateRange.startDate,
end: calendarDateRange.endDate,
});
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
const calendarIssues = cycleId
? cycleCalendarIssues
: moduleId
? moduleCalendarIssues
: projectCalendarIssues;
const currentViewDaysData = currentViewDays.map((date: Date) => {
const filterIssue =
calendarIssues && calendarIssues.length > 0
? (calendarIssues as IIssue[]).filter(
(issue) =>
issue.target_date && renderDateFormat(issue.target_date) === renderDateFormat(date)
)
: [];
return {
date: renderDateFormat(date),
issues: filterIssue,
};
});
const weeks = ((date: Date[]) => {
const weeks = [];
if (showWeekEnds) {
for (let day = 0; day <= 6; day++) {
weeks.push(date[day]);
}
} else {
for (let day = 0; day <= 4; day++) {
weeks.push(date[day]);
}
}
return weeks;
})(currentViewDays);
const onDragEnd = (result: DropResult) => {
const { source, destination, draggableId } = result;
if (!destination || !workspaceSlug || !projectId) return;
if (source.droppableId === destination.droppableId) return;
const fetchKey = cycleId
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
: moduleId
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
: PROJECT_CALENDAR_ISSUES(projectId as string);
mutate<IIssue[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === draggableId)
return {
...p,
target_date: destination.droppableId,
};
return p;
}),
false
);
issuesService.patchIssue(workspaceSlug as string, projectId as string, draggableId, {
target_date: destination?.droppableId,
});
};
const updateDate = (date: Date) => {
setCurrentDate(date);
setCalendarDateRange({
startDate: startOfWeek(date),
endDate: lastDayOfWeek(date),
});
};
return calendarIssues ? (
<DragDropContext onDragEnd={onDragEnd}>
<div className="-m-2 h-full overflow-y-auto rounded-lg text-brand-secondary">
<div className="mb-4 flex items-center justify-between">
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
<Popover className="flex h-full items-center justify-start rounded-lg">
{({ open }) => (
<>
<Popover.Button className={`group flex h-full items-start gap-1 text-brand-base`}>
<div className="flex items-center justify-center gap-2 text-2xl font-semibold">
<span>{formatDate(currentDate, "Month")}</span>{" "}
<span>{formatDate(currentDate, "yyyy")}</span>
</div>
</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 top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-brand-surface-2 shadow-lg">
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
{yearOptions.map((year) => (
<button
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-brand-base"
: "text-xs text-brand-secondary "
} hover:text-sm hover:font-medium hover:text-brand-base`}
>
{year.label}
</button>
))}
</div>
<div className="grid grid-cols-4 border-t border-brand-base px-2">
{monthOptions.map((month) => (
<button
onClick={() =>
updateDate(updateDateWithMonth(month.value, currentDate))
}
className={`px-2 py-2 text-xs text-brand-secondary hover:font-medium hover:text-brand-base ${
isSameMonth(month.value, currentDate)
? "font-medium text-brand-base"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<div className="flex items-center gap-2">
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(subtractMonths(currentDate, 1));
} else {
setCurrentDate(subtract7DaysToDate(currentDate));
setCalendarDateRange({
startDate: getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
endDate: getCurrentWeekEndDate(subtract7DaysToDate(currentDate)),
});
}
}}
>
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(addMonths(currentDate, 1));
} else {
setCurrentDate(addSevenDaysToDate(currentDate));
setCalendarDateRange({
startDate: getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
endDate: getCurrentWeekEndDate(addSevenDaysToDate(currentDate)),
});
}
}}
>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex w-full items-center justify-end gap-2">
<button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 px-4 py-1.5 text-sm hover:bg-brand-surface-1 hover:text-brand-base focus:outline-none"
onClick={() => {
if (isMonthlyView) {
updateDate(new Date());
} else {
setCurrentDate(new Date());
setCalendarDateRange({
startDate: getCurrentWeekStartDate(new Date()),
endDate: getCurrentWeekEndDate(new Date()),
});
}
}}
>
Today{" "}
</button>
<CustomMenu
customButton={
<div
className={`group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 px-3 py-1.5 text-sm hover:bg-brand-surface-1 hover:text-brand-base focus:outline-none `}
>
{isMonthlyView ? "Monthly" : "Weekly"}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(true);
setCalendarDateRange({
startDate: startOfWeek(currentDate),
endDate: lastDayOfWeek(currentDate),
});
}}
className="w-52 text-sm text-brand-secondary"
>
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
<span className="flex items-center gap-2">Monthly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${
isMonthlyView ? "opacity-100" : "opacity-0"
}`}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(false);
setCalendarDateRange({
startDate: getCurrentWeekStartDate(currentDate),
endDate: getCurrentWeekEndDate(currentDate),
});
}}
className="w-52 text-sm text-brand-secondary"
>
<div className="flex w-full items-center justify-between gap-2">
<span className="flex items-center gap-2">Weekly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${
isMonthlyView ? "opacity-0" : "opacity-100"
}`}
/>
</div>
</CustomMenu.MenuItem>
<div className="mt-1 flex w-52 items-center justify-between border-t border-brand-base py-2 px-1 text-sm text-brand-secondary">
<h4>Show weekends</h4>
<button
type="button"
className={`relative inline-flex h-3.5 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
showWeekEnds ? "bg-green-500" : "bg-brand-surface-2"
}`}
role="switch"
aria-checked={showWeekEnds}
onClick={() => setShowWeekEnds(!showWeekEnds)}
>
<span className="sr-only">Show weekends</span>
<span
aria-hidden="true"
className={`inline-block h-2.5 w-2.5 transform rounded-full bg-brand-surface-2 shadow ring-0 transition duration-200 ease-in-out ${
showWeekEnds ? "translate-x-2.5" : "translate-x-0"
}`}
/>
</button>
</div>
</CustomMenu>
</div>
</div>
<div
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
}`}
>
{weeks.map((date, index) => (
<div
key={index}
className={`flex items-center justify-start gap-2 border-brand-base bg-brand-surface-1 p-1.5 text-base font-medium text-brand-secondary ${
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`}
>
<span>
{isMonthlyView ? formatDate(date, "eee").substring(0, 3) : formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
</div>
))}
</div>
<div
className={`grid h-full auto-rows-[minmax(150px,1fr)] ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `}
>
{currentViewDaysData.map((date, index) => (
<StrictModeDroppable droppableId={date.date}>
{(provided, snapshot) => (
<div
key={index}
ref={provided.innerRef}
{...provided.droppableProps}
className={`group flex flex-col gap-1.5 border-t border-brand-base p-2.5 text-left text-sm font-medium hover:bg-brand-surface-1 ${
showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
}`}
>
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>}
{date.issues.length > 0 &&
date.issues.map((issue: IIssue, index) => (
<Draggable draggableId={issue.id} index={index}>
{(provided, snapshot) => (
<div
key={index}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={`w-full cursor-pointer truncate rounded bg-brand-surface-2 p-1.5 hover:scale-105 ${
snapshot.isDragging ? "shadow-lg" : ""
}`}
>
<Link
href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}
className="w-full"
>
{issue.name}
</Link>
</div>
)}
</Draggable>
))}
<div className="flex items-center justify-center p-1.5 text-sm text-brand-secondary opacity-0 group-hover:opacity-100">
<button
className="flex items-center justify-center gap-2 text-center"
onClick={() => addIssueToDate(date.date)}
>
<PlusIcon className="h-4 w-4 text-brand-secondary" />
Add new issue
</button>
</div>
{provided.placeholder}
</div>
)}
</StrictModeDroppable>
))}
</div>
</div>
</DragDropContext>
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
);
};

View File

@ -0,0 +1 @@
export * from "./calendar"

View File

@ -0,0 +1,267 @@
import { useEffect, useState } from "react";
// react-hook-form
import { useForm } from "react-hook-form";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
const defaultValues = {
palette: "",
};
export const ThemeForm: React.FC<any> = ({ handleFormSubmit, handleClose, status, data }) => {
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<any>({
defaultValues,
});
const [darkPalette, setDarkPalette] = useState(false);
const handleUpdateTheme = async (formData: any) => {
await handleFormSubmit({ ...formData, darkPalette });
reset({
...defaultValues,
});
};
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
// --color-bg-base: 25, 27, 27;
// --color-bg-surface-1: 31, 32, 35;
// --color-bg-surface-2: 39, 42, 45;
// --color-border: 46, 50, 52;
// --color-bg-sidebar: 19, 20, 22;
// --color-accent: 60, 133, 217;
// --color-text-base: 255, 255, 255;
// --color-text-secondary: 142, 148, 146;
return (
<form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5">
<h3 className="text-lg font-medium leading-6 text-brand-base">Customize your theme</h3>
<div className="space-y-4">
<div className="mt-6 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
<div className="sm:col-span-2">
<Input
id="bgBase"
label="Background"
name="bgBase"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.bgBase}
register={register}
validations={{
required: "Background color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Background color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-2">
<Input
id="bgSurface1"
label="Background surface 1"
name="bgSurface1"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.bgSurface1}
register={register}
validations={{
required: "Background surface 1 color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Background surface 1 color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-2">
<Input
id="bgSurface2"
label="Background surface 2"
name="bgSurface1"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.bgSurface1}
register={register}
validations={{
required: "Background surface 2 color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Background surface 2 color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-2">
<Input
id="border"
label="Border"
name="border"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.border}
register={register}
validations={{
required: "Border color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Border color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-2">
<Input
id="sidebar"
label="Sidebar"
name="sidebar"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.sidebar}
register={register}
validations={{
required: "Sidebar color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Sidebar color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-2">
<Input
id="accent"
label="Accent"
name="accent"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.accent}
register={register}
validations={{
required: "Accent color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Accent color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-3">
<Input
id="textBase"
label="Text primary"
name="textBase"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.textBase}
register={register}
validations={{
required: "Text primary color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Text primary color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-3">
<Input
id="textSecondary"
label="Text secondary"
name="textSecondary"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.textSecondary}
register={register}
validations={{
required: "Text secondary color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Text secondary color should be hex format",
},
}}
/>
</div>
</div>
<div>
<Input
id="palette"
label="All colors"
name="palette"
type="name"
placeholder="Enter comma separated hex colors"
autoComplete="off"
error={errors.palette}
register={register}
validations={{
required: "Color values is required",
pattern: {
value: /^(#(?:[0-9a-fA-F]{3}){1,2},){7}#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Color values should be hex format, separated by commas",
},
}}
/>
</div>
<div
className="flex cursor-pointer items-center gap-1"
onClick={() => setDarkPalette((prevData) => !prevData)}
>
<span className="text-xs">Dark palette</span>
<button
type="button"
className={`pointer-events-none relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent ${
darkPalette ? "bg-brand-accent" : "bg-gray-300"
} transition-colors duration-300 ease-in-out focus:outline-none`}
role="switch"
aria-checked="false"
>
<span className="sr-only">Dark palette</span>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-3 w-3 ${
darkPalette ? "translate-x-3" : "translate-x-0"
} transform rounded-full bg-brand-surface-2 shadow ring-0 transition duration-300 ease-in-out`}
/>
</button>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{status
? isSubmitting
? "Updating Theme..."
: "Update Theme"
: isSubmitting
? "Creating Theme..."
: "Set Theme"}
</PrimaryButton>
</div>
</form>
);
};

View File

@ -0,0 +1,65 @@
import React from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// components
import { ThemeForm } from "./custom-theme-form";
// helpers
import { applyTheme } from "helpers/theme.helper";
// fetch-keys
type Props = {
isOpen: boolean;
handleClose: () => void;
};
export const CustomThemeModal: React.FC<Props> = ({ isOpen, handleClose }) => {
const onClose = () => {
handleClose();
};
const handleFormSubmit = async (formData: any) => {
applyTheme(formData.palette, formData.darkPalette);
onClose();
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-brand-surface-1 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<ThemeForm
handleClose={handleClose}
handleFormSubmit={handleFormSubmit}
status={false}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,224 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// types
import { IIssue } from "types";
// fetch-keys
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
} from "constants/fetch-keys";
type FormInput = {
issues: string[];
};
type Props = {
isOpen: boolean;
handleClose: () => void;
issues: IIssue[];
handleOnSubmit: any;
};
export const ExistingIssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
issues,
handleOnSubmit,
}) => {
const [query, setQuery] = useState("");
const router = useRouter();
const { cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
const { params } = useIssuesView();
const handleClose = () => {
onClose();
setQuery("");
reset();
};
const {
handleSubmit,
reset,
control,
formState: { isSubmitting },
} = useForm<FormInput>({
defaultValues: {
issues: [],
},
});
const onSubmit: SubmitHandler<FormInput> = async (data) => {
if (!data.issues || data.issues.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
await handleOnSubmit(data);
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
mutate(CYCLE_DETAILS(cycleId as string));
}
if (moduleId) {
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
mutate(MODULE_DETAILS(moduleId as string));
}
handleClose();
setToastAlert({
title: "Success",
type: "success",
message: `Issue${data.issues.length > 1 ? "s" : ""} added successfully`,
});
};
const filteredIssues: IIssue[] =
query === ""
? issues ?? []
: issues.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? [];
return (
<>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-brand-surface-2 shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<form>
<Controller
control={control}
name="issues"
render={({ field }) => (
<Combobox as="div" {...field} multiple>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mb-2 px-3 text-xs font-semibold text-brand-base">
Select issues to add
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={issue.id}
className={({ active }) =>
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-brand-base" : ""
}`
}
>
{({ selected }) => (
<>
<input type="checkbox" checked={selected} readOnly />
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-brand-secondary">
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
{issue.name}
</>
)}
</Combobox.Option>
))}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-brand-secondary">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>.
</h3>
</div>
)}
</Combobox.Options>
</Combobox>
)}
/>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Adding..." : "Add selected issues"}
</PrimaryButton>
</div>
)}
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
);
};

View File

@ -0,0 +1,316 @@
import React from "react";
import Image from "next/image";
// icons
import {
ArrowTopRightOnSquareIcon,
CalendarDaysIcon,
ChartBarIcon,
ChatBubbleBottomCenterTextIcon,
ChatBubbleLeftEllipsisIcon,
LinkIcon,
PaperClipIcon,
PlayIcon,
RectangleGroupIcon,
Squares2X2Icon,
TrashIcon,
UserIcon,
} from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons";
// helpers
import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import RemirrorRichTextEditor from "components/rich-text-editor";
import Link from "next/link";
const activityDetails: {
[key: string]: {
message?: string;
icon: JSX.Element;
};
} = {
assignee: {
message: "removed the assignee",
icon: <UserGroupIcon className="h-3 w-3" color="#6b7280" aria-hidden="true" />,
},
assignees: {
message: "added a new assignee",
icon: <UserGroupIcon className="h-3 w-3" color="#6b7280" aria-hidden="true" />,
},
blocks: {
message: "marked this issue being blocked by",
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
},
blocking: {
message: "marked this issue is blocking",
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
cycles: {
message: "set the cycle to",
icon: <CyclesIcon height="12" width="12" color="#6b7280" />,
},
labels: {
icon: <TagIcon height="12" width="12" color="#6b7280" />,
},
modules: {
message: "set the module to",
icon: <RectangleGroupIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
},
state: {
message: "set the state to",
icon: <Squares2X2Icon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
},
priority: {
message: "set the priority to",
icon: <ChartBarIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
},
name: {
message: "set the name to",
icon: <ChatBubbleBottomCenterTextIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
},
description: {
message: "updated the description.",
icon: <ChatBubbleBottomCenterTextIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
},
estimate_point: {
message: "set the estimate point to",
icon: <PlayIcon className="h-3 w-3 text-gray-500 -rotate-90" aria-hidden="true" />,
},
target_date: {
message: "set the due date to",
icon: <CalendarDaysIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
},
parent: {
message: "set the parent to",
icon: <UserIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
},
issue: {
message: "deleted the issue.",
icon: <TrashIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
},
estimate: {
message: "updated the estimate",
icon: <PlayIcon className="h-3 w-3 text-gray-500 -rotate-90" aria-hidden="true" />,
},
link: {
message: "updated the link",
icon: <LinkIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
},
attachment: {
message: "updated the attachment",
icon: <PaperClipIcon className="h-3 w-3 text-gray-500 " aria-hidden="true" />,
},
};
export const Feeds: React.FC<any> = ({ activities }) => (
<div>
<ul role="list" className="-mb-4">
{activities.map((activity: any, activityIdx: number) => {
// determines what type of action is performed
let action = activityDetails[activity.field as keyof typeof activityDetails]?.message;
if (activity.field === "labels") {
action = activity.new_value !== "" ? "added a new label" : "removed the label";
} else if (activity.field === "blocking") {
action =
activity.new_value !== ""
? "marked this issue is blocking"
: "removed the issue from blocking";
} else if (activity.field === "blocks") {
action =
activity.new_value !== "" ? "marked this issue being blocked by" : "removed blocker";
} else if (activity.field === "target_date") {
action =
activity.new_value && activity.new_value !== ""
? "set the due date to"
: "removed the due date";
} else if (activity.field === "parent") {
action =
activity.new_value && activity.new_value !== ""
? "set the parent to"
: "removed the parent";
} else if (activity.field === "priority") {
action =
activity.new_value && activity.new_value !== ""
? "set the priority to"
: "removed the priority";
} else if (activity.field === "description") {
action = "updated the";
} else if (activity.field === "attachment") {
action = `${activity.verb} the`;
} else if (activity.field === "link") {
action = `${activity.verb} the`;
}
// for values that are after the action clause
let value: any = activity.new_value ? activity.new_value : activity.old_value;
if (
activity.verb === "created" &&
activity.field !== "cycles" &&
activity.field !== "modules" &&
activity.field !== "attachment" &&
activity.field !== "link" &&
activity.field !== "estimate"
) {
const { workspace_detail, project, issue } = activity;
value = (
<span className="text-gray-600">
created{" "}
<Link href={`/${workspace_detail.slug}/projects/${project}/issues/${issue}`}>
<a className="inline-flex items-center hover:underline">
this issue. <ArrowTopRightOnSquareIcon className="h-3.5 w-3.5 ml-1" />
</a>
</Link>
</span>
);
} else if (activity.field === "state") {
value = activity.new_value ? addSpaceIfCamelCase(activity.new_value) : "None";
} else if (activity.field === "labels") {
let name;
let id = "#000000";
if (activity.new_value !== "") {
name = activity.new_value;
id = activity.new_identifier ? activity.new_identifier : id;
} else {
name = activity.old_value;
id = activity.old_identifier ? activity.old_identifier : id;
}
value = name;
} else if (activity.field === "assignees") {
value = activity.new_value;
} else if (activity.field === "target_date") {
const date =
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value;
value = renderShortNumericDateFormat(date as string);
} else if (activity.field === "description") {
value = "description";
} else if (activity.field === "attachment") {
value = "attachment";
} else if (activity.field === "link") {
value = "link";
} else if (activity.field === "estimate_point") {
value = activity.new_value
? activity.new_value + ` Point${parseInt(activity.new_value ?? "", 10) > 1 ? "s" : ""}`
: "None";
}
if (activity.field === "comment") {
return (
<div key={activity.id}>
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
<Image
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
>
{activity.actor_detail.first_name.charAt(0)}
</div>
)}
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-brand-surface-2 px-0.5 py-px">
<ChatBubbleLeftEllipsisIcon
className="h-3.5 w-3.5 text-gray-400"
aria-hidden="true"
/>
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-xs">
{activity.actor_detail.first_name}
{activity.actor_detail.is_bot ? "Bot" : " " + activity.actor_detail.last_name}
</div>
<p className="mt-0.5 text-xs text-brand-secondary">
Commented {timeAgo(activity.created_at)}
</p>
</div>
<div className="issue-comments-section p-0">
<RemirrorRichTextEditor
value={
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value
}
editable={false}
onBlur={() => ({})}
noBorder
customClassName="text-xs bg-brand-surface-1"
/>
</div>
</div>
</div>
</div>
);
}
if ("field" in activity && activity.field !== "updated_by") {
return (
<li key={activity.id}>
<div className="relative pb-1">
{activities.length > 1 && activityIdx !== activities.length - 1 ? (
<span
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-brand-surface-2"
aria-hidden="true"
/>
) : null}
<div className="relative flex items-start space-x-2">
<>
<div>
<div className="relative px-1.5">
<div className="mt-1.5">
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-brand-surface-1 ring-white">
{activity.field ? (
activityDetails[activity.field as keyof typeof activityDetails]?.icon
) : activity.actor_detail.avatar &&
activity.actor_detail.avatar !== "" ? (
<Image
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={24}
width={24}
className="rounded-full"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
>
{activity.actor_detail.first_name.charAt(0)}
</div>
)}
</div>
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-3">
<div className="text-xs text-brand-secondary">
<span className="text-gray font-medium">
{activity.actor_detail.first_name}
{activity.actor_detail.is_bot
? " Bot"
: " " + activity.actor_detail.last_name}
</span>
<span> {action} </span>
<span className="text-xs font-medium text-brand-base"> {value} </span>
<span className="whitespace-nowrap">{timeAgo(activity.created_at)}</span>
</div>
</div>
</>
</div>
</div>
</li>
);
}
})}
</ul>
</div>
);

View File

@ -0,0 +1,350 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// ui
import { Avatar } from "components/ui";
// helpers
import { getStatesList } from "helpers/state.helper";
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// services
import issuesService from "services/issues.service";
import projectService from "services/project.service";
import stateService from "services/state.service";
// types
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATE_LIST } from "constants/fetch-keys";
import { IIssueFilterOptions } from "types";
export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const { data: issueLabels } = useSWR(
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId.toString())
: null
);
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
if (!filters) return <></>;
const nullFilters = Object.keys(filters).filter(
(key) => filters[key as keyof IIssueFilterOptions] === null
);
return (
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
{Object.keys(filters).map((key) => {
if (filters[key as keyof typeof filters] !== null)
return (
<div
key={key}
className="flex items-center gap-x-2 rounded-full border border-brand-base bg-brand-surface-2 px-2 py-1"
>
<span className="font-medium capitalize text-brand-secondary">
{replaceUnderscoreIfSnakeCase(key)}:
</span>
{filters[key as keyof IIssueFilterOptions] === null ||
(filters[key as keyof IIssueFilterOptions]?.length ?? 0) <= 0 ? (
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
) : Array.isArray(filters[key as keyof IIssueFilterOptions]) ? (
<div className="space-x-2">
{key === "state" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.state?.map((stateId: any) => {
const state = states?.find((s) => s.id === stateId);
return (
<p
key={state?.id}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium text-white"
style={{
color: state?.color,
backgroundColor: `${state?.color}20`,
}}
>
<span>
{getStateGroupIcon(
state?.group ?? "backlog",
"12",
"12",
state?.color
)}
</span>
<span>{state?.name ?? ""}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters(
{
state: filters.state?.filter((s: any) => s !== stateId),
},
!Boolean(viewId)
)
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
);
})}
<button
type="button"
onClick={() =>
setFilters({
state: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : key === "priority" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.priority?.map((priority: any) => (
<p
key={priority}
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium capitalize text-white ${
priority === "urgent"
? "bg-red-100 text-red-600 hover:bg-red-100"
: priority === "high"
? "bg-orange-100 text-orange-500 hover:bg-orange-100"
: priority === "medium"
? "bg-yellow-100 text-yellow-500 hover:bg-yellow-100"
: priority === "low"
? "bg-green-100 text-green-500 hover:bg-green-100"
: "bg-brand-surface-1 text-gray-700 hover:bg-brand-surface-1"
}`}
>
<span>{getPriorityIcon(priority)}</span>
<span>{priority ? priority : "None"}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters(
{
priority: filters.priority?.filter((p: any) => p !== priority),
},
!Boolean(viewId)
)
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
))}
<button
type="button"
onClick={() =>
setFilters({
priority: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : key === "assignees" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.member.id === memberId)?.member;
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full px-1 font-medium capitalize"
>
<Avatar user={member} />
<span>{member?.first_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters(
{
assignees: filters.assignees?.filter(
(p: any) => p !== memberId
),
},
!Boolean(viewId)
)
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})}
<button
type="button"
onClick={() =>
setFilters({
assignees: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : (key as keyof IIssueFilterOptions) === "created_by" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.created_by?.map((memberId: string) => {
const member = members?.find((m) => m.member.id === memberId)?.member;
return (
<div
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full px-1 font-medium capitalize"
>
<Avatar user={member} />
<span>{member?.first_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters(
{
created_by: filters.created_by?.filter(
(p: any) => p !== memberId
),
},
!Boolean(viewId)
)
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})}
<button
type="button"
onClick={() =>
setFilters({
created_by: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : key === "labels" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.labels?.map((labelId: string) => {
const label = issueLabels?.find((l) => l.id === labelId);
if (!label) return null;
const color = label.color !== "" ? label.color : "#0f172a";
return (
<div
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium"
style={{
background: `${color}33`, // add 20% opacity
}}
key={labelId}
>
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: color,
}}
/>
<span
style={{
color: color,
}}
>
{label.name}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters(
{
labels: filters.labels?.filter((l: any) => l !== labelId),
},
!Boolean(viewId)
)
}
>
<XMarkIcon
className="h-3 w-3"
style={{
color: color,
}}
/>
</span>
</div>
);
})}
<button
type="button"
onClick={() =>
setFilters({
labels: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : (
(filters[key as keyof IIssueFilterOptions] as any)?.join(", ")
)}
</div>
) : (
<div className="flex items-center gap-x-1 capitalize">
{filters[key as keyof typeof filters]}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
)}
</div>
);
})}
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<button
type="button"
onClick={() =>
setFilters({
type: null,
state: null,
priority: null,
assignees: null,
labels: null,
created_by: null,
})
}
className="flex items-center gap-x-1 rounded-full border border-brand-base bg-brand-surface-2 px-3 py-1.5 text-xs"
>
<span className="font-medium">Clear all filters</span>
<XMarkIcon className="h-4 w-4" />
</button>
)}
</div>
);
};

View File

@ -0,0 +1,204 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// react-hook-form
import { useForm } from "react-hook-form";
// services
import aiService from "services/ai.service";
import trackEventServices from "services/track-event.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { IIssue, IPageBlock } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
inset?: string;
content: string;
htmlContent?: string;
onResponse: (response: string) => void;
projectId: string;
block?: IPageBlock;
issue?: IIssue;
};
type FormData = {
prompt: string;
task: string;
};
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
});
export const GptAssistantModal: React.FC<Props> = ({
isOpen,
handleClose,
inset = "top-0 left-0",
content,
htmlContent,
onResponse,
projectId,
block,
issue,
}) => {
const [response, setResponse] = useState("");
const [invalidResponse, setInvalidResponse] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
handleSubmit,
register,
reset,
setFocus,
formState: { isSubmitting },
} = useForm({
defaultValues: {
prompt: content,
task: "",
},
});
const onClose = () => {
handleClose();
setResponse("");
setInvalidResponse(false);
reset();
};
const handleResponse = async (formData: FormData) => {
if (!workspaceSlug || !projectId) return;
if (formData.task === "") {
setToastAlert({
type: "error",
title: "Error!",
message: "Please enter some task to get AI assistance.",
});
return;
}
await aiService
.createGptTask(workspaceSlug as string, projectId as string, {
prompt: content && content !== "" ? content : htmlContent ?? "",
task: formData.task,
})
.then((res) => {
setResponse(res.response_html);
setFocus("task");
if (res.response === "") setInvalidResponse(true);
else setInvalidResponse(false);
})
.catch((err) => {
if (err.status === 429)
setToastAlert({
type: "error",
title: "Error!",
message:
"You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Some error occurred. Please try again.",
});
});
};
useEffect(() => {
if (isOpen) setFocus("task");
}, [isOpen, setFocus]);
return (
<div
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-brand-base bg-brand-surface-2 p-4 shadow ${
isOpen ? "block" : "hidden"
}`}
>
{((content && content !== "") || htmlContent !== "<p></p>") && (
<div className="remirror-section text-sm">
Content:
<RemirrorRichTextEditor
value={htmlContent ?? <p>{content}</p>}
customClassName="-m-3"
noBorder
borderOnFocus={false}
editable={false}
/>
</div>
)}
{response !== "" && (
<div className="text-sm page-block-section">
Response:
<RemirrorRichTextEditor
value={`<p>${response}</p>`}
customClassName="-mx-3 -my-3"
noBorder
borderOnFocus={false}
editable={false}
/>
</div>
)}
{invalidResponse && (
<div className="text-sm text-red-500">
No response could be generated. This may be due to insufficient content or task
information. Please try again.
</div>
)}
<Input
type="text"
name="task"
register={register}
placeholder={`${
content && content !== ""
? "Tell AI what action to perform on this content..."
: "Ask AI anything..."
}`}
autoComplete="off"
/>
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
{response !== "" && (
<PrimaryButton
onClick={() => {
onResponse(response);
onClose();
if (block)
trackEventServices.trackUseGPTResponseEvent(
block,
"USE_GPT_RESPONSE_IN_PAGE_BLOCK"
);
else if (issue)
trackEventServices.trackUseGPTResponseEvent(issue, "USE_GPT_RESPONSE_IN_ISSUE");
}}
>
Use this response
</PrimaryButton>
)}
<div className="flex items-center gap-2">
<SecondaryButton onClick={onClose}>Close</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleSubmit(handleResponse)}
loading={isSubmitting}
>
{isSubmitting
? "Generating response..."
: response === ""
? "Generate response"
: "Generate again"}
</PrimaryButton>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,156 @@
import React, { useEffect, useState, useRef } from "react";
// next
import Image from "next/image";
// swr
import useSWR from "swr";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react";
// services
import fileService from "services/file.service";
// components
import { Input, Spinner, PrimaryButton } from "components/ui";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
const unsplashEnabled =
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1";
const tabOptions = [
{
key: "unsplash",
title: "Unsplash",
},
{
key: "upload",
title: "Upload",
},
];
type Props = {
label: string | React.ReactNode;
value: string | null;
onChange: (data: string) => void;
};
export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange }) => {
const ref = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [searchParams, setSearchParams] = useState("");
const [formData, setFormData] = useState({
search: "",
});
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () =>
fileService.getUnsplashImages(1, searchParams)
);
useOutsideClickDetector(ref, () => {
setIsOpen(false);
});
useEffect(() => {
if (!images || value !== null) return;
onChange(images[0].urls.regular);
}, [value, onChange, images]);
if (!unsplashEnabled) return null;
return (
<Popover className="relative z-[2]" ref={ref}>
<Popover.Button
className="rounded-md border border-brand-base bg-brand-surface-2 px-2 py-1 text-xs text-gray-700"
onClick={() => setIsOpen((prev) => !prev)}
>
{label}
</Popover.Button>
<Transition
show={isOpen}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md bg-brand-surface-2 shadow-lg">
<div className="h-96 w-80 overflow-auto rounded border border-brand-base bg-brand-surface-2 p-5 shadow-2xl sm:max-w-2xl md:w-96 lg:w-[40rem]">
<Tab.Group>
<Tab.List as="span" className="inline-block rounded bg-brand-surface-2 p-1">
{tabOptions.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded py-1 px-4 text-center text-sm outline-none transition-colors ${
selected ? "bg-brand-accent text-white" : "text-brand-base"
}`
}
>
{tab.title}
</Tab>
))}
</Tab.List>
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
<Tab.Panel className="h-full w-full space-y-4">
<div className="flex gap-x-2 pt-7">
<Input
name="search"
className="text-sm"
id="search"
value={formData.search}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images"
/>
<PrimaryButton
type="button"
onClick={() => setSearchParams(formData.search)}
className="bg-indigo-600"
size="sm"
>
Search
</PrimaryButton>
</div>
{images ? (
<div className="grid grid-cols-4 gap-4">
{images.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
>
<Image
src={image.urls.small}
alt={image.alt_description}
layout="fill"
objectFit="cover"
className="cursor-pointer rounded"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
/>
</div>
))}
</div>
) : (
<div className="flex justify-center pt-20">
<Spinner />
</div>
)}
</Tab.Panel>
<Tab.Panel className="flex h-full w-full flex-col items-center justify-center">
<p>Coming Soon...</p>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
};

View File

@ -0,0 +1,190 @@
import React, { useCallback, useState } from "react";
import NextImage from "next/image";
import { useRouter } from "next/router";
// react-dropzone
import { useDropzone } from "react-dropzone";
// headless ui
import { Transition, Dialog } from "@headlessui/react";
// services
import fileServices from "services/file.service";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { UserCircleIcon } from "components/icons";
type Props = {
value?: string | null;
onClose: () => void;
isOpen: boolean;
onSuccess: (url: string) => void;
userImage?: boolean;
};
export const ImageUploadModal: React.FC<Props> = ({
value,
onSuccess,
isOpen,
onClose,
userImage,
}) => {
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const onDrop = useCallback((acceptedFiles: File[]) => {
setImage(acceptedFiles[0]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
});
const handleSubmit = async () => {
setIsImageUploading(true);
if (!image || !workspaceSlug) return;
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
if (userImage) {
fileServices
.uploadUserFile(formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setIsImageUploading(false);
setImage(null);
if (value) {
const index = value.indexOf(".com");
const asset = value.substring(index + 5);
fileServices.deleteUserFile(asset);
}
})
.catch((err) => {
console.error(err);
});
} else
fileServices
.uploadFile(workspaceSlug as string, formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setIsImageUploading(false);
setImage(null);
if (value) {
const index = value.indexOf(".com");
const asset = value.substring(index + 5);
fileServices.deleteFile(asset);
}
})
.catch((err) => {
console.error(err);
});
};
const handleClose = () => {
setImage(null);
onClose();
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-brand-surface-2 px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-xl sm:p-6">
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
Upload Image
</Dialog.Title>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div
{...getRootProps()}
className={`relative block h-80 w-full rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-brand-base hover:border-gray-400"
: ""
}`}
>
{image !== null || (value && value !== "") ? (
<>
<button
type="button"
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-brand-surface-1 px-2 py-0.5 text-xs font-medium text-gray-600"
>
Edit
</button>
<NextImage
layout="fill"
objectFit="cover"
src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image"
/>
</>
) : (
<>
<UserCircleIcon className="mx-auto h-16 w-16 text-gray-400" />
<span className="mt-2 block text-sm font-medium text-brand-base">
{isDragActive
? "Drop image here to upload"
: "Drag & drop image here"}
</span>
</>
)}
<input {...getInputProps()} type="text" />
</div>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</PrimaryButton>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,14 @@
export * from "./board-view";
export * from "./list-view";
export * from "./sidebar";
export * from "./bulk-delete-issues-modal";
export * from "./existing-issues-list-modal";
export * from "./gpt-assistant-modal";
export * from "./image-upload-modal";
export * from "./issues-view-filter";
export * from "./issues-view";
export * from "./link-modal";
export * from "./image-picker-popover";
export * from "./filter-list";
export * from "./feeds";
export * from "./theme-switch";

View File

@ -0,0 +1,282 @@
import React from "react";
import { useRouter } from "next/router";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useIssuesView from "hooks/use-issues-view";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// components
import { SelectFilters } from "components/views";
// ui
import { CustomMenu } from "components/ui";
// icons
import {
ChevronDownIcon,
ListBulletIcon,
Squares2X2Icon,
CalendarDaysIcon,
} from "@heroicons/react/24/outline";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { Properties } from "types";
// constants
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
import useEstimateOption from "hooks/use-estimate-option";
export const IssuesFilterView: React.FC = () => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const {
issueView,
setIssueView,
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
showEmptyGroups,
setShowEmptyGroups,
filters,
setFilters,
resetFilterToDefault,
setNewFilterDefaultView,
} = useIssuesView();
const [properties, setProperties] = useIssuesProperties(
workspaceSlug as string,
projectId as string
);
const { isEstimateActive } = useEstimateOption();
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2 ${
issueView === "list" ? "bg-brand-surface-2" : ""
}`}
onClick={() => setIssueView("list")}
>
<ListBulletIcon className="h-4 w-4 text-brand-secondary" />
</button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2 ${
issueView === "kanban" ? "bg-brand-surface-2" : ""
}`}
onClick={() => setIssueView("kanban")}
>
<Squares2X2Icon className="h-4 w-4 text-brand-secondary" />
</button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2 ${
issueView === "calendar" ? "bg-brand-surface-2" : ""
}`}
onClick={() => setIssueView("calendar")}
>
<CalendarDaysIcon className="h-4 w-4 text-brand-secondary" />
</button>
</div>
<SelectFilters
filters={filters}
onSelect={(option) => {
const key = option.key as keyof typeof filters;
const valueExists = filters[key]?.includes(option.value);
if (valueExists) {
setFilters(
{
...(filters ?? {}),
[option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value
),
},
!Boolean(viewId)
);
} else {
setFilters(
{
...(filters ?? {}),
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
},
!Boolean(viewId)
);
}
}}
direction="left"
height="rg"
/>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-brand-base bg-transparent px-3 py-1.5 text-xs hover:bg-brand-surface-1 hover:text-brand-base focus:outline-none ${
open ? "bg-brand-surface-1 text-brand-base" : "text-brand-secondary"
}`}
>
View
<ChevronDownIcon 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-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg border border-brand-base bg-brand-surface-1 p-3 shadow-lg">
<div className="relative divide-y-2 divide-brand-base">
<div className="space-y-4 pb-3 text-xs">
{issueView !== "calendar" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-brand-secondary">Group by</h4>
<CustomMenu
label={
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
width="lg"
>
{GROUP_BY_OPTIONS.map((option) =>
issueView === "kanban" && option.key === null ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-brand-secondary">Order by</h4>
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
width="lg"
>
{ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
</>
)}
<div className="flex items-center justify-between">
<h4 className="text-brand-secondary">Issue type</h4>
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type)
?.name ?? "Select"
}
width="lg"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
{issueView !== "calendar" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-brand-secondary">Show empty states</h4>
<button
type="button"
className={`relative inline-flex h-3.5 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
showEmptyGroups ? "bg-green-500" : "bg-brand-surface-2"
}`}
role="switch"
aria-checked={showEmptyGroups}
onClick={() => setShowEmptyGroups(!showEmptyGroups)}
>
<span className="sr-only">Show empty groups</span>
<span
aria-hidden="true"
className={`inline-block h-2.5 w-2.5 transform rounded-full bg-brand-surface-2 shadow ring-0 transition duration-200 ease-in-out ${
showEmptyGroups ? "translate-x-2.5" : "translate-x-0"
}`}
/>
</button>
</div>
<div className="relative flex justify-end gap-x-3">
<button type="button" onClick={() => resetFilterToDefault()}>
Reset to default
</button>
<button
type="button"
className="font-medium text-brand-accent"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div>
</>
)}
</div>
{issueView !== "calendar" && (
<div className="space-y-2 py-3">
<h4 className="text-sm text-brand-secondary">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-brand-accent bg-brand-accent text-brand-base"
: "border-brand-base"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
)}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
};

View File

@ -0,0 +1,546 @@
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service";
// contexts
import { useProjectMyMembership } from "contexts/project-member.context";
// hooks
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
// components
import { AllLists, AllBoards, FilterList } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CreateUpdateViewModal } from "components/views";
import { TransferIssues, TransferIssuesModal } from "components/cycles";
// ui
import { EmptySpace, EmptySpaceItem, EmptyState, PrimaryButton, Spinner } from "components/ui";
import { CalendarView } from "./calendar-view";
// icons
import {
ListBulletIcon,
PlusIcon,
RectangleStackIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// images
import emptyIssue from "public/empty-state/empty-issue.svg";
// helpers
import { getStatesList } from "helpers/state.helper";
import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue, IIssueFilterOptions } from "types";
// fetch-keys
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
STATE_LIST,
} from "constants/fetch-keys";
// image
type Props = {
type?: "issue" | "cycle" | "module";
openIssuesListModal?: () => void;
isCompleted?: boolean;
};
export const IssuesView: React.FC<Props> = ({
type = "issue",
openIssuesListModal,
isCompleted = false,
}) => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [createViewModal, setCreateViewModal] = useState<any>(null);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
// update issue modal
const [editIssueModal, setEditIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<
(IIssue & { actionType: "edit" | "delete" }) | undefined
>(undefined);
// delete issue modal
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
// trash box
const [trashBox, setTrashBox] = useState(false);
// transfer issue
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { memberRole } = useProjectMyMembership();
const { setToastAlert } = useToast();
const {
groupedByIssues,
issueView,
groupByProperty: selectedGroup,
orderBy,
filters,
isNotEmpty,
setFilters,
params,
} = useIssuesView();
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
const handleDeleteIssue = useCallback(
(issue: IIssue) => {
setDeleteIssueModal(true);
setIssueToDelete(issue);
},
[setDeleteIssueModal, setIssueToDelete]
);
const handleOnDragEnd = useCallback(
(result: DropResult) => {
setTrashBox(false);
if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return;
const { source, destination } = result;
const draggedItem = groupedByIssues[source.droppableId][source.index];
if (destination.droppableId === "trashBox") {
handleDeleteIssue(draggedItem);
} else {
if (orderBy === "sort_order") {
let newSortOrder = draggedItem.sort_order;
const destinationGroupArray = groupedByIssues[destination.droppableId];
if (destinationGroupArray.length !== 0) {
// check if dropping in the same group
if (source.droppableId === destination.droppableId) {
// check if dropping at beginning
if (destination.index === 0)
newSortOrder = destinationGroupArray[0].sort_order - 10000;
// check if dropping at last
else if (destination.index === destinationGroupArray.length - 1)
newSortOrder =
destinationGroupArray[destinationGroupArray.length - 1].sort_order + 10000;
else {
if (destination.index > source.index)
newSortOrder =
(destinationGroupArray[source.index + 1].sort_order +
destinationGroupArray[source.index + 2].sort_order) /
2;
else if (destination.index < source.index)
newSortOrder =
(destinationGroupArray[source.index - 1].sort_order +
destinationGroupArray[source.index - 2].sort_order) /
2;
}
} else {
// check if dropping at beginning
if (destination.index === 0)
newSortOrder = destinationGroupArray[0].sort_order - 10000;
// check if dropping at last
else if (destination.index === destinationGroupArray.length)
newSortOrder =
destinationGroupArray[destinationGroupArray.length - 1].sort_order + 10000;
else
newSortOrder =
(destinationGroupArray[destination.index - 1].sort_order +
destinationGroupArray[destination.index].sort_order) /
2;
}
}
draggedItem.sort_order = newSortOrder;
}
const destinationGroup = destination.droppableId; // destination group id
if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) {
// different group/column;
// source.droppableId !== destination.droppableId -> even if order by is not sort_order,
// if the issue is moved to a different group, then we will change the group of the
// dragged item(or issue)
if (selectedGroup === "priority") draggedItem.priority = destinationGroup;
else if (selectedGroup === "state") draggedItem.state = destinationGroup;
}
const sourceGroup = source.droppableId; // source group id
mutate<{
[key: string]: IIssue[];
}>(
cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
(prevData) => {
if (!prevData) return prevData;
const sourceGroupArray = prevData[sourceGroup];
const destinationGroupArray = groupedByIssues[destinationGroup];
sourceGroupArray.splice(source.index, 1);
destinationGroupArray.splice(destination.index, 0, draggedItem);
return {
...prevData,
[sourceGroup]: orderArrayBy(sourceGroupArray, orderBy),
[destinationGroup]: orderArrayBy(destinationGroupArray, orderBy),
};
},
false
);
// patch request
issuesService
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
priority: draggedItem.priority,
state: draggedItem.state,
sort_order: draggedItem.sort_order,
})
.then((response) => {
const sourceStateBeforeDrag = states.find((state) => state.name === source.droppableId);
if (
sourceStateBeforeDrag?.group !== "completed" &&
response?.state_detail?.group === "completed"
)
trackEventServices.trackIssueMarkedAsDoneEvent({
workspaceSlug,
workspaceId: draggedItem.workspace,
projectName: draggedItem.project_detail.name,
projectIdentifier: draggedItem.project_detail.identifier,
projectId,
issueId: draggedItem.id,
});
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
mutate(CYCLE_DETAILS(cycleId as string));
}
if (moduleId) {
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
mutate(MODULE_DETAILS(moduleId as string));
}
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
});
}
},
[
workspaceSlug,
cycleId,
moduleId,
groupedByIssues,
projectId,
selectedGroup,
orderBy,
handleDeleteIssue,
params,
states,
]
);
const addIssueToState = useCallback(
(groupTitle: string) => {
setCreateIssueModal(true);
if (selectedGroup)
setPreloadedData({
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
else setPreloadedData({ actionType: "createIssue" });
},
[setCreateIssueModal, setPreloadedData, selectedGroup]
);
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 removeIssueFromCycle = useCallback(
(bridgeId: string) => {
if (!workspaceSlug || !projectId || !cycleId) return;
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
issuesService
.removeIssueFromCycle(
workspaceSlug as string,
projectId as string,
cycleId as string,
bridgeId
)
.catch((e) => {
console.log(e);
});
},
[workspaceSlug, projectId, cycleId, params]
);
const removeIssueFromModule = useCallback(
(bridgeId: string) => {
if (!workspaceSlug || !projectId || !moduleId) return;
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
modulesService
.removeIssueFromModule(
workspaceSlug as string,
projectId as string,
moduleId as string,
bridgeId
)
.catch((e) => {
console.log(e);
});
},
[workspaceSlug, projectId, moduleId, params]
);
const handleTrashBox = useCallback(
(isDragging: boolean) => {
if (isDragging && !trashBox) setTrashBox(true);
},
[trashBox, setTrashBox]
);
const nullFilters = Object.keys(filters).filter(
(key) => filters[key as keyof IIssueFilterOptions] === null
);
const areFiltersApplied =
Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length;
return (
<>
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
preLoadedData={createViewModal}
/>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
prePopulateData={{ ...issueToEdit }}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
/>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal}
/>
<>
<div
className={`flex items-center justify-between gap-2 ${
issueView === "list" ? (areFiltersApplied ? "mt-6 px-8" : "") : "-mt-2"
}`}
>
<FilterList filters={filters} setFilters={setFilters} />
{areFiltersApplied && (
<PrimaryButton
onClick={() => {
if (viewId) {
setFilters({}, true);
setToastAlert({
title: "View updated",
message: "Your view has been updated",
type: "success",
});
} else
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
{!viewId && <PlusIcon className="h-4 w-4" />}
{viewId ? "Update" : "Save"} view
</PrimaryButton>
)}
</div>
{areFiltersApplied && (
<div className={` ${issueView === "list" ? "mt-4" : "my-4"} border-t`} />
)}
</>
<DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox">
{(provided, snapshot) => (
<div
className={`${
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
} fixed top-9 right-9 z-30 flex h-28 w-96 flex-col items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-red-500/20 p-3 text-xs font-medium italic text-red-500 ${
snapshot.isDraggingOver ? "bg-red-500/100 text-white" : ""
} duration-200`}
ref={provided.innerRef}
{...provided.droppableProps}
>
<TrashIcon className="h-4 w-4" />
Drop issue here to delete
</div>
)}
</StrictModeDroppable>
{groupedByIssues ? (
isNotEmpty ? (
<>
{isCompleted && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
{issueView === "list" ? (
<AllLists
type={type}
states={states}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
isCompleted={isCompleted}
userAuth={memberRole}
/>
) : issueView === "kanban" ? (
<AllBoards
type={type}
states={states}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
handleDeleteIssue={handleDeleteIssue}
handleTrashBox={handleTrashBox}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
isCompleted={isCompleted}
userAuth={memberRole}
/>
) : (
<CalendarView addIssueToDate={addIssueToDate} />
)}
</>
) : type === "issue" ? (
<EmptyState
type="issue"
title="Create New Issue"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
imgURL={emptyIssue}
/>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<EmptySpace
title="You don't have any issue yet."
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
Icon={RectangleStackIcon}
>
<EmptySpaceItem
title="Create a new issue"
description={
<span>
Use <pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>{" "}
shortcut to create a new issue
</span>
}
Icon={PlusIcon}
action={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
/>
{openIssuesListModal && (
<EmptySpaceItem
title="Add an existing issue"
description="Open list"
Icon={ListBulletIcon}
action={openIssuesListModal}
/>
)}
</EmptySpace>
</div>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</DragDropContext>
</>
);
};

View File

@ -0,0 +1,128 @@
import React from "react";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// types
import type { IIssueLink, ModuleLink } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onFormSubmit: (formData: IIssueLink | ModuleLink) => Promise<void>;
};
const defaultValues: ModuleLink = {
title: "",
url: "",
};
export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }) => {
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<ModuleLink>({
defaultValues,
});
const onSubmit = async (formData: ModuleLink) => {
await onFormSubmit(formData);
onClose();
};
const onClose = () => {
handleClose();
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-brand-surface-2 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
Add Link
</Dialog.Title>
<div className="mt-2 space-y-3">
<div>
<Input
id="url"
label="URL"
name="url"
type="url"
placeholder="Enter URL"
autoComplete="off"
error={errors.url}
register={register}
validations={{
required: "URL is required",
}}
/>
</div>
<div>
<Input
id="title"
label="Title"
name="title"
type="text"
placeholder="Enter title"
autoComplete="off"
error={errors.title}
register={register}
validations={{
required: "Title is required",
}}
/>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Adding Link..." : "Add Link"}
</PrimaryButton>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,69 @@
// hooks
import useIssuesView from "hooks/use-issues-view";
// components
import { SingleList } from "components/core/list-view/single-list";
// types
import { IIssue, IState, UserAuth } from "types";
// types
type Props = {
type: "issue" | "cycle" | "module";
states: IState[] | undefined;
addIssueToState: (groupTitle: string) => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string) => void) | null;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const AllLists: React.FC<Props> = ({
type,
states,
addIssueToState,
makeIssueCopy,
openIssuesListModal,
handleEditIssue,
handleDeleteIssue,
removeIssue,
isCompleted = false,
userAuth,
}) => {
const { groupedByIssues, groupByProperty: selectedGroup, showEmptyGroups } = useIssuesView();
return (
<>
{groupedByIssues && (
<div>
{Object.keys(groupedByIssues).map((singleGroup) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
return (
<SingleList
key={singleGroup}
type={type}
groupTitle={singleGroup}
groupedByIssues={groupedByIssues}
selectedGroup={selectedGroup}
currentState={currentState}
addIssueToState={() => addIssueToState(singleGroup)}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={removeIssue}
isCompleted={isCompleted}
userAuth={userAuth}
/>
);
})}
</div>
)}
</>
);
};

View File

@ -0,0 +1,3 @@
export * from "./all-lists";
export * from "./single-issue";
export * from "./single-list";

View File

@ -0,0 +1,365 @@
import React, { useCallback, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import issuesService from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// components
import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewEstimateSelect,
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues/view-select";
// hooks
import useIssueView from "hooks/use-issues-view";
// ui
import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
// icons
import {
ClipboardDocumentCheckIcon,
LinkIcon,
PencilIcon,
TrashIcon,
XMarkIcon,
ArrowTopRightOnSquareIcon,
PaperClipIcon,
} from "@heroicons/react/24/outline";
// helpers
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { handleIssuesMutation } from "constants/issue";
// types
import { IIssue, Properties, UserAuth } from "types";
// fetch-keys
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
} from "constants/fetch-keys";
type Props = {
type?: string;
issue: IIssue;
properties: Properties;
groupTitle?: string;
editIssue: () => void;
index: number;
makeIssueCopy: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const SingleListIssue: React.FC<Props> = ({
type,
issue,
properties,
editIssue,
index,
makeIssueCopy,
removeIssue,
groupTitle,
handleDeleteIssue,
isCompleted = false,
userAuth,
}) => {
// context menu
const [contextMenu, setContextMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
const { groupByProperty: selectedGroup, orderBy, params } = useIssueView();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (cycleId)
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
(prevData) =>
handleIssuesMutation(
formData,
groupTitle ?? "",
selectedGroup,
index,
orderBy,
prevData
),
false
);
if (moduleId)
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
MODULE_ISSUES_WITH_PARAMS(moduleId as string, params),
(prevData) =>
handleIssuesMutation(
formData,
groupTitle ?? "",
selectedGroup,
index,
orderBy,
prevData
),
false
);
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
(prevData) =>
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, orderBy, prevData),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then(() => {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
mutate(CYCLE_DETAILS(cycleId as string));
} else if (moduleId) {
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
mutate(MODULE_DETAILS(moduleId as string));
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
});
},
[
workspaceSlug,
projectId,
cycleId,
moduleId,
issue,
groupTitle,
index,
selectedGroup,
orderBy,
params,
]
);
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
return (
<>
<ContextMenu
position={contextMenuPosition}
title="Quick actions"
isOpen={contextMenu}
setIsOpen={setContextMenu}
>
{!isNotAllowed && (
<>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
Edit issue
</ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
Delete issue
</ContextMenu.Item>
</>
)}
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</ContextMenu.Item>
<a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
</a>
</ContextMenu>
<div
className="flex items-center justify-between gap-2 border-b border-brand-base bg-brand-base px-4 py-2.5 last:border-b-0"
onContextMenu={(e) => {
e.preventDefault();
setContextMenu(true);
setContextMenuPosition({ x: e.pageX, y: e.pageY });
}}
>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-brand-secondary">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
)}
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-brand-base">
{truncateText(issue.name, 50)}
</span>
</Tooltip>
</a>
</Link>
<div className="flex flex-wrap items-center gap-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
isNotAllowed={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex items-center gap-1 rounded-md border border-brand-base px-3 py-1 text-xs text-brand-secondary shadow-sm">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && issue.label_details.length > 0 ? (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
) : (
""
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
isNotAllowed={isNotAllowed}
/>
)}
{properties.estimate && (
<ViewEstimateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
isNotAllowed={isNotAllowed}
/>
)}
{properties.link && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
<div className="flex items-center gap-1 text-gray-500">
<LinkIcon className="h-3.5 w-3.5 text-gray-500" />
{issue.link_count}
</div>
</Tooltip>
</div>
)}
{properties.attachment_count && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
<div className="flex items-center gap-1 text-gray-500">
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45 text-gray-500" />
{issue.attachment_count}
</div>
</Tooltip>
</div>
)}
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<div className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
<span>Remove from {type}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue link</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
</>
);
};

View File

@ -0,0 +1,227 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// services
import issuesService from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
// components
import { SingleListIssue } from "components/core";
// ui
import { Avatar, CustomMenu } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IIssueLabels, IState, TIssueGroupByOptions, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
type?: "issue" | "cycle" | "module";
currentState?: IState | null;
bgColor?: string;
groupTitle: string;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: TIssueGroupByOptions;
addIssueToState: () => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string) => void) | null;
isCompleted?: boolean;
userAuth: UserAuth;
};
export const SingleList: React.FC<Props> = ({
type,
currentState,
bgColor,
groupTitle,
groupedByIssues,
selectedGroup,
addIssueToState,
makeIssueCopy,
handleEditIssue,
handleDeleteIssue,
openIssuesListModal,
removeIssue,
isCompleted = false,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle);
switch (selectedGroup) {
case "state":
title = addSpaceIfCamelCase(currentState?.name ?? "");
break;
case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
break;
}
return title;
};
const getGroupIcon = () => {
let icon;
switch (selectedGroup) {
case "state":
icon = currentState && getStateGroupIcon(currentState.group, "16", "16", bgColor);
break;
case "priority":
icon = getPriorityIcon(groupTitle, "text-lg");
break;
case "labels":
const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
icon = (
<span
className="h-3 w-3 flex-shrink-0 rounded-full"
style={{ backgroundColor: labelColor }}
/>
);
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
icon = <Avatar user={member} height="24px" width="24px" fontSize="12px" />;
break;
}
return icon;
};
return (
<Disclosure as="div" defaultOpen>
{({ open }) => (
<div>
<div className="flex items-center justify-between px-4 py-2.5">
<Disclosure.Button>
<div className="flex items-center gap-x-3">
{selectedGroup !== null && (
<div className="flex items-center">{getGroupIcon()}</div>
)}
{selectedGroup !== null ? (
<h2 className="text-sm font-semibold capitalize leading-6 text-brand-base">
{getGroupTitle()}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<span className="text-brand-2 min-w-[2.5rem] rounded-full bg-brand-surface-2 py-1 text-center text-xs">
{groupedByIssues[groupTitle as keyof IIssue].length}
</span>
</div>
</Disclosure.Button>
{type === "issue" ? (
<button
type="button"
className="p-1 text-brand-secondary hover:bg-brand-surface-2"
onClick={addIssueToState}
>
<PlusIcon className="h-4 w-4" />
</button>
) : isCompleted ? (
""
) : (
<CustomMenu
customButton={
<div className="flex cursor-pointer items-center">
<PlusIcon className="h-4 w-4" />
</div>
}
optionsPosition="right"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
<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>
{groupedByIssues[groupTitle] ? (
groupedByIssues[groupTitle].length > 0 ? (
groupedByIssues[groupTitle].map((issue, index) => (
<SingleListIssue
key={issue.id}
type={type}
issue={issue}
properties={properties}
groupTitle={groupTitle}
index={index}
editIssue={() => handleEditIssue(issue)}
makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue}
removeIssue={() => {
if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id);
}}
isCompleted={isCompleted}
userAuth={userAuth}
/>
))
) : (
<p className="bg-brand-base px-4 py-2.5 text-sm text-brand-secondary">
No issues.
</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
);
};

View File

@ -0,0 +1,3 @@
export * from "./links-list";
export * from "./sidebar-progress-stats";
export * from "./single-progress-stats";

View File

@ -0,0 +1,72 @@
import Link from "next/link";
// icons
import { ArrowTopRightOnSquareIcon, LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// types
import { IUserLite, UserAuth } from "types";
type Props = {
links: {
id: string;
created_at: Date;
created_by: string;
created_by_detail: IUserLite;
metadata: any;
title: string;
url: string;
}[];
handleDeleteLink: (linkId: string) => void;
userAuth: UserAuth;
};
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<>
{links.map((link) => (
<div key={link.id} className="relative">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1">
<Link href={link.url}>
<a
className="grid h-7 w-7 place-items-center rounded bg-brand-surface-1 p-1 outline-none hover:bg-brand-surface-2"
target="_blank"
>
<ArrowTopRightOnSquareIcon className="h-4 w-4 text-brand-secondary" />
</a>
</Link>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-brand-surface-1 p-1 text-red-500 outline-none duration-300 hover:bg-red-500/20"
onClick={() => handleDeleteLink(link.id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
)}
<Link href={link.url}>
<a className="relative flex gap-2 rounded-md bg-brand-base p-2" target="_blank">
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5 className="w-4/5 break-all">{link.title}</h5>
<p className="mt-0.5 text-brand-secondary">
Added {timeAgo(link.created_at)}
<br />
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.email}
</p>
</div>
</a>
</Link>
</div>
))}
</>
);
};

View File

@ -0,0 +1,94 @@
import React from "react";
import { XAxis, YAxis, Tooltip, AreaChart, Area, ReferenceLine, TooltipProps} from "recharts";
//types
import { IIssue } from "types";
import { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
// helper
import { getDatesInRange, renderShortNumericDateFormat } from "helpers/date-time.helper";
type Props = {
issues: IIssue[];
start: string;
end: string;
};
const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
const startDate = new Date(start);
const endDate = new Date(end);
const getChartData = () => {
const dateRangeArray = getDatesInRange(startDate, endDate);
let count = 0;
const dateWiseData = dateRangeArray.map((d) => {
const current = d.toISOString().split("T")[0];
const total = issues.length;
const currentData = issues.filter(
(i) => i.completed_at && i.completed_at.toString().split("T")[0] === current
);
count = currentData ? currentData.length + count : count;
return {
currentDate: renderShortNumericDateFormat(current),
currentDateData: currentData,
pending: new Date(current) < new Date() ? total - count : null,
};
});
return dateWiseData;
};
const CustomTooltip = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
if (active && payload && payload.length) {
return (
<div className="rounded-sm bg-brand-surface-1 p-1 text-xs text-brand-base">
<p>{payload[0].payload.currentDate}</p>
</div>
);
}
return null;
};
const ChartData = getChartData();
return (
<div className="absolute -left-4 flex h-full w-full items-center justify-center text-xs">
<AreaChart
width={360}
height={160}
data={ChartData}
margin={{
top: 12,
right: 12,
left: 0,
bottom: 12,
}}
>
<defs>
<linearGradient id="linearblue" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3F76FF" stopOpacity={0.7} />
<stop offset="50%" stopColor="#3F76FF" stopOpacity={0.1} />
<stop offset="100%" stopColor="#3F76FF" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="currentDate" tickSize={10} minTickGap={10} />
<YAxis tickSize={10} minTickGap={10} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="pending"
stroke="#8884d8"
fill="url(#linearblue)"
activeDot={{ r: 8 }}
/>
<ReferenceLine
stroke="#16a34a"
strokeDasharray="3 3"
segment={[
{ x: `${renderShortNumericDateFormat(endDate)}`, y: 0 },
{ x: `${renderShortNumericDateFormat(startDate)}`, y: issues.length },
]}
/>
</AreaChart>
</div>
);
};
export default ProgressChart;

View File

@ -0,0 +1,251 @@
import React from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Tab } from "@headlessui/react";
// services
import issuesServices from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useLocalStorage from "hooks/use-local-storage";
import useIssuesView from "hooks/use-issues-view";
// components
import { SingleProgressStats } from "components/core";
// ui
import { Avatar } from "components/ui";
// icons
import User from "public/user.png";
// types
import { IIssue, IIssueLabels, IModule, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
// types
type Props = {
groupedIssues: any;
issues: IIssue[];
module?: IModule;
userAuth?: UserAuth;
};
const stateGroupColours: {
[key: string]: string;
} = {
backlog: "#3f76ff",
unstarted: "#ff9e9e",
started: "#d687ff",
cancelled: "#ff5353",
completed: "#096e8d",
};
export const SidebarProgressStats: React.FC<Props> = ({
groupedIssues,
issues,
module,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { filters, setFilters } = useIssuesView();
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const currentValue = (tab: string | null) => {
switch (tab) {
case "Assignees":
return 0;
case "Labels":
return 1;
case "States":
return 2;
default:
return 3;
}
};
return (
<Tab.Group
defaultIndex={currentValue(tab)}
onChange={(i) => {
switch (i) {
case 0:
return setTab("Assignees");
case 1:
return setTab("Labels");
case 2:
return setTab("States");
default:
return setTab("States");
}
}}
>
<Tab.List
as="div"
className={`flex w-full items-center justify-between rounded-md bg-brand-surface-1 px-1 py-1.5
${module ? "text-xs" : "text-sm"} `}
>
<Tab
className={({ selected }) =>
`w-full rounded px-3 py-1 text-brand-base ${
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}`
}
>
Assignees
</Tab>
<Tab
className={({ selected }) =>
`w-full rounded px-3 py-1 text-brand-base ${
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}`
}
>
Labels
</Tab>
<Tab
className={({ selected }) =>
`w-full rounded px-3 py-1 text-brand-base ${
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}`
}
>
States
</Tab>
</Tab.List>
<Tab.Panels className="flex w-full items-center justify-between pt-1">
<Tab.Panel as="div" className="flex w-full flex-col text-xs">
{members?.map((member, index) => {
const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id));
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
if (totalArray.length > 0) {
return (
<SingleProgressStats
key={index}
title={
<>
<Avatar user={member.member} />
<span>{member.member.first_name}</span>
</>
}
completed={completeArray.length}
total={totalArray.length}
onClick={() => {
if (filters.assignees?.includes(member.member.id))
setFilters({
assignees: filters.assignees?.filter((a) => a !== member.member.id),
});
else
setFilters({ assignees: [...(filters?.assignees ?? []), member.member.id] });
}}
selected={filters.assignees?.includes(member.member.id)}
/>
);
}
})}
{issues?.filter((i) => i.assignees?.length === 0).length > 0 ? (
<SingleProgressStats
title={
<>
<div className="h-5 w-5 rounded-full border-2 border-white bg-brand-surface-2">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="User"
/>
</div>
<span>No assignee</span>
</>
}
completed={
issues?.filter(
(i) => i.state_detail.group === "completed" && i.assignees?.length === 0
).length
}
total={issues?.filter((i) => i.assignees?.length === 0).length}
/>
) : (
""
)}
</Tab.Panel>
<Tab.Panel as="div" className="w-full space-y-1">
{issueLabels?.map((label, index) => {
const totalArray = issues?.filter((i) => i.labels?.includes(label.id));
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
if (totalArray.length > 0) {
return (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor:
label.color && label.color !== "" ? label.color : "#000000",
}}
/>
<span className="text-xs capitalize">{label.name}</span>
</div>
}
completed={completeArray.length}
total={totalArray.length}
onClick={() => {
if (filters.labels?.includes(label.id))
setFilters({
labels: filters.labels?.filter((l) => l !== label.id),
});
else setFilters({ labels: [...(filters?.labels ?? []), label.id] });
}}
selected={filters.labels?.includes(label.id)}
/>
);
}
})}
</Tab.Panel>
<Tab.Panel as="div" className="flex w-full flex-col ">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: stateGroupColours[group],
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={issues.length}
/>
))}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
};

View File

@ -0,0 +1,38 @@
import React from "react";
import { ProgressBar } from "components/ui";
type TSingleProgressStatsProps = {
title: any;
completed: number;
total: number;
onClick?: () => void;
selected?: boolean;
};
export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
title,
completed,
total,
onClick,
selected = false,
}) => (
<div
className={`flex w-full items-center justify-between rounded p-2 text-xs ${
onClick ? "cursor-pointer hover:bg-brand-surface-1" : ""
} ${selected ? "bg-brand-surface-1" : ""}`}
onClick={onClick}
>
<div className="flex w-1/2 items-center justify-start gap-2">{title}</div>
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
<div className="flex h-5 items-center justify-center gap-1">
<span className="h-4 w-4 ">
<ProgressBar value={completed} maxValue={total} />
</span>
<span className="w-8 text-right">{Math.floor((completed / total) * 100)}%</span>
</div>
<span>of</span>
<span>{total}</span>
</div>
</div>
);

View File

@ -0,0 +1,60 @@
import { useState, useEffect, ChangeEvent } from "react";
import { useTheme } from "next-themes";
import { THEMES_OBJ } from "constants/themes";
import { CustomSelect } from "components/ui";
import { CustomThemeModal } from "./custom-theme-modal";
export const ThemeSwitch = () => {
const [mounted, setMounted] = useState(false);
const [customThemeModal, setCustomThemeModal] = useState(false);
const { theme, setTheme } = useTheme();
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<>
<CustomSelect
value={theme}
label={theme ? THEMES_OBJ.find((t) => t.value === theme)?.label : "Select your theme"}
onChange={({ value, type }: { value: string; type: string }) => {
if (value === "custom") {
if (!customThemeModal) setCustomThemeModal(true);
} else {
const cssVars = [
"--color-bg-base",
"--color-bg-surface-1",
"--color-bg-surface-2",
"--color-border",
"--color-bg-sidebar",
"--color-accent",
"--color-text-base",
"--color-text-secondary",
];
cssVars.forEach((cssVar) => document.documentElement.style.removeProperty(cssVar));
}
document.documentElement.style.setProperty("color-scheme", type);
setTheme(value);
}}
input
width="w-full"
position="right"
>
{THEMES_OBJ.map(({ value, label, type }) => (
<CustomSelect.Option key={value} value={{ value, type }}>
{label}
</CustomSelect.Option>
))}
</CustomSelect>
{/* <CustomThemeModal isOpen={customThemeModal} handleClose={() => setCustomThemeModal(false)} /> */}
</>
);
};

View File

@ -0,0 +1,101 @@
import { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import cyclesService from "services/cycles.service";
// components
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
// icons
import { CompletedCycleIcon, ExclamationIcon } from "components/icons";
// types
import { ICycle, SelectCycleType } from "types";
// fetch-keys
import { CYCLE_COMPLETE_LIST } from "constants/fetch-keys";
import { EmptyState, Loader } from "components/ui";
// image
import emptyCycle from "public/empty-state/empty-cycle.svg";
export interface CompletedCyclesListProps {
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
}
export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
setCreateUpdateCycleModal,
setSelectedCycle,
}) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: completedCycles } = useSWR(
workspaceSlug && projectId ? CYCLE_COMPLETE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => cyclesService.getCompletedCycles(workspaceSlug as string, projectId as string)
: null
);
const handleDeleteCycle = (cycle: ICycle) => {
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
setCycleDeleteModal(true);
};
const handleEditCycle = (cycle: ICycle) => {
setSelectedCycle({ ...cycle, actionType: "edit" });
setCreateUpdateCycleModal(true);
};
return (
<>
<DeleteCycleModal
isOpen={
cycleDeleteModal &&
!!selectedCycleForDelete &&
selectedCycleForDelete.actionType === "delete"
}
setIsOpen={setCycleDeleteModal}
data={selectedCycleForDelete}
/>
{completedCycles ? (
completedCycles.completed_cycles.length > 0 ? (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-brand-secondary">
<ExclamationIcon height={14} width={14} />
<span>Completed cycles are not editable.</span>
</div>
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{completedCycles.completed_cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
isCompleted
/>
))}
</div>
</div>
) : (
<EmptyState
type="cycle"
title="Create New Cycle"
description="Sprint more effectively with Cycles by confining your project
to a fixed amount of time. Create new cycle now."
imgURL={emptyCycle}
/>
)
) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
<Loader.Item height="200px" />
<Loader.Item height="200px" />
</Loader>
)}
</>
);
};

View File

@ -0,0 +1,88 @@
import { useState } from "react";
// components
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
import { EmptyState, Loader } from "components/ui";
// image
import emptyCycle from "public/empty-state/empty-cycle.svg";
// icon
import { XMarkIcon } from "@heroicons/react/24/outline";
// types
import { ICycle, SelectCycleType } from "types";
type TCycleStatsViewProps = {
cycles: ICycle[] | undefined;
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
type: "current" | "upcoming" | "draft";
};
export const CyclesList: React.FC<TCycleStatsViewProps> = ({
cycles,
setCreateUpdateCycleModal,
setSelectedCycle,
type,
}) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
const [showNoCurrentCycleMessage, setShowNoCurrentCycleMessage] = useState(true);
const handleDeleteCycle = (cycle: ICycle) => {
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
setCycleDeleteModal(true);
};
const handleEditCycle = (cycle: ICycle) => {
setSelectedCycle({ ...cycle, actionType: "edit" });
setCreateUpdateCycleModal(true);
};
return (
<>
<DeleteCycleModal
isOpen={
cycleDeleteModal &&
!!selectedCycleForDelete &&
selectedCycleForDelete.actionType === "delete"
}
setIsOpen={setCycleDeleteModal}
data={selectedCycleForDelete}
/>
{cycles ? (
cycles.length > 0 ? (
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
/>
))}
</div>
) : type === "current" ? (
showNoCurrentCycleMessage && (
<div className="flex items-center justify-between bg-brand-surface-2 w-full px-6 py-4 rounded-[10px]">
<h3 className="text-base font-medium text-brand-base "> No current cycle is present.</h3>
<button onClick={() => setShowNoCurrentCycleMessage(false)}>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
)
) : (
<EmptyState
type="cycle"
title="Create New Cycle"
description="Sprint more effectively with Cycles by confining your project
to a fixed amount of time. Create new cycle now."
imgURL={emptyCycle}
/>
)
) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
</Loader>
)}
</>
);
};

View File

@ -0,0 +1,192 @@
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import cycleService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { DangerButton, SecondaryButton } from "components/ui";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types
import type {
CompletedCyclesResponse,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
ICycle,
} from "types";
type TConfirmCycleDeletionProps = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: ICycle;
};
// fetch-keys
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DRAFT_LIST,
CYCLE_LIST,
} from "constants/fetch-keys";
import { getDateRangeStatus } from "helpers/date-time.helper";
export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
isOpen,
setIsOpen,
data,
}) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const handleClose = () => {
setIsOpen(false);
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
if (!data || !workspaceSlug) return;
setIsDeleteLoading(true);
await cycleService
.deleteCycle(workspaceSlug as string, data.project, data.id)
.then(() => {
switch (getDateRangeStatus(data.start_date, data.end_date)) {
case "completed":
mutate<CompletedCyclesResponse>(
CYCLE_COMPLETE_LIST(projectId as string),
(prevData) => {
if (!prevData) return;
return {
completed_cycles: prevData.completed_cycles?.filter(
(cycle) => cycle.id !== data?.id
),
};
},
false
);
break;
case "current":
mutate<CurrentAndUpcomingCyclesResponse>(
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
(prevData) => {
if (!prevData) return;
return {
current_cycle: prevData.current_cycle?.filter((c) => c.id !== data?.id),
upcoming_cycle: prevData.upcoming_cycle,
};
},
false
);
break;
case "upcoming":
mutate<CurrentAndUpcomingCyclesResponse>(
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
(prevData) => {
if (!prevData) return;
return {
current_cycle: prevData.current_cycle,
upcoming_cycle: prevData.upcoming_cycle?.filter((c) => c.id !== data?.id),
};
},
false
);
break;
default:
mutate<DraftCyclesResponse>(
CYCLE_DRAFT_LIST(projectId as string),
(prevData) => {
if (!prevData) return;
return {
draft_cycles: prevData.draft_cycles?.filter((cycle) => cycle.id !== data?.id),
};
},
false
);
}
handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Cycle deleted successfully",
});
})
.catch(() => {
setIsDeleteLoading(false);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-brand-surface-2 text-left shadow-xl transition-all sm:my-8 sm:w-[40rem]">
<div className="bg-brand-surface-2 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</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-brand-base">
Delete Cycle
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-brand-secondary">
Are you sure you want to delete cycle-{" "}
<span className="font-bold">{data?.name}</span>? All of the data related
to the cycle will be permanently removed. This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2 bg-gray-50 p-4 sm:px-6">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
{isDeleteLoading ? "Deleting..." : "Delete"}
</DangerButton>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,78 @@
import React from "react";
import { LinearProgressIndicator } from "components/ui";
export const EmptyCycle = () => {
const emptyCycleData = [
{
id: 1,
name: "backlog",
value: 20,
color: "#DEE2E6",
},
{
id: 2,
name: "unstarted",
value: 14,
color: "#26B5CE",
},
{
id: 3,
name: "started",
value: 27,
color: "#F7AE59",
},
{
id: 4,
name: "cancelled",
value: 15,
color: "#D687FF",
},
{
id: 5,
name: "completed",
value: 14,
color: "#09A953",
},
];
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-5 ">
<div className="relative h-32 w-72">
<div className="absolute right-0 top-0 flex w-64 flex-col rounded-[10px] bg-brand-surface-2 text-xs shadow">
<div className="flex flex-col items-start justify-center gap-2.5 p-3.5">
<span className="text-sm font-semibold text-brand-base">Cycle Name</span>
<div className="flex h-full w-full items-center gap-4">
<span className="h-2 w-20 rounded-full bg-brand-surface-2" />
<span className="h-2 w-20 rounded-full bg-brand-surface-2" />
</div>
</div>
<div className="border-t border-brand-base bg-brand-surface-1 px-4 py-3">
<LinearProgressIndicator data={emptyCycleData} />
</div>
</div>
<div className="absolute left-0 bottom-0 flex w-64 flex-col rounded-[10px] bg-brand-surface-2 text-xs shadow">
<div className="flex flex-col items-start justify-center gap-2.5 p-3.5">
<span className="text-sm font-semibold text-brand-base">Cycle Name</span>
<div className="flex h-full w-full items-center gap-4">
<span className="h-2 w-20 rounded-full bg-brand-surface-2" />
<span className="h-2 w-20 rounded-full bg-brand-surface-2" />
</div>
</div>
<div className="border-t border-brand-base bg-brand-surface-1 px-4 py-3">
<LinearProgressIndicator data={emptyCycleData} />
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center gap-4 text-center ">
<h3 className="text-xl font-semibold">Create New Cycle</h3>
<p className="text-sm text-brand-secondary">
Sprint more effectively with Cycles by confining your project <br /> to a fixed amount of
time. Create new cycle now.
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { DateSelect, Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// helpers
import { getDateRangeStatus, isDateRangeValid } from "helpers/date-time.helper";
// types
import { ICycle } from "types";
type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleClose: () => void;
status: boolean;
data?: ICycle;
};
const defaultValues: Partial<ICycle> = {
name: "",
description: "",
start_date: null,
end_date: null,
};
export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const [isDateValid, setIsDateValid] = useState(true);
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
watch,
reset,
} = useForm<ICycle>({
defaultValues,
});
const handleCreateUpdateCycle = async (formData: Partial<ICycle>) => {
await handleFormSubmit(formData);
reset({
...defaultValues,
});
};
const cycleStatus =
data?.start_date && data?.end_date ? getDateRangeStatus(data?.start_date, data?.end_date) : "";
const dateChecker = async (payload: any) => {
await cyclesService
.cycleDateCheck(workspaceSlug as string, projectId as string, payload)
.then((res) => {
if (res.status) {
setIsDateValid(true);
} else {
setIsDateValid(false);
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
}
})
.catch((err) => {
console.log(err);
});
};
const checkEmptyDate =
(watch("start_date") === "" && watch("end_date") === "") ||
(!watch("start_date") && !watch("end_date"));
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
return (
<form onSubmit={handleSubmit(handleCreateUpdateCycle)}>
<div className="space-y-5">
<h3 className="text-lg font-medium leading-6 text-brand-base">
{status ? "Update" : "Create"} Cycle
</h3>
<div className="space-y-3">
<div>
<Input
mode="transparent"
autoComplete="off"
id="name"
name="name"
type="name"
className="resize-none text-xl"
placeholder="Title"
error={errors.name}
register={register}
validations={{
required: "Name is required",
maxLength: {
value: 255,
message: "Name should be less than 255 characters",
},
}}
/>
</div>
<div>
<TextArea
id="description"
name="description"
placeholder="Description"
className="h-32 resize-none text-sm"
mode="transparent"
error={errors.description}
register={register}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div>
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<DateSelect
label="Start date"
value={value}
onChange={(val) => {
onChange(val);
if (val && watch("end_date")) {
if (isDateRangeValid(val, `${watch("end_date")}`)) {
cycleStatus != "current" &&
dateChecker({
start_date: val,
end_date: watch("end_date"),
});
} else {
setIsDateValid(false);
setToastAlert({
type: "error",
title: "Error!",
message: "The date you have entered is invalid. Please check and enter a valid date.",
});
}
}
}}
/>
)}
/>
</div>
<div>
<Controller
control={control}
name="end_date"
render={({ field: { value, onChange } }) => (
<DateSelect
label="End date"
value={value}
onChange={(val) => {
onChange(val);
if (watch("start_date") && val) {
if (isDateRangeValid(`${watch("start_date")}`, val)) {
cycleStatus != "current" &&
dateChecker({
start_date: watch("start_date"),
end_date: val,
});
} else {
setIsDateValid(false);
setToastAlert({
type: "error",
title: "Error!",
message: "The date you have entered is invalid. Please check and enter a valid date.",
});
}
}
}}
/>
)}
/>
</div>
</div>
</div>
</div>
<div className="-mx-5 mt-5 flex justify-end gap-2 border-t border-brand-base px-5 pt-5">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton
type="submit"
className={
checkEmptyDate
? "cursor-pointer"
: isDateValid
? "cursor-pointer"
: "cursor-not-allowed"
}
loading={isSubmitting || checkEmptyDate ? false : isDateValid ? false : true}
>
{status
? isSubmitting
? "Updating Cycle..."
: "Update Cycle"
: isSubmitting
? "Creating Cycle..."
: "Create Cycle"}
</PrimaryButton>
</div>
</form>
);
};

View File

@ -0,0 +1,11 @@
export * from "./completed-cycles-list";
export * from "./cycles-list";
export * from "./delete-cycle-modal";
export * from "./form";
export * from "./modal";
export * from "./select";
export * from "./sidebar";
export * from "./single-cycle-card";
export * from "./empty-cycle";
export * from "./transfer-issues-modal";
export * from "./transfer-issues";

View File

@ -0,0 +1,181 @@
import { Fragment } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import cycleService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { CycleForm } from "components/cycles";
// helper
import { getDateRangeStatus } from "helpers/date-time.helper";
// types
import type { ICycle } from "types";
// fetch keys
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DRAFT_LIST,
CYCLE_INCOMPLETE_LIST,
} from "constants/fetch-keys";
type CycleModalProps = {
isOpen: boolean;
handleClose: () => void;
data?: ICycle;
};
export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
isOpen,
handleClose,
data,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const createCycle = async (payload: Partial<ICycle>) => {
await cycleService
.createCycle(workspaceSlug as string, projectId as string, payload)
.then((res) => {
switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string));
break;
case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
break;
case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
break;
default:
mutate(CYCLE_DRAFT_LIST(projectId as string));
}
mutate(CYCLE_INCOMPLETE_LIST(projectId as string));
handleClose();
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle created successfully.",
});
})
.catch((err) => {
setToastAlert({
type: "error",
title: "Error!",
message: "Error in creating cycle. Please try again.",
});
});
};
const updateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
await cycleService
.updateCycle(workspaceSlug as string, projectId as string, cycleId, payload)
.then((res) => {
switch (getDateRangeStatus(data?.start_date, data?.end_date)) {
case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string));
break;
case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
break;
case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
break;
default:
mutate(CYCLE_DRAFT_LIST(projectId as string));
}
if (
getDateRangeStatus(data?.start_date, data?.end_date) !=
getDateRangeStatus(res.start_date, res.end_date)
) {
switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string));
break;
case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
break;
case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
break;
default:
mutate(CYCLE_DRAFT_LIST(projectId as string));
}
}
handleClose();
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
})
.catch((err) => {
setToastAlert({
type: "error",
title: "Error!",
message: "Error in updating cycle. Please try again.",
});
});
};
const handleFormSubmit = async (formData: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;
const payload: Partial<ICycle> = {
...formData,
};
if (!data) await createCycle(payload);
else await updateCycle(data.id, payload);
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-brand-surface-1 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<CycleForm
handleFormSubmit={handleFormSubmit}
handleClose={handleClose}
status={data ? true : false}
data={data}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,127 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
import { CyclesIcon } from "components/icons";
// services
import cycleServices from "services/cycles.service";
// components
import { CreateUpdateCycleModal } from "components/cycles";
// fetch-keys
import { CYCLE_LIST } from "constants/fetch-keys";
export type IssueCycleSelectProps = {
projectId: string;
value: any;
onChange: (value: any) => void;
multiple?: boolean;
};
export const CycleSelect: React.FC<IssueCycleSelectProps> = ({
projectId,
value,
onChange,
multiple = false,
}) => {
// states
const [isCycleModalActive, setCycleModalActive] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: cycles } = useSWR(
workspaceSlug && projectId ? CYCLE_LIST(projectId) : null,
workspaceSlug && projectId
? () => cycleServices.getCycles(workspaceSlug as string, projectId)
: null
);
const options = cycles?.map((cycle) => ({ value: cycle.id, display: cycle.name }));
const openCycleModal = () => {
setCycleModalActive(true);
};
const closeCycleModal = () => {
setCycleModalActive(false);
};
return (
<>
<CreateUpdateCycleModal isOpen={isCycleModalActive} handleClose={closeCycleModal} />
<Listbox as="div" className="relative" value={value} onChange={onChange} multiple={multiple}>
{({ open }) => (
<>
<Listbox.Button
className={`flex cursor-pointer items-center gap-1 rounded-md border border-brand-base px-2 py-1 text-xs shadow-sm duration-300 hover:bg-brand-surface-1 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`}
>
<CyclesIcon className="h-3 w-3 text-brand-secondary" />
<div className="flex items-center gap-2 truncate">
{cycles?.find((c) => c.id === value)?.name ?? "Cycles"}
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className={`absolute mt-1 max-h-32 min-w-[8rem] overflow-y-auto whitespace-nowrap bg-brand-surface-2 shadow-lg text-xs z-10 rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none`}
>
<div className="py-1">
{options ? (
options.length > 0 ? (
options.map((option) => (
<Listbox.Option
key={option.value}
className={({ selected, active }) =>
`${
selected ||
(Array.isArray(value)
? value.includes(option.value)
: value === option.value)
? "bg-indigo-50 font-medium"
: ""
} ${
active ? "bg-indigo-50" : ""
} relative cursor-pointer select-none p-2 text-brand-base`
}
value={option.value}
>
<span className={` flex items-center gap-2 truncate`}>
{option.display}
</span>
</Listbox.Option>
))
) : (
<p className="text-center text-sm text-brand-secondary">No options</p>
)
) : (
<p className="text-center text-sm text-brand-secondary">Loading...</p>
)}
<button
type="button"
className="relative w-full flex select-none items-center gap-x-2 p-2 text-gray-400 hover:bg-indigo-50 hover:text-brand-base"
onClick={openCycleModal}
>
<PlusIcon className="h-4 w-4 text-gray-400" aria-hidden="true" />
<span>Create cycle</span>
</button>
</div>
</Listbox.Options>
</Transition>
</>
)}
</Listbox>
</>
);
};

View File

@ -0,0 +1,495 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import Image from "next/image";
import useSWR, { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
import { Disclosure, Popover, Transition } from "@headlessui/react";
import DatePicker from "react-datepicker";
// icons
import {
CalendarDaysIcon,
ChartPieIcon,
ArrowLongRightIcon,
TrashIcon,
UserCircleIcon,
ChevronDownIcon,
DocumentIcon,
LinkIcon,
} from "@heroicons/react/24/outline";
// ui
import { CustomMenu, Loader, ProgressBar } from "components/ui";
// hooks
import useToast from "hooks/use-toast";
// services
import cyclesService from "services/cycles.service";
// components
import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart";
import { DeleteCycleModal } from "components/cycles";
// icons
import { ExclamationIcon } from "components/icons";
// helpers
import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper";
import { isDateRangeValid, renderDateFormat, renderShortDate } from "helpers/date-time.helper";
// types
import { ICycle, IIssue } from "types";
// fetch-keys
import { CYCLE_DETAILS, CYCLE_ISSUES } from "constants/fetch-keys";
type Props = {
cycle: ICycle | undefined;
isOpen: boolean;
cycleStatus: string;
isCompleted: boolean;
};
export const CycleDetailsSidebar: React.FC<Props> = ({
cycle,
isOpen,
cycleStatus,
isCompleted,
}) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { setToastAlert } = useToast();
const defaultValues: Partial<ICycle> = {
start_date: new Date().toString(),
end_date: new Date().toString(),
};
const { data: issues } = useSWR<IIssue[]>(
workspaceSlug && projectId && cycleId ? CYCLE_ISSUES(cycleId as string) : null,
workspaceSlug && projectId && cycleId
? () =>
cyclesService.getCycleIssues(
workspaceSlug as string,
projectId as string,
cycleId as string
)
: null
);
const { reset, watch } = useForm({
defaultValues,
});
const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !cycleId) return;
mutate<ICycle>(
CYCLE_DETAILS(cycleId as string),
(prevData) => ({ ...(prevData as ICycle), ...data }),
false
);
cyclesService
.patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data)
.then(() => mutate(CYCLE_DETAILS(cycleId as string)))
.catch((e) => console.log(e));
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`)
.then(() => {
setToastAlert({
type: "success",
title: "Cycle link copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
};
useEffect(() => {
if (cycle)
reset({
...cycle,
});
}, [cycle, reset]);
const isStartValid = new Date(`${cycle?.start_date}`) <= new Date();
const isEndValid = new Date(`${cycle?.end_date}`) >= new Date(`${cycle?.start_date}`);
const progressPercentage = cycle
? Math.round((cycle.completed_issues / cycle.total_issues) * 100)
: null;
return (
<>
<DeleteCycleModal isOpen={cycleDeleteModal} setIsOpen={setCycleDeleteModal} data={cycle} />
<div
className={`fixed top-0 ${
isOpen ? "right-0" : "-right-[24rem]"
} z-20 h-full w-[24rem] overflow-y-auto border-l border-brand-base bg-brand-sidebar py-5 duration-300`}
>
{cycle ? (
<>
<div className="flex flex-col items-start justify-center">
<div className="flex gap-2.5 px-5 text-sm">
<div className="flex items-center ">
<span
className={`flex items-center rounded border-[0.5px] border-brand-base bg-brand-surface-1 px-2.5 py-1.5 text-center text-sm capitalize text-brand-muted-1 `}
>
{capitalizeFirstLetter(cycleStatus)}
</span>
</div>
<div className="relative flex h-full w-52 items-center justify-center gap-2 text-sm text-brand-muted-1">
<Popover className="flex h-full items-center justify-center rounded-lg">
{({ open }) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className={`group flex h-full items-center gap-1 rounded border-[0.5px] border-brand-base bg-brand-surface-1 px-2.5 py-1.5 text-brand-muted-1 ${
open ? "bg-brand-surface-1" : ""
}`}
>
<CalendarDaysIcon className="h-3 w-3" />
<span>{renderShortDate(new Date(`${cycle?.start_date}`))}</span>
</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 top-10 -right-5 z-20 transform overflow-hidden">
<DatePicker
selected={
watch("start_date")
? new Date(`${watch("start_date")}`)
: new Date()
}
onChange={(date) => {
if (date && watch("end_date")) {
if (
isDateRangeValid(renderDateFormat(date), `${watch("end_date")}`)
) {
submitChanges({
start_date: renderDateFormat(date),
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"The date you have entered is invalid. Please check and enter a valid date.",
});
}
}
}}
selectsStart
startDate={new Date(`${watch("start_date")}`)}
endDate={new Date(`${watch("end_date")}`)}
maxDate={new Date(`${watch("end_date")}`)}
shouldCloseOnSelect
inline
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<span>
<ArrowLongRightIcon className="h-3 w-3" />
</span>
<Popover className="flex h-full items-center justify-center rounded-lg">
{({ open }) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className={`group flex items-center gap-1 rounded border-[0.5px] border-brand-base bg-brand-surface-1 px-2.5 py-1.5 text-brand-muted-1 ${
open ? "bg-brand-surface-1" : ""
}`}
>
<CalendarDaysIcon className="h-3 w-3 " />
<span>{renderShortDate(new Date(`${cycle?.end_date}`))}</span>
</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 top-10 -right-5 z-20 transform overflow-hidden">
<DatePicker
selected={
watch("end_date") ? new Date(`${watch("end_date")}`) : new Date()
}
onChange={(date) => {
if (watch("start_date") && date) {
if (
isDateRangeValid(
`${watch("start_date")}`,
renderDateFormat(date)
)
) {
submitChanges({
end_date: renderDateFormat(date),
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"The date you have entered is invalid. Please check and enter a valid date.",
});
}
}
}}
selectsEnd
startDate={new Date(`${watch("start_date")}`)}
endDate={new Date(`${watch("end_date")}`)}
minDate={new Date(`${watch("start_date")}`)}
shouldCloseOnSelect
inline
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
<div className="flex flex-col gap-6 px-6 py-6 w-full">
<div className="flex flex-col items-start justify-start gap-2 w-full">
<div className="flex items-start justify-between gap-2 w-full">
<h4 className="text-xl font-semibold text-brand-base">{cycle.name}</h4>
<CustomMenu width="lg" ellipsis>
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<span className="whitespace-normal text-sm leading-5 text-brand-secondary">
{cycle.description}
</span>
</div>
<div className="flex flex-col gap-4 text-sm">
<div className="flex items-center justify-start gap-1">
<div className="flex w-40 items-center justify-start gap-2 text-brand-secondary">
<UserCircleIcon className="h-5 w-5" />
<span>Lead</span>
</div>
<div className="flex items-center gap-2.5">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
src={cycle.owned_by.avatar}
height={12}
width={12}
className="rounded-full"
alt={cycle.owned_by.first_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-800 capitalize text-white">
{cycle.owned_by.first_name.charAt(0)}
</span>
)}
<span className="text-brand-secondary">{cycle.owned_by.first_name}</span>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-40 items-center justify-start gap-2 text-brand-secondary">
<ChartPieIcon className="h-5 w-5" />
<span>Progress</span>
</div>
<div className="flex items-center gap-2.5 text-brand-secondary">
<span className="h-4 w-4">
<ProgressBar value={cycle.completed_issues} maxValue={cycle.total_issues} />
</span>
{cycle.completed_issues}/{cycle.total_issues}
</div>
</div>
</div>
</div>
</div>
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-brand-base p-6">
<Disclosure defaultOpen>
{({ open }) => (
<div
className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}
>
<div className="flex w-full items-center justify-between gap-2 ">
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-brand-secondary">Progress</span>
{!open && progressPercentage ? (
<span className="rounded bg-[#09A953]/10 px-1.5 py-0.5 text-xs text-[#09A953]">
{progressPercentage ? `${progressPercentage}%` : ""}
</span>
) : (
""
)}
</div>
{isStartValid && isEndValid ? (
<Disclosure.Button>
<ChevronDownIcon
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
aria-hidden="true"
/>
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<ExclamationIcon height={14} width={14} />
<span className="text-xs italic text-brand-secondary">
{cycleStatus === "upcoming"
? "Cycle is yet to start."
: "Invalid date. Please enter valid date."}
</span>
</div>
)}
</div>
<Transition show={open}>
<Disclosure.Panel>
{isStartValid && isEndValid ? (
<div className=" h-full w-full py-4">
<div className="flex items-start justify-between gap-4 py-2 text-xs">
<div className="flex items-center gap-1">
<span>
<DocumentIcon className="h-3 w-3 text-brand-secondary" />
</span>
<span>
Pending Issues -{" "}
{cycle.total_issues -
(cycle.completed_issues + cycle.cancelled_issues)}
</span>
</div>
<div className="flex items-center gap-3 text-brand-base">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</div>
<div className="relative h-40 w-80">
<ProgressChart
issues={issues ?? []}
start={cycle?.start_date ?? ""}
end={cycle?.end_date ?? ""}
/>
</div>
</div>
) : (
""
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-brand-base p-6">
<Disclosure defaultOpen>
{({ open }) => (
<div
className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-brand-secondary">Other Information</span>
</div>
{cycle.total_issues > 0 ? (
<Disclosure.Button>
<ChevronDownIcon
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
aria-hidden="true"
/>
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<ExclamationIcon height={14} width={14} />
<span className="text-xs italic text-brand-secondary">
No issues found. Please add issue.
</span>
</div>
)}
</div>
<Transition show={open}>
<Disclosure.Panel>
{cycle.total_issues > 0 ? (
<div className=" h-full w-full py-4">
<SidebarProgressStats
issues={issues ?? []}
groupedIssues={{
backlog: cycle.backlog_issues,
unstarted: cycle.unstarted_issues,
started: cycle.started_issues,
completed: cycle.completed_issues,
cancelled: cycle.cancelled_issues,
}}
/>
</div>
) : (
""
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
</>
) : (
<Loader className="px-5">
<div className="space-y-2">
<Loader.Item height="15px" width="50%" />
<Loader.Item height="15px" width="30%" />
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</div>
</>
);
};

View File

@ -0,0 +1,417 @@
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, LinearProgressIndicator, Tooltip } from "components/ui";
import { Disclosure, Transition } from "@headlessui/react";
// icons
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
import { TargetIcon } from "components/icons";
import {
ChevronDownIcon,
LinkIcon,
PencilIcon,
StarIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import {
CompletedCyclesResponse,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
ICycle,
} from "types";
// fetch-keys
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DRAFT_LIST,
} from "constants/fetch-keys";
type TSingleStatProps = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
isCompleted?: boolean;
};
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
export const SingleCycleCard: React.FC<TSingleStatProps> = ({
cycle,
handleEditCycle,
handleDeleteCycle,
isCompleted = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId || !cycle) return;
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
switch (cycleStatus) {
case "current":
case "upcoming":
mutate<CurrentAndUpcomingCyclesResponse>(
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
(prevData) => ({
current_cycle: (prevData?.current_cycle ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
}),
false
);
break;
case "completed":
mutate<CompletedCyclesResponse>(
CYCLE_COMPLETE_LIST(projectId as string),
(prevData) => ({
completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
}),
false
);
break;
case "draft":
mutate<DraftCyclesResponse>(
CYCLE_DRAFT_LIST(projectId as string),
(prevData) => ({
draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
}),
false
);
break;
}
cyclesService
.addCycleToFavorites(workspaceSlug as string, projectId as string, {
cycle: cycle.id,
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId || !cycle) return;
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
switch (cycleStatus) {
case "current":
case "upcoming":
mutate<CurrentAndUpcomingCyclesResponse>(
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
(prevData) => ({
current_cycle: (prevData?.current_cycle ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
}),
false
);
break;
case "completed":
mutate<CompletedCyclesResponse>(
CYCLE_COMPLETE_LIST(projectId as string),
(prevData) => ({
completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
}),
false
);
break;
case "draft":
mutate<DraftCyclesResponse>(
CYCLE_DRAFT_LIST(projectId as string),
(prevData) => ({
draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
}),
false
);
break;
}
cyclesService
.removeCycleFromFavorites(workspaceSlug as string, projectId as string, cycle.id)
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the cycle from favorites. Please try again.",
});
});
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value:
cycle.total_issues > 0
? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100
: 0,
color: group.color,
}));
return (
<div>
<div className="flex flex-col rounded-[10px] bg-brand-surface-2 text-xs shadow">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-start justify-between gap-1">
<Tooltip tooltipContent={cycle.name} position="top-left">
<h3 className="break-all text-lg font-semibold">
{truncateText(cycle.name, 75)}
</h3>
</Tooltip>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<StarIcon className="h-4 w-4 " color="#858E96" />
</button>
)}
</div>
<div className="flex items-center justify-start gap-5">
<div className="flex items-start gap-1 ">
<CalendarDaysIcon className="h-4 w-4 text-brand-base" />
<span className="text-brand-secondary">Start :</span>
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<div className="flex items-start gap-1 ">
<TargetIcon className="h-4 w-4 text-brand-base" />
<span className="text-brand-secondary">End :</span>
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-2.5">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-base capitalize bg-brand-secondary">
{cycle.owned_by.first_name.charAt(0)}
</span>
)}
<span className="text-brand-base">{cycle.owned_by.first_name}</span>
</div>
<div className="flex items-center">
{!isCompleted && (
<button
onClick={(e) => {
e.preventDefault();
handleEditCycle();
}}
className="flex cursor-pointer items-center rounded p-1 duration-300 hover:bg-brand-surface-1"
>
<span>
<PencilIcon className="h-4 w-4" />
</span>
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleDeleteCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</a>
</Link>
<div className="flex h-full flex-col rounded-b-[10px]">
<Disclosure>
{({ open }) => (
<div
className={`flex h-full w-full flex-col border-t border-brand-base bg-brand-surface-1 ${
open ? "" : "flex-row"
}`}
>
<div className="flex w-full items-center gap-2 px-4 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
<Disclosure.Button>
<span className="p-1">
<ChevronDownIcon
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
aria-hidden="true"
/>
</span>
</Disclosure.Button>
</div>
<Transition show={open}>
<Disclosure.Panel>
<div className="overflow-hidden rounded-b-md bg-brand-surface-2 py-3 shadow">
<div className="col-span-2 space-y-3 px-4">
<div className="space-y-3 text-xs">
{stateGroups.map((group) => (
<div
key={group.key}
className="flex items-center justify-between gap-2"
>
<div className="flex items-center gap-2">
<span
className="block h-2 w-2 rounded-full"
style={{
backgroundColor: group.color,
}}
/>
<h6 className="text-xs">{group.title}</h6>
</div>
<div>
<span>
{cycle[group.key as keyof ICycle] as number}{" "}
<span className="text-brand-secondary">
-{" "}
{cycle.total_issues > 0
? `${Math.round(
((cycle[group.key as keyof ICycle] as number) /
cycle.total_issues) *
100
)}%`
: "0%"}
</span>
</span>
</div>
</div>
))}
</div>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,170 @@
import React, { useState, useEffect } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// component
import { Dialog, Transition } from "@headlessui/react";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
//icons
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { ContrastIcon, CyclesIcon, ExclamationIcon, TransferIcon } from "components/icons";
// fetch-key
import { CYCLE_INCOMPLETE_LIST, CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
// types
import { ICycle } from "types";
//helper
import { getDateRangeStatus } from "helpers/date-time.helper";
import useIssuesView from "hooks/use-issues-view";
type Props = {
isOpen: boolean;
handleClose: () => void;
};
export const TransferIssuesModal: React.FC<Props> = ({ isOpen, handleClose }) => {
const [query, setQuery] = useState("");
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { params } = useIssuesView();
const { setToastAlert } = useToast();
const transferIssue = async (payload: any) => {
await cyclesService
.transferIssues(workspaceSlug as string, projectId as string, cycleId as string, payload)
.then((res) => {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
setToastAlert({
type: "success",
title: "Issues transfered successfully",
message: "Issues have been transferred successfully",
});
})
.catch((err) => {
setToastAlert({
type: "error",
title: "Error!",
message: "Issues cannot be transfer. Please try again.",
});
});
};
const { data: incompleteCycles } = useSWR(
workspaceSlug && projectId ? CYCLE_INCOMPLETE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => cyclesService.getIncompleteCycles(workspaceSlug as string, projectId as string)
: null
);
const filteredOptions =
query === ""
? incompleteCycles
: incompleteCycles?.filter((option) =>
option.name.toLowerCase().includes(query.toLowerCase())
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
}, [handleClose]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10">
<div className="mt-10 flex min-h-full items-start justify-center p-4 text-center sm:p-0 md:mt-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-brand-surface-1 py-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between px-5">
<div className="flex items-center gap-2">
<TransferIcon className="h-4 w-5" color="#495057" />
<h4 className="text-gray-700 font-medium text-[1.50rem]">Transfer Issues</h4>
</div>
<button onClick={handleClose}>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2 pb-3 px-5 border-b border-brand-base">
<MagnifyingGlassIcon className="h-4 w-4 text-brand-secondary" />
<input
className="outline-none"
placeholder="Search for a cycle..."
onChange={(e) => setQuery(e.target.value)}
value={query}
/>
</div>
<div className="flex flex-col items-start w-full gap-2 px-5">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option: ICycle) => (
<button
key={option.id}
className="flex items-center gap-4 px-4 py-3 text-gray-600 text-sm rounded w-full hover:bg-brand-surface-1"
onClick={() => {
transferIssue({
new_cycle_id: option?.id,
});
handleClose();
}}
>
<ContrastIcon className="h-5 w-5" />
<div className="flex justify-between w-full">
<span>{option?.name}</span>
<span className=" flex bg-gray-200 capitalize px-2 rounded-full items-center">
{getDateRangeStatus(option?.start_date, option?.end_date)}
</span>
</div>
</button>
))
) : (
<div className="flex items-center justify-center gap-4 p-5 text-sm w-full">
<ExclamationIcon height={14} width={14} />
<span className="text-center text-brand-secondary">
You dont have any current cycle. Please create one to transfer the
issues.
</span>
</div>
)
) : (
<p className="text-center text-brand-secondary">Loading...</p>
)}
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,56 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// component
import { PrimaryButton, Tooltip } from "components/ui";
// icon
import { ExclamationIcon, TransferIcon } from "components/icons";
// services
import cycleServices from "services/cycles.service";
// fetch-key
import { CYCLE_DETAILS } from "constants/fetch-keys";
type Props = {
handleClick: () => void;
};
export const TransferIssues: React.FC<Props> = ({ handleClick }) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { data: cycleDetails } = useSWR(
cycleId ? CYCLE_DETAILS(cycleId as string) : null,
workspaceSlug && projectId && cycleId
? () =>
cycleServices.getCycleDetails(
workspaceSlug as string,
projectId as string,
cycleId as string
)
: null
);
const transferableIssuesCount = cycleDetails
? cycleDetails.backlog_issues + cycleDetails.unstarted_issues + cycleDetails.started_issues
: 0;
return (
<div className="flex items-center justify-between -mt-4 mb-4">
<div className="flex items-center gap-2 text-sm text-brand-secondary">
<ExclamationIcon height={14} width={14} />
<span>Completed cycles are not editable.</span>
</div>
{transferableIssuesCount > 0 && (
<div>
<PrimaryButton onClick={handleClick} className="flex items-center gap-3 rounded-lg">
<TransferIcon className="h-4 w-4" color="white"/>
<span className="text-white">Transfer Issues</span>
</PrimaryButton>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,23 @@
import React, { useState, useEffect } from "react";
// react beautiful dnd
import { Droppable, DroppableProps } from "react-beautiful-dnd";
const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const animation = requestAnimationFrame(() => setEnabled(true));
return () => {
cancelAnimationFrame(animation);
setEnabled(false);
};
}, []);
if (!enabled) return null;
return <Droppable {...props}>{children}</Droppable>;
};
export default StrictModeDroppable;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
export const saveRecentEmoji = (emoji: string) => {
const recentEmojis = localStorage.getItem("recentEmojis");
if (recentEmojis) {
const recentEmojisArray = recentEmojis.split(",");
if (recentEmojisArray.includes(emoji)) {
const index = recentEmojisArray.indexOf(emoji);
recentEmojisArray.splice(index, 1);
}
recentEmojisArray.unshift(emoji);
if (recentEmojisArray.length > 18) {
recentEmojisArray.pop();
}
localStorage.setItem("recentEmojis", recentEmojisArray.join(","));
} else {
localStorage.setItem("recentEmojis", emoji);
}
};
export const getRecentEmojis = () => {
const recentEmojis = localStorage.getItem("recentEmojis");
if (recentEmojis) {
const recentEmojisArray = recentEmojis.split(",");
return recentEmojisArray;
}
return [];
};

View File

@ -0,0 +1,607 @@
{
"material_rounded": [
{
"name": "search"
},
{
"name": "home"
},
{
"name": "menu"
},
{
"name": "close"
},
{
"name": "settings"
},
{
"name": "done"
},
{
"name": "check_circle"
},
{
"name": "favorite"
},
{
"name": "add"
},
{
"name": "delete"
},
{
"name": "arrow_back"
},
{
"name": "star"
},
{
"name": "logout"
},
{
"name": "add_circle"
},
{
"name": "cancel"
},
{
"name": "arrow_drop_down"
},
{
"name": "more_vert"
},
{
"name": "check"
},
{
"name": "check_box"
},
{
"name": "toggle_on"
},
{
"name": "open_in_new"
},
{
"name": "refresh"
},
{
"name": "login"
},
{
"name": "radio_button_unchecked"
},
{
"name": "more_horiz"
},
{
"name": "apps"
},
{
"name": "radio_button_checked"
},
{
"name": "download"
},
{
"name": "remove"
},
{
"name": "toggle_off"
},
{
"name": "bolt"
},
{
"name": "arrow_upward"
},
{
"name": "filter_list"
},
{
"name": "delete_forever"
},
{
"name": "autorenew"
},
{
"name": "key"
},
{
"name": "sort"
},
{
"name": "sync"
},
{
"name": "add_box"
},
{
"name": "block"
},
{
"name": "restart_alt"
},
{
"name": "menu_open"
},
{
"name": "shopping_cart_checkout"
},
{
"name": "expand_circle_down"
},
{
"name": "backspace"
},
{
"name": "undo"
},
{
"name": "done_all"
},
{
"name": "do_not_disturb_on"
},
{
"name": "open_in_full"
},
{
"name": "double_arrow"
},
{
"name": "sync_alt"
},
{
"name": "zoom_in"
},
{
"name": "done_outline"
},
{
"name": "drag_indicator"
},
{
"name": "fullscreen"
},
{
"name": "star_half"
},
{
"name": "settings_accessibility"
},
{
"name": "reply"
},
{
"name": "exit_to_app"
},
{
"name": "unfold_more"
},
{
"name": "library_add"
},
{
"name": "cached"
},
{
"name": "select_check_box"
},
{
"name": "terminal"
},
{
"name": "change_circle"
},
{
"name": "disabled_by_default"
},
{
"name": "swap_horiz"
},
{
"name": "swap_vert"
},
{
"name": "app_registration"
},
{
"name": "download_for_offline"
},
{
"name": "close_fullscreen"
},
{
"name": "file_open"
},
{
"name": "minimize"
},
{
"name": "open_with"
},
{
"name": "dataset"
},
{
"name": "add_task"
},
{
"name": "start"
},
{
"name": "keyboard_voice"
},
{
"name": "create_new_folder"
},
{
"name": "forward"
},
{
"name": "download"
},
{
"name": "settings_applications"
},
{
"name": "compare_arrows"
},
{
"name": "redo"
},
{
"name": "zoom_out"
},
{
"name": "publish"
},
{
"name": "html"
},
{
"name": "token"
},
{
"name": "switch_access_shortcut"
},
{
"name": "fullscreen_exit"
},
{
"name": "sort_by_alpha"
},
{
"name": "delete_sweep"
},
{
"name": "indeterminate_check_box"
},
{
"name": "view_timeline"
},
{
"name": "settings_backup_restore"
},
{
"name": "arrow_drop_down_circle"
},
{
"name": "assistant_navigation"
},
{
"name": "sync_problem"
},
{
"name": "clear_all"
},
{
"name": "density_medium"
},
{
"name": "heart_plus"
},
{
"name": "filter_alt_off"
},
{
"name": "expand"
},
{
"name": "subdirectory_arrow_right"
},
{
"name": "download_done"
},
{
"name": "arrow_outward"
},
{
"name": "123"
},
{
"name": "swipe_left"
},
{
"name": "auto_mode"
},
{
"name": "saved_search"
},
{
"name": "place_item"
},
{
"name": "system_update_alt"
},
{
"name": "javascript"
},
{
"name": "search_off"
},
{
"name": "output"
},
{
"name": "select_all"
},
{
"name": "fit_screen"
},
{
"name": "swipe_up"
},
{
"name": "dynamic_form"
},
{
"name": "hide_source"
},
{
"name": "swipe_right"
},
{
"name": "switch_access_shortcut_add"
},
{
"name": "browse_gallery"
},
{
"name": "css"
},
{
"name": "density_small"
},
{
"name": "assistant_direction"
},
{
"name": "check_small"
},
{
"name": "youtube_searched_for"
},
{
"name": "move_up"
},
{
"name": "swap_horizontal_circle"
},
{
"name": "data_thresholding"
},
{
"name": "install_mobile"
},
{
"name": "move_down"
},
{
"name": "dataset_linked"
},
{
"name": "keyboard_command_key"
},
{
"name": "view_kanban"
},
{
"name": "swipe_down"
},
{
"name": "key_off"
},
{
"name": "transcribe"
},
{
"name": "send_time_extension"
},
{
"name": "swipe_down_alt"
},
{
"name": "swipe_left_alt"
},
{
"name": "swipe_right_alt"
},
{
"name": "swipe_up_alt"
},
{
"name": "keyboard_option_key"
},
{
"name": "cycle"
},
{
"name": "rebase"
},
{
"name": "rebase_edit"
},
{
"name": "empty_dashboard"
},
{
"name": "magic_exchange"
},
{
"name": "acute"
},
{
"name": "point_scan"
},
{
"name": "step_into"
},
{
"name": "cheer"
},
{
"name": "emoticon"
},
{
"name": "explosion"
},
{
"name": "water_bottle"
},
{
"name": "weather_hail"
},
{
"name": "syringe"
},
{
"name": "pill"
},
{
"name": "genetics"
},
{
"name": "allergy"
},
{
"name": "medical_mask"
},
{
"name": "body_fat"
},
{
"name": "barefoot"
},
{
"name": "infrared"
},
{
"name": "wrist"
},
{
"name": "metabolism"
},
{
"name": "conditions"
},
{
"name": "taunt"
},
{
"name": "altitude"
},
{
"name": "tibia"
},
{
"name": "footprint"
},
{
"name": "eyeglasses"
},
{
"name": "man_3"
},
{
"name": "woman_2"
},
{
"name": "rheumatology"
},
{
"name": "tornado"
},
{
"name": "landslide"
},
{
"name": "foggy"
},
{
"name": "severe_cold"
},
{
"name": "tsunami"
},
{
"name": "vape_free"
},
{
"name": "sign_language"
},
{
"name": "emoji_symbols"
},
{
"name": "clear_night"
},
{
"name": "emoji_food_beverage"
},
{
"name": "hive"
},
{
"name": "thunderstorm"
},
{
"name": "communication"
},
{
"name": "rocket"
},
{
"name": "pets"
},
{
"name": "public"
},
{
"name": "quiz"
},
{
"name": "mood"
},
{
"name": "gavel"
},
{
"name": "eco"
},
{
"name": "diamond"
},
{
"name": "forest"
},
{
"name": "rainy"
},
{
"name": "skull"
}
]
}

View File

@ -0,0 +1,214 @@
import React, { useEffect, useState, useRef } from "react";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react";
// react colors
import { TwitterPicker } from "react-color";
// types
import { Props } from "./types";
// emojis
import emojis from "./emojis.json";
import icons from "./icons.json";
// helpers
import { getRecentEmojis, saveRecentEmoji } from "./helpers";
import { getRandomEmoji } from "helpers/common.helper";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
const tabOptions = [
{
key: "emoji",
title: "Emoji",
},
{
key: "icon",
title: "Icon",
},
];
const EmojiIconPicker: React.FC<Props> = ({
label,
value,
onChange,
onIconColorChange,
onIconsClick,
}) => {
const ref = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [openColorPicker, setOpenColorPicker] = useState(false);
const [activeColor, setActiveColor] = useState<string>("#020617");
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
useEffect(() => {
setRecentEmojis(getRecentEmojis());
}, []);
useOutsideClickDetector(ref, () => {
setIsOpen(false);
});
useEffect(() => {
if (!value || value?.length === 0) onChange(getRandomEmoji());
}, [value, onChange]);
return (
<Popover className="relative z-[1]" ref={ref}>
<Popover.Button
className="rounded-full bg-brand-surface-1 p-2 outline-none sm:text-sm"
onClick={() => setIsOpen((prev) => !prev)}
>
{label}
</Popover.Button>
<Transition
show={isOpen}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Popover.Panel className="absolute z-10 mt-2 w-[250px] rounded-[4px] bg-brand-surface-2 shadow-lg">
<div className="h-[230px] w-[250px] overflow-auto border border-brand-base rounded-[4px] bg-brand-surface-2 p-2 shadow-xl">
<Tab.Group as="div" className="flex h-full w-full flex-col">
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 p-1">
{tabOptions.map((tab) => (
<Tab key={tab.key} as={React.Fragment}>
{({ selected }) => (
<button
type="button"
onClick={() => {
setOpenColorPicker(false);
}}
className={`-my-1 w-1/2 border-b pb-2 text-center text-sm font-medium outline-none transition-colors ${
selected ? "border-theme text-theme" : "border-transparent text-gray-500"
}`}
>
{tab.title}
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels className="flex-1 overflow-y-auto">
<Tab.Panel>
{recentEmojis.length > 0 && (
<div className="py-2">
<h3 className="mb-2 ml-1 text-xs text-gray-400">Recent</h3>
<div className="grid grid-cols-8 gap-2">
{recentEmojis.map((emoji) => (
<button
type="button"
className="h-4 w-4 select-none text-sm hover:bg-brand-surface-2 flex items-center justify-between"
key={emoji}
onClick={() => {
onChange(emoji);
setIsOpen(false);
}}
>
{String.fromCodePoint(parseInt(emoji))}
</button>
))}
</div>
</div>
)}
<hr className="w-full h-[1px] mb-2" />
<div>
<div className="grid grid-cols-8 gap-x-2 gap-y-3">
{emojis.map((emoji) => (
<button
type="button"
className="h-4 w-4 mb-1 select-none text-sm hover:bg-brand-surface-2 flex items-center"
key={emoji}
onClick={() => {
onChange(emoji);
saveRecentEmoji(emoji);
setIsOpen(false);
}}
>
{String.fromCodePoint(parseInt(emoji))}
</button>
))}
</div>
</div>
</Tab.Panel>
<div className="py-2">
<Tab.Panel className="flex h-full w-full flex-col justify-center">
<div className="relative">
<div className="pb-2 px-1 flex items-center justify-between">
{[
"#FF6B00",
"#8CC1FF",
"#FCBE1D",
"#18904F",
"#ADF672",
"#05C3FF",
"#000000",
].map((curCol) => (
<span
className="w-4 h-4 rounded-full cursor-pointer"
style={{ backgroundColor: curCol }}
onClick={() => setActiveColor(curCol)}
/>
))}
<button
type="button"
onClick={() => setOpenColorPicker((prev) => !prev)}
className="flex items-center gap-1"
>
<span
className="w-4 h-4 rounded-full conical-gradient"
style={{ backgroundColor: activeColor }}
/>
</button>
</div>
<div>
<TwitterPicker
className={`m-2 !absolute top-4 left-4 z-10 ${
openColorPicker ? "block" : "hidden"
}`}
color={activeColor}
onChange={(color) => {
setActiveColor(color.hex);
if (onIconColorChange) onIconColorChange(color.hex);
}}
triangle="hide"
width="205px"
/>
</div>
</div>
<hr className="w-full h-[1px] mb-1" />
<div className="grid grid-cols-8 mt-1 ml-1 gap-x-2 gap-y-3">
{icons.material_rounded.map((icon) => (
<button
type="button"
className="h-4 w-4 mb-1 select-none text-lg hover:bg-brand-surface-2 flex items-center"
key={icon.name}
onClick={() => {
if (onIconsClick) onIconsClick(icon.name);
setIsOpen(false);
}}
>
<span
style={{ color: activeColor }}
className="material-symbols-rounded text-lg"
>
{icon.name}
</span>
</button>
))}
</div>
</Tab.Panel>
</div>
</Tab.Panels>
</Tab.Group>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
};
export default EmojiIconPicker;

View File

@ -0,0 +1,7 @@
export type Props = {
label: string | React.ReactNode;
value: any;
onChange: (data: any) => void;
onIconsClick?: (data: any) => void;
onIconColorChange?: (data: any) => void;
};

View File

@ -0,0 +1,407 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import estimatesService from "services/estimates.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// helpers
import { checkDuplicates } from "helpers/array.helper";
// types
import { IEstimate, IEstimateFormData } from "types";
// fetch-keys
import { ESTIMATES_LIST, ESTIMATE_DETAILS } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IEstimate;
};
type FormValues = {
name: string;
description: string;
value1: string;
value2: string;
value3: string;
value4: string;
value5: string;
value6: string;
};
const defaultValues: Partial<FormValues> = {
name: "",
description: "",
value1: "",
value2: "",
value3: "",
value4: "",
value5: "",
value6: "",
};
export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data, isOpen }) => {
const {
register,
formState: { isSubmitting },
handleSubmit,
reset,
} = useForm<FormValues>({
defaultValues,
});
const onClose = () => {
handleClose();
reset();
};
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const createEstimate = async (payload: IEstimateFormData) => {
if (!workspaceSlug || !projectId) return;
await estimatesService
.createEstimate(workspaceSlug as string, projectId as string, payload)
.then(() => {
mutate(ESTIMATES_LIST(projectId as string));
onClose();
})
.catch((err) => {
if (err.status === 400)
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate with that name already exists. Please try again with another name.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate could not be created. Please try again.",
});
});
};
const updateEstimate = async (payload: IEstimateFormData) => {
if (!workspaceSlug || !projectId || !data) return;
mutate<IEstimate[]>(
ESTIMATES_LIST(projectId.toString()),
(prevData) =>
prevData?.map((p) => {
if (p.id === data.id)
return {
...p,
name: payload.estimate.name,
description: payload.estimate.description,
points: p.points.map((point, index) => ({
...point,
value: payload.estimate_points[index].value,
})),
};
return p;
}),
false
);
await estimatesService
.patchEstimate(workspaceSlug as string, projectId as string, data?.id as string, payload)
.then(() => {
mutate(ESTIMATES_LIST(projectId.toString()));
mutate(ESTIMATE_DETAILS(data.id));
handleClose();
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate could not be updated. Please try again.",
});
});
onClose();
};
const onSubmit = async (formData: FormValues) => {
if (!formData.name || formData.name === "") {
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate title cannot be empty.",
});
return;
}
if (
formData.value1 === "" ||
formData.value2 === "" ||
formData.value3 === "" ||
formData.value4 === "" ||
formData.value5 === "" ||
formData.value6 === ""
) {
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate point cannot be empty.",
});
return;
}
if (
checkDuplicates([
formData.value1,
formData.value2,
formData.value3,
formData.value4,
formData.value5,
formData.value6,
])
) {
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate points cannot have duplicate values.",
});
return;
}
const payload: IEstimateFormData = {
estimate: {
name: formData.name,
description: formData.description,
},
estimate_points: [
{
key: 0,
value: formData.value1,
},
{
key: 1,
value: formData.value2,
},
{
key: 2,
value: formData.value3,
},
{
key: 3,
value: formData.value4,
},
{
key: 4,
value: formData.value5,
},
{
key: 5,
value: formData.value6,
},
],
};
if (data) await updateEstimate(payload);
else await createEstimate(payload);
};
useEffect(() => {
if (data)
reset({
...defaultValues,
...data,
value1: data.points[0]?.value,
value2: data.points[1]?.value,
value3: data.points[2]?.value,
value4: data.points[3]?.value,
value5: data.points[4]?.value,
value6: data.points[5]?.value,
});
else reset({ ...defaultValues });
}, [data, reset]);
return (
<>
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-brand-base px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-3">
<div className="text-lg font-medium leading-6">
{data ? "Update" : "Create"} Estimate
</div>
<div>
<Input
id="name"
name="name"
type="name"
placeholder="Title"
autoComplete="off"
className="resize-none text-xl"
register={register}
/>
</div>
<div>
<TextArea
id="description"
name="description"
placeholder="Description"
className="h-32 resize-none text-sm"
register={register}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-brand-surface-2">
<span className="rounded-lg px-2 text-sm text-brand-secondary">1</span>
<span className="rounded-r-lg bg-brand-base">
<Input
id="name"
name="value1"
type="name"
className="rounded-l-none"
placeholder="Point 1"
autoComplete="off"
register={register}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-brand-surface-2">
<span className="rounded-lg px-2 text-sm text-brand-secondary">2</span>
<span className="rounded-r-lg bg-brand-base">
<Input
id="name"
name="value2"
type="name"
className="rounded-l-none"
placeholder="Point 2"
autoComplete="off"
register={register}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-brand-surface-2">
<span className="rounded-lg px-2 text-sm text-brand-secondary">3</span>
<span className="rounded-r-lg bg-brand-base">
<Input
id="name"
name="value3"
type="name"
className="rounded-l-none"
placeholder="Point 3"
autoComplete="off"
register={register}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-brand-surface-2">
<span className="rounded-lg px-2 text-sm text-brand-secondary">4</span>
<span className="rounded-r-lg bg-brand-base">
<Input
id="name"
name="value4"
type="name"
className="rounded-l-none"
placeholder="Point 4"
autoComplete="off"
register={register}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-brand-surface-2">
<span className="rounded-lg px-2 text-sm text-brand-secondary">5</span>
<span className="rounded-r-lg bg-brand-base">
<Input
id="name"
name="value5"
type="name"
className="rounded-l-none"
placeholder="Point 5"
autoComplete="off"
register={register}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-brand-surface-2">
<span className="rounded-lg px-2 text-sm text-brand-secondary">6</span>
<span className="rounded-r-lg bg-brand-base">
<Input
id="name"
name="value6"
type="name"
className="rounded-l-none"
placeholder="Point 6"
autoComplete="off"
register={register}
/>
</span>
</span>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{data
? isSubmitting
? "Updating Estimate..."
: "Update Estimate"
: isSubmitting
? "Creating Estimate..."
: "Create Estimate"}
</PrimaryButton>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
};

View File

@ -0,0 +1,104 @@
import React, { useEffect, useState } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// types
import { IEstimate } from "types";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { SecondaryButton, DangerButton } from "components/ui";
type Props = {
isOpen: boolean;
handleClose: () => void;
data: IEstimate;
handleDelete: () => void;
};
export const DeleteEstimateModal: React.FC<Props> = ({
isOpen,
handleClose,
data,
handleDelete,
}) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
setIsDeleteLoading(false);
}, [isOpen]);
const onClose = () => {
setIsDeleteLoading(false);
handleClose();
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-100 p-4">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Estimate</h3>
</span>
</div>
<span>
<p className="break-all text-sm leading-7 text-gray-500">
Are you sure you want to delete estimate-{" "}
<span className="break-all font-semibold">{data.name}</span>
{""}? All of the data related to the estiamte will be permanently removed.
This action cannot be undone.
</p>
</span>
<div className="flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<DangerButton
onClick={() => {
setIsDeleteLoading(true);
handleDelete();
}}
loading={isDeleteLoading}
>
{isDeleteLoading ? "Deleting..." : "Delete Estimate"}
</DangerButton>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,3 @@
export * from "./create-update-estimate-modal";
export * from "./single-estimate";
export * from "./delete-estimate-modal";

View File

@ -0,0 +1,145 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// services
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// components
import { DeleteEstimateModal } from "components/estimates";
// ui
import { CustomMenu, SecondaryButton } from "components/ui";
//icons
import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IEstimate } from "types";
type Props = {
estimate: IEstimate;
editEstimate: (estimate: IEstimate) => void;
handleEstimateDelete: (estimateId: string) => void;
};
export const SingleEstimate: React.FC<Props> = ({
estimate,
editEstimate,
handleEstimateDelete,
}) => {
const [isDeleteEstimateModalOpen, setIsDeleteEstimateModalOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const { projectDetails, mutateProjectDetails } = useProjectDetails();
const handleUseEstimate = async () => {
if (!workspaceSlug || !projectId) return;
const payload = {
estimate: estimate.id,
};
mutateProjectDetails((prevData) => {
if (!prevData) return prevData;
return { ...prevData, estimate: estimate.id };
}, false);
await projectService
.updateProject(workspaceSlug as string, projectId as string, payload)
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate points could not be used. Please try again.",
});
});
};
return (
<>
<div className="gap-2 py-3">
<div className="flex items-center justify-between">
<div>
<h6 className="flex w-[40vw] items-center gap-2 truncate text-sm font-medium">
{estimate.name}
{projectDetails?.estimate && projectDetails?.estimate === estimate.id && (
<span className="rounded bg-green-500/20 px-2 py-0.5 text-xs capitalize text-green-500">
In use
</span>
)}
</h6>
<p className="font-sm w-[40vw] truncate text-[14px] font-normal text-brand-secondary">
{estimate.description}
</p>
</div>
<div className="flex items-center gap-2">
{projectDetails?.estimate !== estimate.id && estimate.points.length > 0 && (
<SecondaryButton onClick={handleUseEstimate} className="py-1">
Use
</SecondaryButton>
)}
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
editEstimate(estimate);
}}
>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-3.5 w-3.5" />
<span>Edit estimate</span>
</div>
</CustomMenu.MenuItem>
{projectDetails?.estimate !== estimate.id && (
<CustomMenu.MenuItem
onClick={() => {
setIsDeleteEstimateModalOpen(true);
}}
>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-3.5 w-3.5" />
<span>Delete estimate</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
</div>
</div>
{estimate.points.length > 0 ? (
<div className="flex text-xs text-brand-secondary">
Estimate points (
<span className="flex gap-1">
{orderArrayBy(estimate.points, "key").map((point, index) => (
<h6 key={point.id} className="text-brand-secondary">
{point.value}
{index !== estimate.points.length - 1 && ","}{" "}
</h6>
))}
</span>
)
</div>
) : (
<div>
<p className="text-xs text-brand-secondary">No estimate points</p>
</div>
)}
</div>
<DeleteEstimateModal
isOpen={isDeleteEstimateModalOpen}
handleClose={() => setIsDeleteEstimateModalOpen(false)}
data={estimate}
handleDelete={() => {
handleEstimateDelete(estimate.id);
setIsDeleteEstimateModalOpen(false);
}}
/>
</>
);
};

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const ArrowRightIcon: React.FC<Props> = ({
width = "24",
height = "24",
color = "#858E96",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.9583 0.500103L15.0625 4.58344C15.1319 4.65288 15.1806 4.72232 15.2083 4.79177C15.2361 4.86121 15.25 4.9376 15.25 5.02094C15.25 5.10427 15.2361 5.18066 15.2083 5.2501C15.1806 5.31955 15.1319 5.38899 15.0625 5.45844L10.9583 9.5626C10.8472 9.67371 10.7014 9.73274 10.5208 9.73969C10.3403 9.74663 10.1875 9.6876 10.0625 9.5626C9.9375 9.4376 9.875 9.2883 9.875 9.11469C9.875 8.94108 9.9375 8.79177 10.0625 8.66677L13.0833 5.64594L1.125 5.64594C0.944443 5.64594 0.795138 5.58691 0.677083 5.46885C0.559026 5.3508 0.5 5.20149 0.5 5.02094C0.5 4.84038 0.559026 4.69108 0.677083 4.57302C0.795138 4.45496 0.944443 4.39594 1.125 4.39594L13.0833 4.39594L10.0625 1.3751C9.95139 1.26399 9.89236 1.12163 9.88542 0.94802C9.87847 0.774409 9.9375 0.625103 10.0625 0.500103C10.1875 0.375103 10.3368 0.312602 10.5104 0.312602C10.684 0.312602 10.8333 0.375103 10.9583 0.500103Z"
fill={color}
/>
</svg>
);

View File

@ -0,0 +1,21 @@
import React from "react";
import type { Props } from "./types";
export const AssignmentClipboardIcon: React.FC<Props> = ({
width = "24",
height = "24",
color = "#858E96",
className
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path fill={color} d="M2.125 19.25C1.74306 19.25 1.4184 19.1163 1.15104 18.8489C0.883681 18.5816 0.75 18.2569 0.75 17.875V4.12499C0.75 3.74305 0.883681 3.41839 1.15104 3.15103C1.4184 2.88367 1.74306 2.74999 2.125 2.74999H6.82292C6.89931 2.21527 7.14375 1.77603 7.55625 1.43228C7.96875 1.08853 8.45 0.916656 9 0.916656C9.55 0.916656 10.0312 1.08853 10.4438 1.43228C10.8562 1.77603 11.1007 2.21527 11.1771 2.74999H15.875C16.2569 2.74999 16.5816 2.88367 16.849 3.15103C17.1163 3.41839 17.25 3.74305 17.25 4.12499V17.875C17.25 18.2569 17.1163 18.5816 16.849 18.8489C16.5816 19.1163 16.2569 19.25 15.875 19.25H2.125ZM2.125 17.875H15.875V4.12499H2.125V17.875ZM4.41667 15.5833H10.6729V14.2083H4.41667V15.5833ZM4.41667 11.6875H13.5833V10.3125H4.41667V11.6875ZM4.41667 7.79166H13.5833V6.41666H4.41667V7.79166ZM9 3.73541C9.21389 3.73541 9.40104 3.6552 9.56146 3.49478C9.72188 3.33436 9.80208 3.14721 9.80208 2.93332C9.80208 2.71943 9.72188 2.53228 9.56146 2.37186C9.40104 2.21145 9.21389 2.13124 9 2.13124C8.78611 2.13124 8.59896 2.21145 8.43854 2.37186C8.27812 2.53228 8.19792 2.71943 8.19792 2.93332C8.19792 3.14721 8.27812 3.33436 8.43854 3.49478C8.59896 3.6552 8.78611 3.73541 9 3.73541ZM2.125 17.875V4.12499V17.875Z" />
</svg>
);

View File

@ -0,0 +1,59 @@
import {
AudioIcon,
CssIcon,
CsvIcon,
DefaultIcon,
DocIcon,
FigmaIcon,
HtmlIcon,
JavaScriptIcon,
JpgIcon,
PdfIcon,
PngIcon,
SheetIcon,
SvgIcon,
TxtIcon,
VideoIcon,
} from "components/icons";
export const getFileIcon = (fileType: string) => {
switch (fileType) {
case "pdf":
return <PdfIcon height={28} width={28} />;
case "csv":
return <CsvIcon height={28} width={28} />;
case "xlsx":
return <SheetIcon height={28} width={28} />;
case "css":
return <CssIcon height={28} width={28} />;
case "doc":
return <DocIcon height={28} width={28} />;
case "fig":
return <FigmaIcon height={28} width={28} />;
case "html":
return <HtmlIcon height={28} width={28} />;
case "png":
return <PngIcon height={28} width={28} />;
case "jpg":
return <JpgIcon height={28} width={28} />;
case "js":
return <JavaScriptIcon height={28} width={28} />;
case "txt":
return <TxtIcon height={28} width={28} />;
case "svg":
return <SvgIcon height={28} width={28} />;
case "mp3":
return <AudioIcon height={28} width={28} />;
case "wav":
return <AudioIcon height={28} width={28} />;
case "mp4":
return <VideoIcon height={28} width={28} />;
case "wmv":
return <VideoIcon height={28} width={28} />;
case "mkv":
return <VideoIcon height={28} width={28} />;
default:
return <DefaultIcon height={28} width={28} />;
}
};

View File

@ -0,0 +1,9 @@
import React from "react";
import Image from "next/image";
import type { Props } from "./types";
import AudioFileIcon from "public/attachment/audio-icon.png";
export const AudioIcon: React.FC<Props> = ({ width, height }) => (
<Image src={AudioFileIcon} height={height} width={width} alt="AudioFileIcon" />
);

View File

@ -0,0 +1,21 @@
import React from "react";
import type { Props } from "./types";
export const BacklogStateIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
color = "#858e96",
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="10" cy="10" r="9" stroke={color} strokeLinecap="round" strokeDasharray="4 4" />
</svg>
);

View File

@ -0,0 +1,25 @@
import React from "react";
import type { Props } from "./types";
export const BlockedIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 23 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.5 4.8C0.5 3.52696 1.00797 2.30606 1.91216 1.40589C2.81636 0.505713 4.04271 0 5.32143 0H21.3929C21.6913 0 21.9839 0.0827435 22.2378 0.238959C22.4917 0.395174 22.6968 0.618689 22.8303 0.884458C22.9638 1.15023 23.0203 1.44775 22.9935 1.74369C22.9667 2.03963 22.8576 2.32229 22.6786 2.56L18.5804 8L22.6786 13.44C22.8576 13.6777 22.9667 13.9604 22.9935 14.2563C23.0203 14.5522 22.9638 14.8498 22.8303 15.1155C22.6968 15.3813 22.4917 15.6048 22.2378 15.761C21.9839 15.9173 21.6913 16 21.3929 16H5.32143C4.89519 16 4.4864 16.1686 4.18501 16.4686C3.88361 16.7687 3.71429 17.1757 3.71429 17.6V22.4C3.71429 22.8243 3.54496 23.2313 3.24356 23.5314C2.94217 23.8314 2.53338 24 2.10714 24C1.6809 24 1.27212 23.8314 0.970721 23.5314C0.669323 23.2313 0.5 22.8243 0.5 22.4V4.8Z"
fill="#F76659"
/>
<path
d="M8.5918 20.4812H21.084C21.26 20.4812 21.4056 20.4237 21.5207 20.3086C21.6358 20.1935 21.6934 20.0479 21.6934 19.8719C21.6934 19.6958 21.6358 19.5503 21.5207 19.4352C21.4056 19.3201 21.26 19.2625 21.084 19.2625H8.57148L10.3184 17.5156C10.4267 17.4073 10.4809 17.2719 10.4809 17.1094C10.4809 16.9469 10.4199 16.8047 10.298 16.6828C10.1762 16.5609 10.034 16.5 9.87148 16.5C9.70899 16.5 9.5668 16.5609 9.44492 16.6828L6.68242 19.4453C6.61471 19.513 6.56732 19.5807 6.54023 19.6484C6.51315 19.7161 6.49961 19.7906 6.49961 19.8719C6.49961 19.9531 6.51315 20.0276 6.54023 20.0953C6.56732 20.163 6.61471 20.2307 6.68242 20.2984L9.44492 23.0609C9.58034 23.1964 9.72591 23.2607 9.88164 23.2539C10.0374 23.2471 10.1762 23.1828 10.298 23.0609C10.4199 22.9391 10.4809 22.7935 10.4809 22.6242C10.4809 22.4549 10.4267 22.3161 10.3184 22.2078L8.5918 20.4812Z"
fill="#F76659"
/>
</svg>
);

View File

@ -0,0 +1,25 @@
import React from "react";
import type { Props } from "./types";
export const BlockerIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 23 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 4.8C0 3.52696 0.507971 2.30606 1.41216 1.40589C2.31636 0.505713 3.54271 0 4.82143 0H20.8929C21.1913 0 21.4839 0.0827435 21.7378 0.238959C21.9917 0.395174 22.1968 0.618689 22.3303 0.884458C22.4638 1.15023 22.5203 1.44775 22.4935 1.74369C22.4667 2.03963 22.3576 2.32229 22.1786 2.56L18.0804 8L22.1786 13.44C22.3576 13.6777 22.4667 13.9604 22.4935 14.2563C22.5203 14.5522 22.4638 14.8498 22.3303 15.1155C22.1968 15.3813 21.9917 15.6048 21.7378 15.761C21.4839 15.9173 21.1913 16 20.8929 16H4.82143C4.39519 16 3.9864 16.1686 3.68501 16.4686C3.38361 16.7687 3.21429 17.1757 3.21429 17.6V22.4C3.21429 22.8243 3.04496 23.2313 2.74356 23.5314C2.44217 23.8314 2.03338 24 1.60714 24C1.1809 24 0.772119 23.8314 0.470721 23.5314C0.169323 23.2313 0 22.8243 0 22.4V4.8Z"
fill="#F7AE59"
/>
<path
d="M18.5391 20.8797H6.04688C5.87083 20.8797 5.72526 20.8221 5.61016 20.707C5.49505 20.5919 5.4375 20.4464 5.4375 20.2703C5.4375 20.0943 5.49505 19.9487 5.61016 19.8336C5.72526 19.7185 5.87083 19.6609 6.04688 19.6609H18.5594L16.8125 17.9141C16.7042 17.8057 16.65 17.6703 16.65 17.5078C16.65 17.3453 16.7109 17.2031 16.8328 17.0813C16.9547 16.9594 17.0969 16.8984 17.2594 16.8984C17.4219 16.8984 17.5641 16.9594 17.6859 17.0813L20.4484 19.8438C20.5161 19.9115 20.5635 19.9792 20.5906 20.0469C20.6177 20.1146 20.6313 20.1891 20.6313 20.2703C20.6313 20.3516 20.6177 20.426 20.5906 20.4938C20.5635 20.5615 20.5161 20.6292 20.4484 20.6969L17.6859 23.4594C17.5505 23.5948 17.4049 23.6591 17.2492 23.6523C17.0935 23.6456 16.9547 23.5812 16.8328 23.4594C16.7109 23.3375 16.65 23.1919 16.65 23.0227C16.65 22.8534 16.7042 22.7146 16.8125 22.6062L18.5391 20.8797Z"
fill="#F7AE59"
/>
</svg>
);

View File

@ -0,0 +1,16 @@
import React from "react";
import type { Props } from "./types";
export const BoltIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M10.6002 21C10.4169 21 10.2752 20.9417 10.1752 20.825C10.0752 20.7083 10.0419 20.5583 10.0752 20.375L11.0002 13.95H7.3502C7.16686 13.95 7.03353 13.8667 6.9502 13.7C6.86686 13.5333 6.86686 13.375 6.9502 13.225L12.8752 3.325C12.9252 3.24167 13.0085 3.16667 13.1252 3.1C13.2419 3.03333 13.3585 3 13.4752 3C13.6585 3 13.8002 3.05833 13.9002 3.175C14.0002 3.29167 14.0335 3.44167 14.0002 3.625L13.0752 10.025H16.6752C16.8585 10.025 16.996 10.1083 17.0877 10.275C17.1794 10.4417 17.1835 10.6 17.1002 10.75L11.2002 20.675C11.1502 20.7583 11.0669 20.8333 10.9502 20.9C10.8335 20.9667 10.7169 21 10.6002 21V21Z" />
</svg>
);

View File

@ -0,0 +1,19 @@
import React from "react";
import type { Props } from "./types";
export const CalendarMonthIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14C11.7167 14 11.4792 13.9042 11.2875 13.7125C11.0958 13.5208 11 13.2833 11 13C11 12.7167 11.0958 12.4792 11.2875 12.2875C11.4792 12.0958 11.7167 12 12 12C12.2833 12 12.5208 12.0958 12.7125 12.2875C12.9042 12.4792 13 12.7167 13 13C13 13.2833 12.9042 13.5208 12.7125 13.7125C12.5208 13.9042 12.2833 14 12 14ZM8 14C7.71667 14 7.47917 13.9042 7.2875 13.7125C7.09583 13.5208 7 13.2833 7 13C7 12.7167 7.09583 12.4792 7.2875 12.2875C7.47917 12.0958 7.71667 12 8 12C8.28333 12 8.52083 12.0958 8.7125 12.2875C8.90417 12.4792 9 12.7167 9 13C9 13.2833 8.90417 13.5208 8.7125 13.7125C8.52083 13.9042 8.28333 14 8 14ZM16 14C15.7167 14 15.4792 13.9042 15.2875 13.7125C15.0958 13.5208 15 13.2833 15 13C15 12.7167 15.0958 12.4792 15.2875 12.2875C15.4792 12.0958 15.7167 12 16 12C16.2833 12 16.5208 12.0958 16.7125 12.2875C16.9042 12.4792 17 12.7167 17 13C17 13.2833 16.9042 13.5208 16.7125 13.7125C16.5208 13.9042 16.2833 14 16 14ZM12 18C11.7167 18 11.4792 17.9042 11.2875 17.7125C11.0958 17.5208 11 17.2833 11 17C11 16.7167 11.0958 16.4792 11.2875 16.2875C11.4792 16.0958 11.7167 16 12 16C12.2833 16 12.5208 16.0958 12.7125 16.2875C12.9042 16.4792 13 16.7167 13 17C13 17.2833 12.9042 17.5208 12.7125 17.7125C12.5208 17.9042 12.2833 18 12 18ZM8 18C7.71667 18 7.47917 17.9042 7.2875 17.7125C7.09583 17.5208 7 17.2833 7 17C7 16.7167 7.09583 16.4792 7.2875 16.2875C7.47917 16.0958 7.71667 16 8 16C8.28333 16 8.52083 16.0958 8.7125 16.2875C8.90417 16.4792 9 16.7167 9 17C9 17.2833 8.90417 17.5208 8.7125 17.7125C8.52083 17.9042 8.28333 18 8 18ZM16 18C15.7167 18 15.4792 17.9042 15.2875 17.7125C15.0958 17.5208 15 17.2833 15 17C15 16.7167 15.0958 16.4792 15.2875 16.2875C15.4792 16.0958 15.7167 16 16 16C16.2833 16 16.5208 16.0958 16.7125 16.2875C16.9042 16.4792 17 16.7167 17 17C17 17.2833 16.9042 17.5208 16.7125 17.7125C16.5208 17.9042 16.2833 18 16 18ZM4.5 22C4.1 22 3.75 21.85 3.45 21.55C3.15 21.25 3 20.9 3 20.5V5C3 4.6 3.15 4.25 3.45 3.95C3.75 3.65 4.1 3.5 4.5 3.5H6.125V2.8C6.125 2.56667 6.2 2.375 6.35 2.225C6.5 2.075 6.69167 2 6.925 2C7.15833 2 7.35417 2.075 7.5125 2.225C7.67083 2.375 7.75 2.56667 7.75 2.8V3.5H16.25V2.8C16.25 2.56667 16.325 2.375 16.475 2.225C16.625 2.075 16.8167 2 17.05 2C17.2833 2 17.4792 2.075 17.6375 2.225C17.7958 2.375 17.875 2.56667 17.875 2.8V3.5H19.5C19.9 3.5 20.25 3.65 20.55 3.95C20.85 4.25 21 4.6 21 5V20.5C21 20.9 20.85 21.25 20.55 21.55C20.25 21.85 19.9 22 19.5 22H4.5ZM4.5 20.5H19.5V9.75H4.5V20.5ZM4.5 8.25H19.5V5H4.5V8.25ZM4.5 8.25V5V8.25Z"
fill="#212529"
/>
</svg>
);

View File

@ -0,0 +1,16 @@
import React from "react";
import type { Props } from "./types";
export const CancelIcon: React.FC<Props> = ({ width, height, className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.725 16.275C7.875 16.425 8.05 16.5 8.25 16.5C8.45 16.5 8.625 16.425 8.775 16.275L12 13.05L15.25 16.3C15.3833 16.4333 15.5542 16.4958 15.7625 16.4875C15.9708 16.4792 16.1417 16.4083 16.275 16.275C16.425 16.125 16.5 15.95 16.5 15.75C16.5 15.55 16.425 15.375 16.275 15.225L13.05 12L16.3 8.75C16.4333 8.61667 16.4958 8.44583 16.4875 8.2375C16.4792 8.02917 16.4083 7.85833 16.275 7.725C16.125 7.575 15.95 7.5 15.75 7.5C15.55 7.5 15.375 7.575 15.225 7.725L12 10.95L8.75 7.7C8.61667 7.56667 8.44583 7.50417 8.2375 7.5125C8.02917 7.52083 7.85833 7.59167 7.725 7.725C7.575 7.875 7.5 8.05 7.5 8.25C7.5 8.45 7.575 8.625 7.725 8.775L10.95 12L7.7 15.25C7.56667 15.3833 7.50417 15.5542 7.5125 15.7625C7.52083 15.9708 7.59167 16.1417 7.725 16.275ZM12 22C10.5833 22 9.26667 21.7458 8.05 21.2375C6.83333 20.7292 5.775 20.025 4.875 19.125C3.975 18.225 3.27083 17.1667 2.7625 15.95C2.25417 14.7333 2 13.4167 2 12C2 10.6 2.25417 9.29167 2.7625 8.075C3.27083 6.85833 3.975 5.8 4.875 4.9C5.775 4 6.83333 3.29167 8.05 2.775C9.26667 2.25833 10.5833 2 12 2C13.4 2 14.7083 2.25833 15.925 2.775C17.1417 3.29167 18.2 4 19.1 4.9C20 5.8 20.7083 6.85833 21.225 8.075C21.7417 9.29167 22 10.6 22 12C22 13.4167 21.7417 14.7333 21.225 15.95C20.7083 17.1667 20 18.225 19.1 19.125C18.2 20.025 17.1417 20.7292 15.925 21.2375C14.7083 21.7458 13.4 22 12 22ZM12 20.5C14.3333 20.5 16.3333 19.6667 18 18C19.6667 16.3333 20.5 14.3333 20.5 12C20.5 9.66667 19.6667 7.66667 18 6C16.3333 4.33333 14.3333 3.5 12 3.5C9.66667 3.5 7.66667 4.33333 6 6C4.33333 7.66667 3.5 9.66667 3.5 12C3.5 14.3333 4.33333 16.3333 6 18C7.66667 19.6667 9.66667 20.5 12 20.5Z" />
</svg>
);

View File

@ -0,0 +1,78 @@
import React from "react";
import type { Props } from "./types";
export const CancelledStateIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
color = "#f2655a",
}) => (
<svg
width={width}
height={height}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 84.36 84.36"
>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
/>
<circle className="cls-2" fill={color} cx="42.18" cy="42.18" r="31.04" />
<path
className="cls-3"
fill="none"
strokeWidth={3}
stroke="#ffffff"
strokeLinecap="square"
strokeMiterlimit={10}
d="M32.64,32.44q9.54,9.75,19.09,19.48"
/>
<path
className="cls-3"
fill="none"
strokeWidth={3}
stroke="#ffffff"
strokeLinecap="square"
strokeMiterlimit={10}
d="M32.64,51.92,51.73,32.44"
/>
</g>
</g>
</svg>
);

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const CheckIcon: React.FC<Props> = ({
width = "24",
height = "24",
color = "#858E96",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill={color}
d="M5.45005 11.5504C5.35005 11.5504 5.25838 11.5337 5.17505 11.5004C5.09172 11.4671 5.00838 11.4087 4.92505 11.3254L0.400049 6.80039C0.250049 6.65039 0.175049 6.46706 0.175049 6.25039C0.175049 6.03372 0.250049 5.85039 0.400049 5.70039C0.550049 5.55039 0.725049 5.47539 0.925049 5.47539C1.12505 5.47539 1.30005 5.55039 1.45005 5.70039L5.45005 9.70039L14.525 0.625391C14.675 0.475391 14.8542 0.400391 15.0625 0.400391C15.2709 0.400391 15.45 0.475391 15.6 0.625391C15.75 0.775391 15.825 0.954557 15.825 1.16289C15.825 1.37122 15.75 1.55039 15.6 1.70039L5.97505 11.3254C5.89172 11.4087 5.80838 11.4671 5.72505 11.5004C5.64172 11.5337 5.55005 11.5504 5.45005 11.5504Z"
/>
</svg>
);

View File

@ -0,0 +1,19 @@
import React from "react";
import type { Props } from "./types";
export const ClipboardIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.5 21C4.08333 21 3.72917 20.8542 3.4375 20.5625C3.14583 20.2708 3 19.9167 3 19.5V4.5C3 4.08333 3.14583 3.72917 3.4375 3.4375C3.72917 3.14583 4.08333 3 4.5 3H9.625C9.70833 2.41667 9.975 1.9375 10.425 1.5625C10.875 1.1875 11.4 1 12 1C12.6 1 13.125 1.1875 13.575 1.5625C14.025 1.9375 14.2917 2.41667 14.375 3H19.5C19.9167 3 20.2708 3.14583 20.5625 3.4375C20.8542 3.72917 21 4.08333 21 4.5V19.5C21 19.9167 20.8542 20.2708 20.5625 20.5625C20.2708 20.8542 19.9167 21 19.5 21H4.5ZM4.5 19.5H19.5V4.5H4.5V19.5ZM7 17H13.825V15.5H7V17ZM7 12.75H17V11.25H7V12.75ZM7 8.5H17V7H7V8.5ZM12 4.075C12.2333 4.075 12.4375 3.9875 12.6125 3.8125C12.7875 3.6375 12.875 3.43333 12.875 3.2C12.875 2.96667 12.7875 2.7625 12.6125 2.5875C12.4375 2.4125 12.2333 2.325 12 2.325C11.7667 2.325 11.5625 2.4125 11.3875 2.5875C11.2125 2.7625 11.125 2.96667 11.125 3.2C11.125 3.43333 11.2125 3.6375 11.3875 3.8125C11.5625 3.9875 11.7667 4.075 12 4.075ZM4.5 19.5V4.5V19.5Z"
fill="black"
/>
</svg>
);

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const CloudUploadIcon: React.FC<Props> = ({
width = "24",
height = "24",
color = "#858E96",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 22 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill={color}
d="M5.25 16.0004C3.81667 16.0004 2.58333 15.4837 1.55 14.4504C0.516667 13.4171 0 12.1837 0 10.7504C0 9.45039 0.4125 8.30456 1.2375 7.31289C2.0625 6.32122 3.125 5.72539 4.425 5.52539C4.75833 3.90872 5.54167 2.58789 6.775 1.56289C8.00833 0.537891 9.43333 0.0253906 11.05 0.0253906C12.9333 0.0253906 14.5125 0.704557 15.7875 2.06289C17.0625 3.42122 17.7 5.05039 17.7 6.95039V7.55039C18.9 7.51706 19.9167 7.90456 20.75 8.71289C21.5833 9.52122 22 10.5421 22 11.7754C22 12.9254 21.5833 13.9171 20.75 14.7504C19.9167 15.5837 18.925 16.0004 17.775 16.0004H11.75C11.35 16.0004 11 15.8504 10.7 15.5504C10.4 15.2504 10.25 14.9004 10.25 14.5004V8.05039L8.7 9.60039C8.55 9.75039 8.375 9.82122 8.175 9.81289C7.975 9.80456 7.8 9.72539 7.65 9.57539C7.5 9.42539 7.425 9.24622 7.425 9.03789C7.425 8.82956 7.5 8.65039 7.65 8.50039L10.475 5.67539C10.5583 5.59206 10.6417 5.53372 10.725 5.50039C10.8083 5.46706 10.9 5.45039 11 5.45039C11.1 5.45039 11.1917 5.46706 11.275 5.50039C11.3583 5.53372 11.4417 5.59206 11.525 5.67539L14.375 8.52539C14.525 8.67539 14.6 8.85039 14.6 9.05039C14.6 9.25039 14.525 9.42539 14.375 9.57539C14.225 9.72539 14.0458 9.80039 13.8375 9.80039C13.6292 9.80039 13.45 9.72539 13.3 9.57539L11.75 8.05039V14.5004H17.775C18.525 14.5004 19.1667 14.2337 19.7 13.7004C20.2333 13.1671 20.5 12.5254 20.5 11.7754C20.5 11.0254 20.2333 10.3837 19.7 9.85039C19.1667 9.31706 18.525 9.05039 17.775 9.05039H16.2V6.95039C16.2 5.46706 15.6958 4.19206 14.6875 3.12539C13.6792 2.05872 12.4333 1.52539 10.95 1.52539C9.46667 1.52539 8.21667 2.05872 7.2 3.12539C6.18333 4.19206 5.675 5.46706 5.675 6.95039H5.2C4.16667 6.95039 3.29167 7.31289 2.575 8.03789C1.85833 8.76289 1.5 9.65872 1.5 10.7254C1.5 11.7587 1.86667 12.6462 2.6 13.3879C3.33333 14.1296 4.21667 14.5004 5.25 14.5004H8C8.21667 14.5004 8.39583 14.5712 8.5375 14.7129C8.67917 14.8546 8.75 15.0337 8.75 15.2504C8.75 15.4671 8.67917 15.6462 8.5375 15.7879C8.39583 15.9296 8.21667 16.0004 8 16.0004H5.25Z"
/>
</svg>
);

View File

@ -0,0 +1,11 @@
import React from "react";
import Image from "next/image";
import type { Props } from "./types";
import CMDIcon from "public/mac-command.svg";
export const MacCommandIcon: React.FC<Props> = ({ width = "14", height = "14" }) => (
<Image src={CMDIcon} height={height} width={width} alt="CMDIcon" />
);
export default MacCommandIcon;

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const CogIcon: React.FC<Props> = ({
width = "24",
height = "24",
color = "#858E96",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill={color}
d="M11.65 20H8.34998C8.16664 20 8.00414 19.9417 7.86248 19.825C7.72081 19.7083 7.63331 19.5583 7.59998 19.375L7.19998 16.85C6.88331 16.7333 6.54998 16.575 6.19998 16.375C5.84998 16.175 5.54164 15.9667 5.27498 15.75L2.94998 16.825C2.76664 16.9083 2.58331 16.9208 2.39998 16.8625C2.21664 16.8042 2.07498 16.6833 1.97498 16.5L0.324976 13.575C0.224976 13.4083 0.199976 13.2333 0.249976 13.05C0.299976 12.8667 0.399976 12.7167 0.549976 12.6L2.69998 11.025C2.66664 10.875 2.64581 10.7042 2.63748 10.5125C2.62914 10.3208 2.62498 10.15 2.62498 10C2.62498 9.85 2.62914 9.67917 2.63748 9.4875C2.64581 9.29583 2.66664 9.125 2.69998 8.975L0.549976 7.4C0.399976 7.28333 0.299976 7.13333 0.249976 6.95C0.199976 6.76667 0.224976 6.59167 0.324976 6.425L1.97498 3.5C2.07498 3.31667 2.21664 3.19583 2.39998 3.1375C2.58331 3.07917 2.76664 3.09167 2.94998 3.175L5.27498 4.25C5.54164 4.03333 5.84998 3.825 6.19998 3.625C6.54998 3.425 6.88331 3.275 7.19998 3.175L7.59998 0.625C7.63331 0.441667 7.72081 0.291667 7.86248 0.175C8.00414 0.0583333 8.16664 0 8.34998 0H11.65C11.8333 0 11.9958 0.0583333 12.1375 0.175C12.2791 0.291667 12.3666 0.441667 12.4 0.625L12.8 3.15C13.1166 3.26667 13.4541 3.42083 13.8125 3.6125C14.1708 3.80417 14.475 4.01667 14.725 4.25L17.05 3.175C17.2333 3.09167 17.4166 3.07917 17.6 3.1375C17.7833 3.19583 17.925 3.31667 18.025 3.5L19.675 6.4C19.775 6.56667 19.8041 6.74583 19.7625 6.9375C19.7208 7.12917 19.6166 7.28333 19.45 7.4L17.3 8.925C17.3333 9.09167 17.3541 9.27083 17.3625 9.4625C17.3708 9.65417 17.375 9.83333 17.375 10C17.375 10.1667 17.3708 10.3417 17.3625 10.525C17.3541 10.7083 17.3333 10.8833 17.3 11.05L19.45 12.6C19.6 12.7167 19.7 12.8667 19.75 13.05C19.8 13.2333 19.775 13.4083 19.675 13.575L18.025 16.5C17.925 16.6833 17.7833 16.8042 17.6 16.8625C17.4166 16.9208 17.2333 16.9083 17.05 16.825L14.725 15.75C14.4583 15.9667 14.1541 16.1792 13.8125 16.3875C13.4708 16.5958 13.1333 16.75 12.8 16.85L12.4 19.375C12.3666 19.5583 12.2791 19.7083 12.1375 19.825C11.9958 19.9417 11.8333 20 11.65 20ZM9.99998 13.25C10.9 13.25 11.6666 12.9333 12.3 12.3C12.9333 11.6667 13.25 10.9 13.25 10C13.25 9.1 12.9333 8.33333 12.3 7.7C11.6666 7.06667 10.9 6.75 9.99998 6.75C9.09998 6.75 8.33331 7.06667 7.69998 7.7C7.06664 8.33333 6.74998 9.1 6.74998 10C6.74998 10.9 7.06664 11.6667 7.69998 12.3C8.33331 12.9333 9.09998 13.25 9.99998 13.25ZM9.99998 11.75C9.51664 11.75 9.10414 11.5792 8.76248 11.2375C8.42081 10.8958 8.24998 10.4833 8.24998 10C8.24998 9.51667 8.42081 9.10417 8.76248 8.7625C9.10414 8.42083 9.51664 8.25 9.99998 8.25C10.4833 8.25 10.8958 8.42083 11.2375 8.7625C11.5791 9.10417 11.75 9.51667 11.75 10C11.75 10.4833 11.5791 10.8958 11.2375 11.2375C10.8958 11.5792 10.4833 11.75 9.99998 11.75ZM8.89997 18.5H11.1L11.45 15.7C12 15.5667 12.5208 15.3583 13.0125 15.075C13.5041 14.7917 13.95 14.45 14.35 14.05L17 15.2L18 13.4L15.65 11.675C15.7166 11.3917 15.7708 11.1125 15.8125 10.8375C15.8541 10.5625 15.875 10.2833 15.875 10C15.875 9.71667 15.8583 9.4375 15.825 9.1625C15.7916 8.8875 15.7333 8.60833 15.65 8.325L18 6.6L17 4.8L14.35 5.95C13.9666 5.51667 13.5333 5.15417 13.05 4.8625C12.5666 4.57083 12.0333 4.38333 11.45 4.3L11.1 1.5H8.89997L8.54998 4.3C7.98331 4.41667 7.45414 4.61667 6.96248 4.9C6.47081 5.18333 6.03331 5.53333 5.64998 5.95L2.99998 4.8L1.99998 6.6L4.34998 8.325C4.28331 8.60833 4.22914 8.8875 4.18748 9.1625C4.14581 9.4375 4.12498 9.71667 4.12498 10C4.12498 10.2833 4.14581 10.5625 4.18748 10.8375C4.22914 11.1125 4.28331 11.3917 4.34998 11.675L1.99998 13.4L2.99998 15.2L5.64998 14.05C6.04998 14.45 6.49581 14.7917 6.98748 15.075C7.47914 15.3583 7.99998 15.5667 8.54998 15.7L8.89997 18.5Z"
/>
</svg>
);

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const ColorPalletteIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
color = "#858e96",
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 16.5C7.975 16.5 7.00625 16.3031 6.09375 15.9094C5.18125 15.5156 4.38437 14.9781 3.70312 14.2969C3.02187 13.6156 2.48437 12.8187 2.09062 11.9062C1.69687 10.9937 1.5 10.025 1.5 9C1.5 7.9375 1.7 6.95 2.1 6.0375C2.5 5.125 3.04687 4.33125 3.74062 3.65625C4.43437 2.98125 5.24687 2.45312 6.17812 2.07187C7.10937 1.69062 8.10625 1.5 9.16875 1.5C10.1562 1.5 11.0937 1.66563 11.9812 1.99687C12.8687 2.32812 13.6469 2.7875 14.3156 3.375C14.9844 3.9625 15.5156 4.65937 15.9094 5.46562C16.3031 6.27187 16.5 7.15625 16.5 8.11875C16.5 9.46875 16.1062 10.5344 15.3187 11.3156C14.5312 12.0969 13.4875 12.4875 12.1875 12.4875H10.7812C10.5562 12.4875 10.3625 12.575 10.2 12.75C10.0375 12.925 9.95625 13.1187 9.95625 13.3312C9.95625 13.6687 10.0469 13.9562 10.2281 14.1937C10.4094 14.4312 10.5 14.7062 10.5 15.0187C10.5 15.4937 10.3687 15.8594 10.1062 16.1156C9.84375 16.3719 9.475 16.5 9 16.5ZM4.63125 9.4875C4.88125 9.4875 5.1 9.39375 5.2875 9.20625C5.475 9.01875 5.56875 8.8 5.56875 8.55C5.56875 8.3 5.475 8.08125 5.2875 7.89375C5.1 7.70625 4.88125 7.6125 4.63125 7.6125C4.38125 7.6125 4.1625 7.70625 3.975 7.89375C3.7875 8.08125 3.69375 8.3 3.69375 8.55C3.69375 8.8 3.7875 9.01875 3.975 9.20625C4.1625 9.39375 4.38125 9.4875 4.63125 9.4875ZM6.99375 6.3C7.24375 6.3 7.4625 6.20625 7.65 6.01875C7.8375 5.83125 7.93125 5.6125 7.93125 5.3625C7.93125 5.1125 7.8375 4.89375 7.65 4.70625C7.4625 4.51875 7.24375 4.425 6.99375 4.425C6.74375 4.425 6.525 4.51875 6.3375 4.70625C6.15 4.89375 6.05625 5.1125 6.05625 5.3625C6.05625 5.6125 6.15 5.83125 6.3375 6.01875C6.525 6.20625 6.74375 6.3 6.99375 6.3ZM11.0062 6.3C11.2562 6.3 11.475 6.20625 11.6625 6.01875C11.85 5.83125 11.9437 5.6125 11.9437 5.3625C11.9437 5.1125 11.85 4.89375 11.6625 4.70625C11.475 4.51875 11.2562 4.425 11.0062 4.425C10.7562 4.425 10.5375 4.51875 10.35 4.70625C10.1625 4.89375 10.0687 5.1125 10.0687 5.3625C10.0687 5.6125 10.1625 5.83125 10.35 6.01875C10.5375 6.20625 10.7562 6.3 11.0062 6.3ZM13.4625 9.4875C13.7125 9.4875 13.9312 9.39375 14.1187 9.20625C14.3062 9.01875 14.4 8.8 14.4 8.55C14.4 8.3 14.3062 8.08125 14.1187 7.89375C13.9312 7.70625 13.7125 7.6125 13.4625 7.6125C13.2125 7.6125 12.9937 7.70625 12.8062 7.89375C12.6187 8.08125 12.525 8.3 12.525 8.55C12.525 8.8 12.6187 9.01875 12.8062 9.20625C12.9937 9.39375 13.2125 9.4875 13.4625 9.4875ZM9 15.375C9.1375 15.375 9.23437 15.3469 9.29062 15.2906C9.34687 15.2344 9.375 15.1437 9.375 15.0187C9.375 14.8437 9.28437 14.6812 9.10312 14.5312C8.92187 14.3812 8.83125 14.05 8.83125 13.5375C8.83125 12.9625 9.01875 12.4562 9.39375 12.0187C9.76875 11.5812 10.2437 11.3625 10.8187 11.3625H12.1875C13.1375 11.3625 13.9062 11.0844 14.4937 10.5281C15.0812 9.97187 15.375 9.16875 15.375 8.11875C15.375 6.46875 14.75 5.14062 13.5 4.13437C12.25 3.12812 10.8062 2.625 9.16875 2.625C7.34375 2.625 5.79687 3.24062 4.52812 4.47187C3.25937 5.70312 2.625 7.2125 2.625 9C2.625 10.7625 3.24687 12.2656 4.49062 13.5094C5.73438 14.7531 7.2375 15.375 9 15.375Z"
fill={color}
/>
</svg>
);

View File

@ -0,0 +1,16 @@
import React from "react";
import type { Props } from "./types";
export const CommentIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2 16.1V3.05C2 2.81667 2.10833 2.58333 2.325 2.35C2.54167 2.11667 2.76667 2 3 2H15.975C16.225 2 16.4583 2.1125 16.675 2.3375C16.8917 2.5625 17 2.8 17 3.05V11.95C17 12.1833 16.8917 12.4167 16.675 12.65C16.4583 12.8833 16.225 13 15.975 13H6L2.65 16.35C2.53333 16.4667 2.39583 16.4958 2.2375 16.4375C2.07917 16.3792 2 16.2667 2 16.1ZM3.5 3.5V11.5V3.5ZM7.025 18C6.79167 18 6.5625 17.8833 6.3375 17.65C6.1125 17.4167 6 17.1833 6 16.95V14.5H18.5V6H21C21.2333 6 21.4583 6.11667 21.675 6.35C21.8917 6.58333 22 6.825 22 7.075V21.075C22 21.2417 21.9208 21.3542 21.7625 21.4125C21.6042 21.4708 21.4667 21.4417 21.35 21.325L18.025 18H7.025ZM15.5 3.5H3.5V11.5H15.5V3.5Z" />
</svg>
);

View File

@ -0,0 +1,17 @@
import React from "react";
import type { Props } from "./types";
export const CompletedCycleIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "black",
}) => (
<svg xmlns="http://www.w3.org/2000/svg" height={height} width={width} className={className}>
<path
d="m21.65 36.6-6.9-6.85 2.1-2.1 4.8 4.7 9.2-9.2 2.1 2.15ZM6 44V7h6.25V4h3.25v3h17V4h3.25v3H42v37Zm3-3h30V19.5H9Zm0-24.5h30V10H9Zm0 0V10v6.5Z"
fill={color}
/>
</svg>
);

View File

@ -0,0 +1,69 @@
import React from "react";
import type { Props } from "./types";
export const CompletedStateIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
color = "#438af3",
}) => (
<svg
width={width}
height={height}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 84.36 84.36"
>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
/>
<circle className="cls-2" fill={color} cx="42.18" cy="42.18" r="31.04" />
<path
className="cls-3"
fill="none"
strokeWidth={3}
stroke="#ffffff"
strokeLinecap="square"
strokeMiterlimit={10}
d="M30.45,43.75l6.61,6.61L53.92,34"
/>
</g>
</g>
</svg>
);

View File

@ -0,0 +1,25 @@
import React from "react";
import type { Props } from "./types";
export const ContrastIcon: React.FC<Props> = ({
width = "24",
height = "24",
color = "#858E96",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="9" cy="9" r="5.4375" stroke={color} strokeLinecap="round" />
<path
fill={color}
d="M9 5.81247C9 5.39825 9.33876 5.05526 9.74548 5.13368C10.0057 5.18385 10.2608 5.26029 10.5068 5.36219C10.9845 5.56007 11.4186 5.8501 11.7842 6.21574C12.1499 6.58137 12.4399 7.01543 12.6378 7.49315C12.8357 7.97087 12.9375 8.48289 12.9375 8.99997C12.9375 9.51705 12.8357 10.0291 12.6378 10.5068C12.4399 10.9845 12.1499 11.4186 11.7842 11.7842C11.4186 12.1498 10.9845 12.4399 10.5068 12.6377C10.2608 12.7396 10.0057 12.8161 9.74548 12.8663C9.33876 12.9447 9 12.6017 9 12.1875L9 5.81247Z"
/>
</svg>
);

View File

@ -0,0 +1,9 @@
import React from "react";
import Image from "next/image";
import type { Props } from "./types";
import CssFileIcon from "public/attachment/css-icon.png";
export const CssIcon: React.FC<Props> = ({ width, height }) => (
<Image src={CssFileIcon} height={height} width={width} alt="CssFileIcon" />
);

View File

@ -0,0 +1,9 @@
import React from "react";
import Image from "next/image";
import type { Props } from "./types";
import CSVFileIcon from "public/attachment/csv-icon.png";
export const CsvIcon: React.FC<Props> = ({ width , height }) => (
<Image src={CSVFileIcon} height={height} width={width} alt="CSVFileIcon" />
);

Some files were not shown because too many files have changed in this diff Show More