New Directory Setup (#2065)
* chore: moved app & space from apps to root * chore: modified workspace configuration * chore: modified dockerfiles for space and web * chore: modified icons for space * feat: updated files for new svg icons supported by next-images * chore: added /spaces base path for next * chore: added compose config for space * chore: updated husky configuration * chore: updated workflows for new configuration * chore: changed app name to web * fix: resolved build errors with web * chore: reset file tracing root for both projects * chore: added nginx config for deploy * fix: eslint and tsconfig settings for space app * husky setup fixes based on new dir * eslint fixes * prettier formatting --------- Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
This commit is contained in:
parent
20e36194b4
commit
1e152c666c
1022 changed files with 1475 additions and 1240 deletions
140
web/components/workspace/activity-graph.tsx
Normal file
140
web/components/workspace/activity-graph.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// helpers
|
||||
import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IUserActivity } from "types";
|
||||
// constants
|
||||
import { DAYS, MONTHS } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
activities: IUserActivity[] | undefined;
|
||||
};
|
||||
|
||||
export const ActivityGraph: React.FC<Props> = ({ activities }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
const today = new Date();
|
||||
const lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
const twoMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 2, 1);
|
||||
const threeMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 3, 1);
|
||||
const fourMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 4, 1);
|
||||
const fiveMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 5, 1);
|
||||
|
||||
const recentMonths = [
|
||||
fiveMonthsAgo,
|
||||
fourMonthsAgo,
|
||||
threeMonthsAgo,
|
||||
twoMonthsAgo,
|
||||
lastMonth,
|
||||
today,
|
||||
];
|
||||
|
||||
const getDatesOfMonth = (dateOfMonth: Date) => {
|
||||
const month = dateOfMonth.getMonth();
|
||||
const year = dateOfMonth.getFullYear();
|
||||
|
||||
const dates = [];
|
||||
const date = new Date(year, month, 1);
|
||||
|
||||
while (date.getMonth() === month && date < new Date()) {
|
||||
dates.push(renderDateFormat(new Date(date)));
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
};
|
||||
|
||||
const recentDates = [
|
||||
...getDatesOfMonth(recentMonths[0]),
|
||||
...getDatesOfMonth(recentMonths[1]),
|
||||
...getDatesOfMonth(recentMonths[2]),
|
||||
...getDatesOfMonth(recentMonths[3]),
|
||||
...getDatesOfMonth(recentMonths[4]),
|
||||
...getDatesOfMonth(recentMonths[5]),
|
||||
];
|
||||
|
||||
const activitiesIntensity = (activityCount: number) => {
|
||||
if (activityCount <= 3) return "opacity-20";
|
||||
else if (activityCount > 3 && activityCount <= 6) return "opacity-40";
|
||||
else if (activityCount > 6 && activityCount <= 9) return "opacity-80";
|
||||
else return "";
|
||||
};
|
||||
|
||||
const addPaddingTiles = () => {
|
||||
const firstDateDay = new Date(recentDates[0]).getDay();
|
||||
|
||||
for (let i = 0; i < firstDateDay; i++) recentDates.unshift("");
|
||||
};
|
||||
addPaddingTiles();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
setWidth(ref.current.offsetWidth);
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div className="grid place-items-center overflow-x-scroll">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex flex-col gap-2 pt-6">
|
||||
{DAYS.map((day, index) => (
|
||||
<h6 key={day} className="h-4 text-xs">
|
||||
{index % 2 === 0 && day.substring(0, 3)}
|
||||
</h6>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between" style={{ width: `${width}px` }}>
|
||||
{recentMonths.map((month, index) => (
|
||||
<h6 key={index} className="w-full text-xs">
|
||||
{MONTHS[month.getMonth()].substring(0, 3)}
|
||||
</h6>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 grid w-full grid-flow-col gap-2"
|
||||
style={{ gridTemplateRows: "repeat(7, minmax(0, 1fr))" }}
|
||||
ref={ref}
|
||||
>
|
||||
{recentDates.map((date, index) => {
|
||||
const isActive = activities?.find((a) => a.created_date === date);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${date}-${index}`}
|
||||
tooltipContent={`${
|
||||
isActive ? isActive.activity_count : 0
|
||||
} activities on ${renderShortDateWithYearFormat(date)}`}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
date === "" ? "pointer-events-none opacity-0" : ""
|
||||
} h-4 w-4 rounded ${
|
||||
isActive
|
||||
? `bg-custom-primary ${activitiesIntensity(isActive.activity_count)}`
|
||||
: "bg-custom-background-80"
|
||||
}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center gap-2 text-xs">
|
||||
<span>Less</span>
|
||||
<span className="h-4 w-4 rounded bg-custom-background-80" />
|
||||
<span className="h-4 w-4 rounded bg-custom-primary opacity-20" />
|
||||
<span className="h-4 w-4 rounded bg-custom-primary opacity-40" />
|
||||
<span className="h-4 w-4 rounded bg-custom-primary opacity-80" />
|
||||
<span className="h-4 w-4 rounded bg-custom-primary" />
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
web/components/workspace/completed-issues-graph.tsx
Normal file
84
web/components/workspace/completed-issues-graph.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// ui
|
||||
import { CustomMenu, LineGraph } from "components/ui";
|
||||
// constants
|
||||
import { MONTHS } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
issues:
|
||||
| {
|
||||
week_in_month: number;
|
||||
completed_count: number;
|
||||
}[]
|
||||
| undefined;
|
||||
month: number;
|
||||
setMonth: React.Dispatch<React.SetStateAction<number>>;
|
||||
};
|
||||
|
||||
export const CompletedIssuesGraph: React.FC<Props> = ({ month, issues, setMonth }) => {
|
||||
const weeks = month === 2 ? 4 : 5;
|
||||
|
||||
const data: any[] = [];
|
||||
|
||||
for (let i = 1; i <= weeks; i++) {
|
||||
data.push({
|
||||
week_in_month: `Week ${i}`,
|
||||
completed_count: issues?.find((item) => item.week_in_month === i)?.completed_count ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-0.5 flex justify-between">
|
||||
<h3 className="font-semibold">Issues closed by you</h3>
|
||||
<CustomMenu label={<span className="text-sm">{MONTHS[month - 1]}</span>} noBorder>
|
||||
{MONTHS.map((month, index) => (
|
||||
<CustomMenu.MenuItem key={month} onClick={() => setMonth(index + 1)}>
|
||||
{month}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-8 pl-4">
|
||||
{data.every((item) => item.completed_count === 0) ? (
|
||||
<div className="flex items-center justify-center h-72">
|
||||
<h4 className="text-[#d687ff]">No issues closed this month</h4>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<LineGraph
|
||||
height="250px"
|
||||
data={[
|
||||
{
|
||||
id: "completed_issues",
|
||||
color: "#d687ff",
|
||||
data: data.map((item) => ({
|
||||
x: item.week_in_month,
|
||||
y: item.completed_count,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
||||
customYAxisTickValues={data.map((item) => item.completed_count)}
|
||||
colors={(datum) => datum.color}
|
||||
enableSlices="x"
|
||||
sliceTooltip={(datum) => (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
{datum.slice.points[0].data.yFormatted}
|
||||
<span className="text-custom-text-200"> issues closed in </span>
|
||||
{datum.slice.points[0].data.xFormatted}
|
||||
</div>
|
||||
)}
|
||||
theme={{
|
||||
background: "rgb(var(--color-background-100))",
|
||||
}}
|
||||
/>
|
||||
<h4 className="mt-4 flex items-center justify-center gap-2 text-[#d687ff]">
|
||||
<span className="h-2 w-2 bg-[#d687ff]" />
|
||||
Completed Issues
|
||||
</h4>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
web/components/workspace/confirm-workspace-member-remove.tsx
Normal file
97
web/components/workspace/confirm-workspace-member-remove.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React, { useState } from "react";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { DangerButton, SecondaryButton } from "components/ui";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleDelete: () => void;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, handleDelete }) => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
handleDelete();
|
||||
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-custom-backdrop 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-custom-background-80 text-left shadow-xl transition-all sm:my-8 sm:w-[40rem]">
|
||||
<div className="bg-custom-background-80 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-custom-text-100"
|
||||
>
|
||||
Remove {data?.display_name}?
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Are you sure you want to remove member-{" "}
|
||||
<span className="font-bold">{data?.display_name}</span>? They will no
|
||||
longer have access to this workspace. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 bg-custom-background-90 p-4 sm:px-6">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
|
||||
{isDeleteLoading ? "Removing..." : "Remove"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmWorkspaceMemberRemove;
|
||||
225
web/components/workspace/create-workspace-form.tsx
Normal file
225
web/components/workspace/create-workspace-form.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomSelect, Input, PrimaryButton } from "components/ui";
|
||||
// types
|
||||
import { ICurrentUserResponse, IWorkspace } from "types";
|
||||
// fetch-keys
|
||||
import { USER_WORKSPACES } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { ORGANIZATION_SIZE } from "constants/workspace";
|
||||
|
||||
type Props = {
|
||||
onSubmit?: (res: IWorkspace) => Promise<void>;
|
||||
defaultValues: {
|
||||
name: string;
|
||||
slug: string;
|
||||
organization_size: string;
|
||||
};
|
||||
setDefaultValues: Dispatch<SetStateAction<any>>;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
secondaryButton?: React.ReactNode;
|
||||
primaryButtonText?: {
|
||||
loading: string;
|
||||
default: string;
|
||||
};
|
||||
};
|
||||
|
||||
const restrictedUrls = [
|
||||
"api",
|
||||
"installations",
|
||||
"404",
|
||||
"create-workspace",
|
||||
"error",
|
||||
"invitations",
|
||||
"magic-sign-in",
|
||||
"onboarding",
|
||||
"profile",
|
||||
"reset-password",
|
||||
"sign-up",
|
||||
"spaces",
|
||||
"workspace-member-invitation",
|
||||
];
|
||||
|
||||
export const CreateWorkspaceForm: React.FC<Props> = ({
|
||||
onSubmit,
|
||||
defaultValues,
|
||||
setDefaultValues,
|
||||
user,
|
||||
secondaryButton,
|
||||
primaryButtonText = {
|
||||
loading: "Creating...",
|
||||
default: "Create Workspace",
|
||||
},
|
||||
}) => {
|
||||
const [slugError, setSlugError] = useState(false);
|
||||
const [invalidSlug, setInvalidSlug] = useState(false);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
|
||||
|
||||
const handleCreateWorkspace = async (formData: IWorkspace) => {
|
||||
await workspaceService
|
||||
.workspaceSlugCheck(formData.slug)
|
||||
.then(async (res) => {
|
||||
if (res.status === true && !restrictedUrls.includes(formData.slug)) {
|
||||
setSlugError(false);
|
||||
|
||||
await workspaceService
|
||||
.createWorkspace(formData, user)
|
||||
.then(async (res) => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Workspace created successfully.",
|
||||
});
|
||||
|
||||
mutate<IWorkspace[]>(
|
||||
USER_WORKSPACES,
|
||||
(prevData) => [res, ...(prevData ?? [])],
|
||||
false
|
||||
);
|
||||
if (onSubmit) await onSubmit(res);
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Workspace could not be created. Please try again.",
|
||||
})
|
||||
);
|
||||
} else setSlugError(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Some error occurred while creating workspace. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// when the component unmounts set the default values to whatever user typed in
|
||||
setDefaultValues(getValues());
|
||||
},
|
||||
[getValues, setDefaultValues]
|
||||
);
|
||||
|
||||
return (
|
||||
<form className="space-y-6 sm:space-y-9" onSubmit={handleSubmit(handleCreateWorkspace)}>
|
||||
<div className="space-y-6 sm:space-y-7">
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="workspaceName">Workspace Name</label>
|
||||
<Input
|
||||
id="workspaceName"
|
||||
name="name"
|
||||
register={register}
|
||||
autoComplete="off"
|
||||
onChange={(e) =>
|
||||
setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"))
|
||||
}
|
||||
validations={{
|
||||
required: "Workspace name is required",
|
||||
validate: (value) =>
|
||||
/^[\w\s-]*$/.test(value) ||
|
||||
`Name can only contain (" "), ( - ), ( _ ) & alphanumeric characters.`,
|
||||
maxLength: {
|
||||
value: 80,
|
||||
message: "Workspace name should not exceed 80 characters",
|
||||
},
|
||||
}}
|
||||
placeholder="Enter workspace name..."
|
||||
error={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="workspaceUrl">Workspace URL</label>
|
||||
<div className="flex w-full items-center rounded-md border border-custom-border-200 px-3">
|
||||
<span className="whitespace-nowrap text-sm text-custom-text-200">
|
||||
{window && window.location.host}/
|
||||
</span>
|
||||
<Input
|
||||
id="workspaceUrl"
|
||||
mode="trueTransparent"
|
||||
autoComplete="off"
|
||||
name="slug"
|
||||
register={register}
|
||||
className="block w-full rounded-md bg-transparent py-2 !px-0 text-sm"
|
||||
validations={{
|
||||
required: "Workspace URL is required",
|
||||
}}
|
||||
onChange={(e) =>
|
||||
/^[a-zA-Z0-9_-]+$/.test(e.target.value)
|
||||
? setInvalidSlug(false)
|
||||
: setInvalidSlug(true)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{slugError && (
|
||||
<span className="-mt-3 text-sm text-red-500">Workspace URL is already taken!</span>
|
||||
)}
|
||||
{invalidSlug && (
|
||||
<span className="text-sm text-red-500">{`URL can only contain ( - ), ( _ ) & alphanumeric characters.`}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<span>What size is your organization?</span>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="organization_size"
|
||||
control={control}
|
||||
rules={{ required: "This field is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={
|
||||
ORGANIZATION_SIZE.find((c) => c === value) ?? (
|
||||
<span className="text-custom-text-200">Select organization size</span>
|
||||
)
|
||||
}
|
||||
input
|
||||
width="w-full"
|
||||
>
|
||||
{ORGANIZATION_SIZE.map((item) => (
|
||||
<CustomSelect.Option key={item} value={item}>
|
||||
{item}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
{errors.organization_size && (
|
||||
<span className="text-sm text-red-500">{errors.organization_size.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{secondaryButton}
|
||||
<PrimaryButton type="submit" size="md" disabled={!isValid} loading={isSubmitting}>
|
||||
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
196
web/components/workspace/delete-workspace-modal.tsx
Normal file
196
web/components/workspace/delete-workspace-modal.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { DangerButton, Input, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IWorkspace } from "types";
|
||||
// fetch-keys
|
||||
import { USER_WORKSPACES } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
data: IWorkspace | null;
|
||||
onClose: () => void;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
workspaceName: "",
|
||||
confirmDelete: "",
|
||||
};
|
||||
|
||||
export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, user }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useForm({ defaultValues });
|
||||
|
||||
const canDelete =
|
||||
watch("workspaceName") === data?.name && watch("confirmDelete") === "delete my workspace";
|
||||
|
||||
const handleClose = () => {
|
||||
const timer = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timer);
|
||||
}, 350);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!data || !canDelete) return;
|
||||
|
||||
await workspaceService
|
||||
.deleteWorkspace(data.slug, user)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
|
||||
router.push("/");
|
||||
|
||||
mutate<IWorkspace[]>(
|
||||
USER_WORKSPACES,
|
||||
(prevData) => prevData?.filter((workspace) => workspace.id !== data.id)
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Workspace deleted successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again later.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
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-custom-backdrop 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 border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)} 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-500/20 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 Workspace</h3>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span>
|
||||
<p className="text-sm leading-7 text-custom-text-200">
|
||||
Are you sure you want to delete workspace{" "}
|
||||
<span className="break-words font-semibold">{data?.name}</span>? All of the
|
||||
data related to the workspace will be permanently removed. This action cannot
|
||||
be undone.
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<div className="text-custom-text-200">
|
||||
<p className="break-words text-sm ">
|
||||
Enter the workspace name{" "}
|
||||
<span className="font-medium text-custom-text-100">{data?.name}</span> to
|
||||
continue:
|
||||
</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="workspaceName"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Workspace name"
|
||||
className="mt-2"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-custom-text-200">
|
||||
<p className="text-sm">
|
||||
To confirm, type{" "}
|
||||
<span className="font-medium text-custom-text-100">delete my workspace</span>{" "}
|
||||
below:
|
||||
</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmDelete"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter 'delete my workspace'"
|
||||
className="mt-2"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<DangerButton type="submit" disabled={!canDelete} loading={isSubmitting}>
|
||||
{isSubmitting ? "Deleting..." : "Delete Workspace"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
163
web/components/workspace/help-section.tsx
Normal file
163
web/components/workspace/help-section.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import React, { useRef, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
// headless ui
|
||||
import { Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useTheme from "hooks/use-theme";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// icons
|
||||
import { Bolt, HelpOutlineOutlined, WestOutlined } from "@mui/icons-material";
|
||||
import { ChatBubbleOvalLeftEllipsisIcon } from "@heroicons/react/24/outline";
|
||||
import { DocumentIcon, DiscordIcon, GithubIcon } from "components/icons";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
const helpOptions = [
|
||||
{
|
||||
name: "Documentation",
|
||||
href: "https://docs.plane.so/",
|
||||
Icon: DocumentIcon,
|
||||
},
|
||||
{
|
||||
name: "Join our Discord",
|
||||
href: "https://discord.com/invite/A92xrEGCge",
|
||||
Icon: DiscordIcon,
|
||||
},
|
||||
{
|
||||
name: "Report a bug",
|
||||
href: "https://github.com/makeplane/plane/issues/new/choose",
|
||||
Icon: GithubIcon,
|
||||
},
|
||||
{
|
||||
name: "Chat with us",
|
||||
href: null,
|
||||
onClick: () => (window as any).$crisp.push(["do", "chat:show"]),
|
||||
Icon: ChatBubbleOvalLeftEllipsisIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export interface WorkspaceHelpSectionProps {
|
||||
setSidebarActive: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setSidebarActive }) => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
|
||||
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 py-2 px-4 ${
|
||||
store?.theme?.sidebarCollapsed ? "flex-col" : ""
|
||||
}`}
|
||||
>
|
||||
{!store?.theme?.sidebarCollapsed && (
|
||||
<div className="w-1/2 text-center cursor-default rounded-md px-2.5 py-1.5 font-medium outline-none text-sm bg-green-500/10 text-green-500">
|
||||
Free Plan
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center gap-1 ${
|
||||
store?.theme?.sidebarCollapsed ? "flex-col justify-center" : "justify-evenly w-1/2"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
|
||||
store?.theme?.sidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "h",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<Bolt fontSize="small" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
|
||||
store?.theme?.sidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
|
||||
>
|
||||
<HelpOutlineOutlined fontSize="small" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none md:hidden"
|
||||
onClick={() => setSidebarActive(false)}
|
||||
>
|
||||
<WestOutlined fontSize="small" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`hidden md:grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
|
||||
store?.theme?.sidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => store.theme.setSidebarCollapsed(!store?.theme?.sidebarCollapsed)}
|
||||
>
|
||||
<WestOutlined
|
||||
fontSize="small"
|
||||
className={`duration-300 ${store?.theme?.sidebarCollapsed ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Transition
|
||||
show={isNeedHelpOpen}
|
||||
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"
|
||||
>
|
||||
<div
|
||||
className={`absolute bottom-2 ${
|
||||
store?.theme?.sidebarCollapsed ? "left-full" : "left-[-75px]"
|
||||
} space-y-2 rounded-sm bg-custom-background-80 p-1 shadow-md`}
|
||||
ref={helpOptionsRef}
|
||||
>
|
||||
{helpOptions.map(({ name, Icon, href, onClick }) => {
|
||||
if (href)
|
||||
return (
|
||||
<Link href={href} key={name}>
|
||||
<a
|
||||
target="_blank"
|
||||
className="flex items-center gap-x-2 whitespace-nowrap rounded-md px-2 py-1 text-xs hover:bg-custom-background-90"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-custom-text-200" />
|
||||
<span className="text-sm">{name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={onClick ? onClick : undefined}
|
||||
className="flex w-full items-center gap-x-2 whitespace-nowrap rounded-md px-2 py-1 text-xs hover:bg-custom-background-90"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-custom-sidebar-text-200" />
|
||||
<span className="text-sm">{name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
12
web/components/workspace/index.ts
Normal file
12
web/components/workspace/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export * from "./activity-graph";
|
||||
export * from "./completed-issues-graph";
|
||||
export * from "./create-workspace-form";
|
||||
export * from "./delete-workspace-modal";
|
||||
export * from "./help-section";
|
||||
export * from "./issues-list";
|
||||
export * from "./issues-pie-chart";
|
||||
export * from "./issues-stats";
|
||||
export * from "./settings-header";
|
||||
export * from "./sidebar-dropdown";
|
||||
export * from "./sidebar-menu";
|
||||
export * from "./sidebar-quick-action";
|
||||
108
web/components/workspace/issues-list.tsx
Normal file
108
web/components/workspace/issues-list.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
// helpers
|
||||
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssueLite } from "types";
|
||||
import { Loader } from "components/ui";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
|
||||
type Props = {
|
||||
issues: IIssueLite[] | undefined;
|
||||
type: "overdue" | "upcoming";
|
||||
};
|
||||
|
||||
export const IssuesList: React.FC<Props> = ({ issues, type }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const getDateDifference = (date: Date) => {
|
||||
const today = new Date();
|
||||
|
||||
let diffTime = 0;
|
||||
|
||||
if (type === "overdue") diffTime = Math.abs(today.valueOf() - date.valueOf());
|
||||
else diffTime = Math.abs(date.valueOf() - today.valueOf());
|
||||
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold capitalize">{type} Issues</h3>
|
||||
{issues ? (
|
||||
<div className="h-[calc(100%-2.25rem)] rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 text-sm">
|
||||
<div
|
||||
className={`mb-2 grid grid-cols-4 gap-2 rounded-lg px-3 py-2 font-medium ${
|
||||
type === "overdue" ? "bg-red-500/20 bg-opacity-20" : "bg-custom-background-80"
|
||||
}`}
|
||||
>
|
||||
<h4 className="capitalize">{type}</h4>
|
||||
<h4 className="col-span-2">Issue</h4>
|
||||
<h4>{type === "overdue" ? "Due" : "Start"} Date</h4>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-scroll">
|
||||
{issues.length > 0 ? (
|
||||
issues.map((issue) => {
|
||||
const date = type === "overdue" ? issue.target_date : issue.start_date;
|
||||
|
||||
const dateDifference = getDateDifference(new Date(date as string));
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
key={issue.id}
|
||||
>
|
||||
<a>
|
||||
<div className="grid grid-cols-4 gap-2 px-3 py-2">
|
||||
<h5
|
||||
className={`flex cursor-default items-center gap-2 ${
|
||||
type === "overdue"
|
||||
? dateDifference > 6
|
||||
? "text-red-500"
|
||||
: "text-yellow-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{type === "overdue" && (
|
||||
<ExclamationTriangleIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{dateDifference} {dateDifference > 1 ? "days" : "day"}
|
||||
</h5>
|
||||
<h5 className="col-span-2">{truncateText(issue.name, 30)}</h5>
|
||||
<h5 className="cursor-default">
|
||||
{renderShortDateWithYearFormat(new Date(date?.toString() ?? ""))}
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="grid h-full place-items-center">
|
||||
<div className="my-5 flex flex-col items-center gap-4">
|
||||
<LayerDiagonalIcon height={60} width={60} />
|
||||
<span className="text-custom-text-200">
|
||||
No issues found. Use{" "}
|
||||
<pre className="inline rounded bg-custom-background-80 px-2 py-1">C</pre>{" "}
|
||||
shortcut to create a new issue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="200" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
web/components/workspace/issues-pie-chart.tsx
Normal file
71
web/components/workspace/issues-pie-chart.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// ui
|
||||
import { PieGraph } from "components/ui";
|
||||
// helpers
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
// types
|
||||
import { IUserStateDistribution } from "types";
|
||||
// constants
|
||||
import { STATE_GROUP_COLORS } from "constants/state";
|
||||
|
||||
type Props = {
|
||||
groupedIssues: IUserStateDistribution[] | undefined;
|
||||
};
|
||||
|
||||
export const IssuesPieChart: React.FC<Props> = ({ groupedIssues }) => (
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold">Issues by States</h3>
|
||||
<div className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4">
|
||||
<div className="sm:col-span-3">
|
||||
<PieGraph
|
||||
data={
|
||||
groupedIssues?.map((cell) => ({
|
||||
id: cell.state_group,
|
||||
label: cell.state_group,
|
||||
value: cell.state_count,
|
||||
color: STATE_GROUP_COLORS[cell.state_group.toLowerCase()],
|
||||
})) ?? []
|
||||
}
|
||||
height="320px"
|
||||
innerRadius={0.6}
|
||||
cornerRadius={5}
|
||||
padAngle={2}
|
||||
enableArcLabels
|
||||
arcLabelsTextColor="#000000"
|
||||
enableArcLinkLabels={false}
|
||||
activeInnerRadiusOffset={5}
|
||||
colors={(datum) => datum.data.color}
|
||||
tooltip={(datum) => (
|
||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 p-2 text-xs">
|
||||
<span className="text-custom-text-200 capitalize">{datum.datum.label} issues:</span>{" "}
|
||||
{datum.datum.value}
|
||||
</div>
|
||||
)}
|
||||
margin={{
|
||||
top: 32,
|
||||
right: 0,
|
||||
bottom: 32,
|
||||
left: 0,
|
||||
}}
|
||||
theme={{
|
||||
background: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex sm:block items-center gap-3 flex-wrap justify-center sm:space-y-2 sm:self-end sm:justify-self-end sm:px-8 sm:pb-8">
|
||||
{groupedIssues?.map((cell) => (
|
||||
<div key={cell.state_group} className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2"
|
||||
style={{ backgroundColor: STATE_GROUP_COLORS[cell.state_group] }}
|
||||
/>
|
||||
<div className="capitalize text-custom-text-200 text-xs whitespace-nowrap">
|
||||
{cell.state_group}- {cell.state_count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
93
web/components/workspace/issues-stats.tsx
Normal file
93
web/components/workspace/issues-stats.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// components
|
||||
import { ActivityGraph } from "components/workspace";
|
||||
// ui
|
||||
import { Loader, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IUserWorkspaceDashboard } from "types";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type Props = {
|
||||
data: IUserWorkspaceDashboard | undefined;
|
||||
};
|
||||
|
||||
export const IssuesStats: React.FC<Props> = ({ data }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
return (
|
||||
<div className="grid grid-cols-1 rounded-[10px] border border-custom-border-200 bg-custom-background-100 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 divide-y divide-custom-border-200 border-b border-custom-border-200 lg:border-r lg:border-b-0">
|
||||
<div className="flex">
|
||||
<div className="basis-1/2 p-4">
|
||||
<h4 className="text-sm">Issues assigned to you</h4>
|
||||
<h5 className="mt-2 text-2xl font-semibold">
|
||||
{data ? (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/${workspaceSlug}/me/my-issues`)}
|
||||
>
|
||||
{data.assigned_issues_count}
|
||||
</div>
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="25px" width="50%" />
|
||||
</Loader>
|
||||
)}
|
||||
</h5>
|
||||
</div>
|
||||
<div className="basis-1/2 border-l border-custom-border-200 p-4">
|
||||
<h4 className="text-sm">Pending issues</h4>
|
||||
<h5 className="mt-2 text-2xl font-semibold">
|
||||
{data ? (
|
||||
data.pending_issues_count
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="25px" width="50%" />
|
||||
</Loader>
|
||||
)}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="basis-1/2 p-4">
|
||||
<h4 className="text-sm">Completed issues</h4>
|
||||
<h5 className="mt-2 text-2xl font-semibold">
|
||||
{data ? (
|
||||
data.completed_issues_count
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="25px" width="50%" />
|
||||
</Loader>
|
||||
)}
|
||||
</h5>
|
||||
</div>
|
||||
<div className="basis-1/2 border-l border-custom-border-200 p-4">
|
||||
<h4 className="text-sm">Issues due by this week</h4>
|
||||
<h5 className="mt-2 text-2xl font-semibold">
|
||||
{data ? (
|
||||
data.issues_due_week_count
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="25px" width="50%" />
|
||||
</Loader>
|
||||
)}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 lg:col-span-2">
|
||||
<h3 className="mb-2 font-semibold capitalize flex items-center gap-2">
|
||||
Activity Graph
|
||||
<Tooltip
|
||||
tooltipContent="Your profile activity graph is a record of actions you've performed on issues across the workspace."
|
||||
className="w-72 border border-custom-border-200"
|
||||
>
|
||||
<InformationCircleIcon className="h-3 w-3" />
|
||||
</Tooltip>
|
||||
</h3>
|
||||
<ActivityGraph activities={data?.issue_activities} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
252
web/components/workspace/send-workspace-invitation-modal.tsx
Normal file
252
web/components/workspace/send-workspace-invitation-modal.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import React, { useEffect } from "react";
|
||||
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// react-hook-form
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
// headless
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { ICurrentUserResponse } from "types";
|
||||
// constants
|
||||
import { ROLE } from "constants/workspace";
|
||||
import { WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
workspace_slug: string;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
type EmailRole = {
|
||||
email: string;
|
||||
role: 5 | 10 | 15 | 20;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
emails: EmailRole[];
|
||||
};
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
emails: [
|
||||
{
|
||||
email: "",
|
||||
role: 15,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, setIsOpen, workspace_slug, user, onSuccess } = props;
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<FormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "emails",
|
||||
});
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: FormValues) => {
|
||||
if (!workspace_slug) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
|
||||
await workspaceService
|
||||
.inviteWorkspace(workspace_slug, payload, user)
|
||||
.then(async (res) => {
|
||||
setIsOpen(false);
|
||||
handleClose();
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Invitations sent successfully.",
|
||||
});
|
||||
onSuccess();
|
||||
})
|
||||
.catch((err) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: `${err.error}`,
|
||||
});
|
||||
console.log(err);
|
||||
})
|
||||
.finally(() => {
|
||||
reset(defaultValues);
|
||||
mutate(WORKSPACE_INVITATIONS);
|
||||
});
|
||||
};
|
||||
|
||||
const appendField = () => {
|
||||
append({ email: "", role: 15 });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) {
|
||||
append([{ email: "", role: 15 }]);
|
||||
}
|
||||
}, [fields, append]);
|
||||
|
||||
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-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-full p-4 text-center">
|
||||
<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 border border-custom-border-100 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl opacity-100 translate-y-0 sm:scale-100">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === "Enter") e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
Invite people to collaborate
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Invite members to work on your workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="group relative grid grid-cols-11 gap-4">
|
||||
<div className="col-span-7">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`emails.${index}.email`}
|
||||
rules={{
|
||||
required: "Email ID is required",
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: "Invalid Email ID",
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<Input
|
||||
{...field}
|
||||
className="text-xs sm:text-sm"
|
||||
placeholder="Enter their email..."
|
||||
/>
|
||||
{errors.emails?.[index]?.email && (
|
||||
<span className="ml-1 text-red-500 text-xs">
|
||||
{errors.emails?.[index]?.email?.message}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`emails.${index}.role`}
|
||||
rules={{ required: true }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span className="text-xs sm:text-sm">{ROLE[value]}</span>}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
input
|
||||
>
|
||||
{Object.entries(ROLE).map(([key, value]) => (
|
||||
<CustomSelect.Option key={key} value={parseInt(key)}>
|
||||
{value}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{fields.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="self-center place-items-center rounded -ml-3"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<XMarkIcon className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 outline-custom-primary bg-transparent text-custom-primary text-sm font-medium py-2 pr-3"
|
||||
onClick={appendField}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add more
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Sending Invitation..." : "Send Invitation"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendWorkspaceInvitationModal;
|
||||
13
web/components/workspace/settings-header.tsx
Normal file
13
web/components/workspace/settings-header.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import SettingsNavbar from "layouts/settings-navbar";
|
||||
|
||||
export const SettingsHeader = () => (
|
||||
<div className="mb-8 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Workspace Settings</h3>
|
||||
<p className="mt-1 text-sm text-custom-text-200">
|
||||
This information will be displayed to every member of the workspace.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsNavbar />
|
||||
</div>
|
||||
);
|
||||
298
web/components/workspace/sidebar-dropdown.tsx
Normal file
298
web/components/workspace/sidebar-dropdown.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { Fragment } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// headless ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useThemeHook from "hooks/use-theme";
|
||||
import useWorkspaces from "hooks/use-workspaces";
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import userService from "services/user.service";
|
||||
import authenticationService from "services/authentication.service";
|
||||
// components
|
||||
import { Avatar, Icon, Loader } from "components/ui";
|
||||
// icons
|
||||
import { CheckIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
// types
|
||||
import { IWorkspace } from "types";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
// Static Data
|
||||
const userLinks = (workspaceSlug: string, userId: string) => [
|
||||
{
|
||||
name: "Workspace Settings",
|
||||
href: `/${workspaceSlug}/settings`,
|
||||
},
|
||||
{
|
||||
name: "Workspace Invites",
|
||||
href: "/invitations",
|
||||
},
|
||||
{
|
||||
name: "My Profile",
|
||||
href: `/${workspaceSlug}/profile/${userId}`,
|
||||
},
|
||||
];
|
||||
|
||||
const profileLinks = (workspaceSlug: string, userId: string) => [
|
||||
{
|
||||
name: "View profile",
|
||||
icon: "account_circle",
|
||||
link: `/${workspaceSlug}/profile/${userId}`,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
icon: "settings",
|
||||
link: `/${workspaceSlug}/me/profile`,
|
||||
},
|
||||
];
|
||||
|
||||
export const WorkspaceSidebarDropdown = () => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { user, mutateUser } = useUser();
|
||||
|
||||
const { collapsed: sidebarCollapse } = useThemeHook();
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { activeWorkspace, workspaces } = useWorkspaces();
|
||||
|
||||
const handleWorkspaceNavigation = (workspace: IWorkspace) => {
|
||||
userService
|
||||
.updateUser({
|
||||
last_workspace_id: workspace?.id,
|
||||
})
|
||||
.then(() => {
|
||||
mutateUser();
|
||||
router.push(`/${workspace.slug}/`);
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Failed to navigate to the workspace. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authenticationService
|
||||
.signOut()
|
||||
.then(() => {
|
||||
mutateUser(undefined);
|
||||
router.push("/");
|
||||
setTheme("system");
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Failed to sign out. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 px-4 pt-4">
|
||||
<Menu as="div" className="relative col-span-4 inline-block w-full text-left">
|
||||
<Menu.Button className="text-custom-sidebar-text-200 flex w-full items-center rounded-sm text-sm font-medium focus:outline-none">
|
||||
<div
|
||||
className={`flex w-full items-center gap-x-2 rounded-sm bg-custom-sidebar-background-80 p-1 ${
|
||||
store?.theme?.sidebarCollapsed ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative grid h-6 w-6 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{activeWorkspace?.logo && activeWorkspace.logo !== "" ? (
|
||||
<img
|
||||
src={activeWorkspace.logo}
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded"
|
||||
alt="Workspace Logo"
|
||||
/>
|
||||
) : (
|
||||
activeWorkspace?.name?.charAt(0) ?? "..."
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!store?.theme?.sidebarCollapsed && (
|
||||
<h4 className="text-custom-text-100">
|
||||
{activeWorkspace?.name ? truncateText(activeWorkspace.name, 14) : "Loading..."}
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
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"
|
||||
>
|
||||
<Menu.Items
|
||||
className="fixed left-4 z-20 mt-1 flex flex-col w-full max-w-[17rem] origin-top-left rounded-md
|
||||
border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 shadow-lg outline-none"
|
||||
>
|
||||
<div className="flex flex-col items-start justify-start gap-3 p-3">
|
||||
<span className="text-sm font-medium text-custom-sidebar-text-200">Workspace</span>
|
||||
{workspaces ? (
|
||||
<div className="flex h-full w-full flex-col items-start justify-start gap-1.5">
|
||||
{workspaces.length > 0 ? (
|
||||
workspaces.map((workspace) => (
|
||||
<Menu.Item key={workspace.id}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleWorkspaceNavigation(workspace)}
|
||||
className="flex w-full items-center justify-between gap-1 p-1 rounded-md text-sm text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-80"
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2.5">
|
||||
<span className="relative flex h-6 w-6 items-center justify-center rounded bg-gray-700 p-2 text-xs uppercase text-white">
|
||||
{workspace?.logo && workspace.logo !== "" ? (
|
||||
<img
|
||||
src={workspace.logo}
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded"
|
||||
alt="Workspace Logo"
|
||||
/>
|
||||
) : (
|
||||
workspace?.name?.charAt(0) ?? "..."
|
||||
)}
|
||||
</span>
|
||||
|
||||
<h5
|
||||
className={`text-sm ${
|
||||
workspaceSlug === workspace.slug ? "" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{truncateText(workspace.name, 18)}
|
||||
</h5>
|
||||
</div>
|
||||
<span className="p-1">
|
||||
<CheckIcon
|
||||
className={`h-3 w-3.5 text-custom-sidebar-text-100 ${
|
||||
workspace.id === activeWorkspace?.id ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))
|
||||
) : (
|
||||
<p>No workspace found!</p>
|
||||
)}
|
||||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
router.push("/create-workspace");
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-2 py-1 text-sm text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create Workspace
|
||||
</Menu.Item>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<Loader className="space-y-2">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</Loader>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start justify-start gap-2 border-t border-custom-sidebar-border-200 px-3 py-2 text-sm">
|
||||
{userLinks(workspaceSlug?.toString() ?? "", user?.id ?? "").map((link, index) => (
|
||||
<Menu.Item
|
||||
key={index}
|
||||
as="div"
|
||||
className="flex w-full items-center justify-start rounded px-2 py-1 text-sm text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||
>
|
||||
<Link href={link.href}>
|
||||
<a className="w-full">{link.name}</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full border-t border-t-custom-sidebar-border-100 px-3 py-2">
|
||||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center justify-start rounded px-2 py-1 text-sm text-red-600 hover:bg-custom-sidebar-background-80"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Sign out
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
|
||||
{!store?.theme?.sidebarCollapsed && (
|
||||
<Menu as="div" className="relative flex-shrink-0">
|
||||
<Menu.Button className="grid place-items-center outline-none">
|
||||
<Avatar user={user} height="28px" width="28px" fontSize="14px" />
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
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"
|
||||
>
|
||||
<Menu.Items
|
||||
className="absolute left-0 z-20 mt-1.5 flex flex-col w-52 origin-top-left rounded-md
|
||||
border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 divide-y divide-custom-sidebar-border-200 shadow-lg text-xs outline-none"
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 pb-2">
|
||||
<span className="px-2 text-custom-sidebar-text-200">{user?.email}</span>
|
||||
{profileLinks(workspaceSlug?.toString() ?? "", user?.id ?? "").map(
|
||||
(link, index) => (
|
||||
<Menu.Item key={index} as="button" type="button">
|
||||
<Link href={link.link}>
|
||||
<a className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
|
||||
<Icon iconName={link.icon} className="!text-lg !leading-5" />
|
||||
{link.name}
|
||||
</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<Icon iconName="logout" className="!text-lg !leading-5" />
|
||||
Sign out
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
89
web/components/workspace/sidebar-menu.tsx
Normal file
89
web/components/workspace/sidebar-menu.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useTheme from "hooks/use-theme";
|
||||
// components
|
||||
import { NotificationPopover } from "components/notifications";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
BarChartRounded,
|
||||
GridViewOutlined,
|
||||
TaskAltOutlined,
|
||||
WorkOutlineOutlined,
|
||||
} from "@mui/icons-material";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
const workspaceLinks = (workspaceSlug: string) => [
|
||||
{
|
||||
Icon: GridViewOutlined,
|
||||
name: "Dashboard",
|
||||
href: `/${workspaceSlug}`,
|
||||
},
|
||||
{
|
||||
Icon: BarChartRounded,
|
||||
name: "Analytics",
|
||||
href: `/${workspaceSlug}/analytics`,
|
||||
},
|
||||
{
|
||||
Icon: WorkOutlineOutlined,
|
||||
name: "Projects",
|
||||
href: `/${workspaceSlug}/projects`,
|
||||
},
|
||||
{
|
||||
Icon: TaskAltOutlined,
|
||||
name: "My Issues",
|
||||
href: `/${workspaceSlug}/me/my-issues`,
|
||||
},
|
||||
];
|
||||
|
||||
export const WorkspaceSidebarMenu = () => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { collapsed: sidebarCollapse } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="w-full cursor-pointer space-y-1 p-4">
|
||||
{workspaceLinks(workspaceSlug as string).map((link, index) => {
|
||||
const isActive =
|
||||
link.name === "Settings"
|
||||
? router.asPath.includes(link.href)
|
||||
: router.asPath === link.href;
|
||||
|
||||
return (
|
||||
<Link key={index} href={link.href}>
|
||||
<a className="block w-full">
|
||||
<Tooltip
|
||||
tooltipContent={link.name}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!store?.theme?.sidebarCollapsed}
|
||||
>
|
||||
<div
|
||||
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
||||
isActive
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||
} ${store?.theme?.sidebarCollapsed ? "justify-center" : ""}`}
|
||||
>
|
||||
{<link.Icon fontSize="small" />}
|
||||
{!store?.theme?.sidebarCollapsed && link.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<NotificationPopover />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
web/components/workspace/sidebar-quick-action.tsx
Normal file
47
web/components/workspace/sidebar-quick-action.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from "react";
|
||||
|
||||
// ui
|
||||
import { Icon } from "components/ui";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
export const WorkspaceSidebarQuickAction = () => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between w-full cursor-pointer px-4 mt-4 ${
|
||||
store?.theme?.sidebarCollapsed ? "flex-col gap-1" : "gap-2"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className={`flex items-center gap-2 flex-grow rounded flex-shrink-0 py-1.5 ${
|
||||
store?.theme?.sidebarCollapsed
|
||||
? "px-2 hover:bg-custom-sidebar-background-80"
|
||||
: "px-3 shadow border-[0.5px] border-custom-border-300"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "c" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<Icon iconName="edit_square" className="!text-lg !leading-4 text-custom-sidebar-text-300" />
|
||||
{!store?.theme?.sidebarCollapsed && <span className="text-sm font-medium">New Issue</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`flex items-center justify-center rounded flex-shrink-0 p-2 ${
|
||||
store?.theme?.sidebarCollapsed
|
||||
? "hover:bg-custom-sidebar-background-80"
|
||||
: "shadow border-[0.5px] border-custom-border-300"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "k", ctrlKey: true, metaKey: true });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<Icon iconName="search" className="!text-lg !leading-4 text-custom-sidebar-text-300" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
69
web/components/workspace/single-invitation.tsx
Normal file
69
web/components/workspace/single-invitation.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// helpers
|
||||
import { getFirstCharacters, truncateText } from "helpers/string.helper";
|
||||
// types
|
||||
import { IWorkspaceMemberInvitation } from "types";
|
||||
|
||||
type Props = {
|
||||
invitation: IWorkspaceMemberInvitation;
|
||||
invitationsRespond: string[];
|
||||
handleInvitation: any;
|
||||
};
|
||||
|
||||
const SingleInvitation: React.FC<Props> = ({
|
||||
invitation,
|
||||
invitationsRespond,
|
||||
handleInvitation,
|
||||
}) => (
|
||||
<li>
|
||||
<label
|
||||
className={`group relative flex cursor-pointer items-start space-x-3 border-2 border-transparent py-4`}
|
||||
htmlFor={invitation.id}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
{invitation.workspace.logo && invitation.workspace.logo !== "" ? (
|
||||
<img
|
||||
src={invitation.workspace.logo}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded"
|
||||
alt={invitation.workspace.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center rounded-xl bg-gray-700 p-4 uppercase text-white">
|
||||
{getFirstCharacters(invitation.workspace.name)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{truncateText(invitation.workspace.name, 30)}</div>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Invited by{" "}
|
||||
{invitation.created_by_detail
|
||||
? invitation.created_by_detail.display_name
|
||||
: invitation.workspace.owner.display_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<button
|
||||
className={`${
|
||||
invitationsRespond.includes(invitation.id)
|
||||
? "bg-custom-background-80 text-custom-text-200"
|
||||
: "bg-custom-primary text-white"
|
||||
} text-sm px-4 py-2 border border-custom-border-200 rounded-3xl`}
|
||||
onClick={(e) => {
|
||||
handleInvitation(
|
||||
invitation,
|
||||
invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted"
|
||||
);
|
||||
}}
|
||||
>
|
||||
{invitationsRespond.includes(invitation.id) ? "Invitation Accepted" : "Accept Invitation"}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
|
||||
export default SingleInvitation;
|
||||
Loading…
Add table
Add a link
Reference in a new issue