fix: merge conflicts resolved

This commit is contained in:
sriram veeraghanta
2024-05-09 18:25:46 +05:30
116 changed files with 6233 additions and 537 deletions

View File

@@ -0,0 +1,38 @@
import { FC } from "react";
// types
import { IActiveCycle } from "@plane/types";
// components
import {
ActiveCyclesProjectTitle,
ActiveCycleHeader,
ActiveCycleProgress,
ActiveCycleProductivity,
ActiveCycleStats,
} from "@/components/active-cycles";
export type ActiveCycleInfoCardProps = {
cycle: IActiveCycle;
workspaceSlug: string;
projectId: string;
};
export const ActiveCycleInfoCard: FC<ActiveCycleInfoCardProps> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
return (
<div
key={cycle.id}
className="flex flex-col gap-4 p-4 rounded-xl border border-custom-border-200 bg-custom-background-100"
>
<ActiveCyclesProjectTitle project={cycle.project_detail} />
<ActiveCycleHeader cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress cycle={cycle} />
<ActiveCycleProductivity cycle={cycle} />
<ActiveCycleStats cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
</div>
);
};

View File

@@ -0,0 +1,284 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import useSWR from "swr";
// icons
import { CalendarCheck } from "lucide-react";
// headless ui
import { Tab } from "@headlessui/react";
// types
import { IActiveCycle } from "@plane/types";
// ui
import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
// components
import { SingleProgressStats } from "@/components/core";
import { StateDropdown } from "@/components/dropdowns";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
import { EIssuesStoreType } from "@/constants/issue";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
// hooks
import { useIssues, useProjectState } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage";
import { EmptyState } from "../empty-state";
export type ActiveCycleStatsProps = {
workspaceSlug: string;
projectId: string;
cycle: IActiveCycle;
};
export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
const { workspaceSlug, projectId, cycle } = props;
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
const currentValue = (tab: string | null) => {
switch (tab) {
case "Priority-Issues":
return 0;
case "Assignees":
return 1;
case "Labels":
return 2;
default:
return 0;
}
};
const {
issues: { fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const { fetchWorkspaceStates } = useProjectState();
const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "urgent,high" }) : null,
workspaceSlug && projectId && cycle.id ? () => fetchActiveCycleIssues(workspaceSlug, projectId, cycle.id) : null
);
useSWR(
workspaceSlug ? `WORKSPACE_STATES_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceStates(workspaceSlug.toString()) : null
);
const cycleIssues = activeCycleIssues ?? [];
return (
<div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg">
<Tab.Group
as={Fragment}
defaultIndex={currentValue(tab)}
onChange={(i) => {
switch (i) {
case 0:
return setTab("Priority-Issues");
case 1:
return setTab("Assignees");
case 2:
return setTab("Labels");
default:
return setTab("Priority-Issues");
}
}}
>
<Tab.List
as="div"
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
style={{
gridTemplateColumns: `repeat(3, 1fr)`,
}}
>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
Priority Issues
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
Assignees
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
Labels
</Tab>
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
<div className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm">
{cycleIssues ? (
cycleIssues.length > 0 ? (
cycleIssues.map((issue: any) => (
<Link
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md hover:bg-custom-background-90 p-1"
>
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
<PriorityIcon priority={issue.priority} withContainer size={12} />
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${cycle.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{cycle.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<StateDropdown
value={issue.state_id ?? undefined}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled
buttonVariant="background-with-text"
buttonContainerClassName="cursor-pointer max-w-24"
showTooltip
/>
{issue.target_date && (
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
<div className="h-full flex truncate items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 group-hover:bg-custom-background-100 cursor-pointer">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="text-xs truncate">
{renderFormattedDateWithoutYear(issue.target_date)}
</span>
</div>
</Tooltip>
)}
</div>
</Link>
))
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState
type={EmptyStateType.ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE}
layout="screen-simple"
size="sm"
/>
</div>
)
) : (
<Loader className="space-y-3">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</div>
</Tab.Panel>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
cycle.distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
return (
<SingleProgressStats
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>No assignee</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
})
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
)}
</Tab.Panel>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
cycle.distribution.labels.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "#000000",
}}
/>
<span className="text-xs">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
/>
))
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_LABEL_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
)}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
);
});

View File

@@ -0,0 +1,77 @@
import { FC } from "react";
import Link from "next/link";
// types
import { ICycle, TCycleGroups } from "@plane/types";
// ui
import { Tooltip, CycleGroupIcon, getButtonStyling, Avatar, AvatarGroup } from "@plane/ui";
// helpers
import { renderFormattedDate, findHowManyDaysLeft } from "@/helpers/date-time.helper";
import { truncateText } from "@/helpers/string.helper";
// hooks
import { useMember } from "@/hooks/store";
export type ActiveCycleHeaderProps = {
cycle: ICycle;
workspaceSlug: string;
projectId: string;
};
export const ActiveCycleHeader: FC<ActiveCycleHeaderProps> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
// store
const { getUserDetails } = useMember();
const cycleOwnerDetails = cycle && cycle.owned_by_id ? getUserDetails(cycle.owned_by_id) : undefined;
const daysLeft = findHowManyDaysLeft(cycle.end_date) ?? 0;
const currentCycleStatus = cycle?.status?.toLocaleLowerCase() as TCycleGroups;
const cycleAssignee = (cycle.distribution?.assignees ?? []).filter((assignee) => assignee.display_name);
return (
<div className="flex items-center justify-between px-3 py-1.5 rounded border-[0.5px] border-custom-border-100 bg-custom-background-90">
<div className="flex items-center gap-2 cursor-default">
<CycleGroupIcon cycleGroup={currentCycleStatus} className="h-4 w-4" />
<Tooltip tooltipContent={cycle.name} position="top-left">
<h3 className="break-words text-lg font-medium">{truncateText(cycle.name, 70)}</h3>
</Tooltip>
<Tooltip
tooltipContent={`Start date: ${renderFormattedDate(cycle.start_date ?? "")} Due Date: ${renderFormattedDate(
cycle.end_date ?? ""
)}`}
position="top-left"
>
<span className="flex gap-1 whitespace-nowrap rounded-sm text-custom-text-400 font-semibold text-sm leading-5">
{`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`}
</span>
</Tooltip>
</div>
<div className="flex items-center gap-4">
<div className="rounded-sm text-sm">
<div className="flex gap-2 divide-x spac divide-x-border-300 text-sm whitespace-nowrap text-custom-text-300 font-medium">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
{cycleAssignee.length > 0 && (
<span className="pl-2">
<AvatarGroup showTooltip>
{cycleAssignee.map((member) => (
<Avatar
key={member.assignee_id}
name={member?.display_name ?? ""}
src={member?.avatar ?? ""}
showTooltip={false}
/>
))}
</AvatarGroup>
</span>
)}
</div>
</div>
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}
className={`${getButtonStyling("outline-primary", "sm")} cursor-pointer`}
>
View Cycle
</Link>
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
export * from "./header";
export * from "./progress";
export * from "./project-title";
export * from "./productivity";
export * from "./cycle-stats";
export * from "./card";
export * from "./list-page";

View File

@@ -0,0 +1,54 @@
import { FC, useEffect } from "react";
import useSWR from "swr";
// ui
import { Spinner } from "@plane/ui";
// components
import { ActiveCycleInfoCard } from "@/components/active-cycles";
// constants
import { WORKSPACE_ACTIVE_CYCLES_LIST } from "@/constants/fetch-keys";
// services
import { CycleService } from "@/services/cycle.service";
const cycleService = new CycleService();
export type ActiveCyclesListPageProps = {
workspaceSlug: string;
cursor: string;
perPage: number;
updateTotalPages: (count: number) => void;
updateResultsCount: (count: number) => void;
};
export const ActiveCyclesListPage: FC<ActiveCyclesListPageProps> = (props) => {
const { workspaceSlug, cursor, perPage, updateTotalPages, updateResultsCount } = props;
// fetching active cycles in workspace
const { data: workspaceActiveCycles } = useSWR(
workspaceSlug && cursor ? WORKSPACE_ACTIVE_CYCLES_LIST(workspaceSlug as string, cursor, `${perPage}`) : null,
workspaceSlug && cursor ? () => cycleService.workspaceActiveCycles(workspaceSlug.toString(), cursor, perPage) : null
);
useEffect(() => {
if (workspaceActiveCycles) {
updateTotalPages(workspaceActiveCycles.total_pages);
updateResultsCount(workspaceActiveCycles.results.length);
}
}, [updateTotalPages, updateResultsCount, workspaceActiveCycles]);
if (!workspaceActiveCycles) {
return (
<div className="flex items-center justify-center h-full w-full">
<Spinner />
</div>
);
}
return (
<>
{workspaceActiveCycles.results.map((cycle: any) => (
<div key={cycle.id} className="px-5 pt-5 last:pb-5">
<ActiveCycleInfoCard workspaceSlug={workspaceSlug?.toString()} projectId={cycle.project_id} cycle={cycle} />
</div>
))}
</>
);
};

View File

@@ -0,0 +1,57 @@
import { FC } from "react";
// types
import { ICycle } from "@plane/types";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { EmptyStateType } from "@/constants/empty-state";
import { EmptyState } from "../empty-state";
export type ActiveCycleProductivityProps = {
cycle: ICycle;
};
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props) => {
const { cycle } = props;
return (
<div className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 border border-custom-border-200 rounded-lg">
<div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
</div>
{cycle.total_issues > 0 ? (
<>
<div className="h-full w-full px-2">
<div className="flex items-center justify-between gap-4 py-1 text-xs text-custom-text-300">
<div className="flex items-center gap-3 text-custom-text-300">
<div className="flex items-center justify-center gap-1">
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
</div>
<div className="relative h-full">
<ProgressChart
className="h-full"
distribution={cycle.distribution?.completion_chart ?? {}}
startDate={cycle.start_date ?? ""}
endDate={cycle.end_date ?? ""}
totalIssues={cycle.total_issues}
/>
</div>
</div>
</>
) : (
<>
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_CHART_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,89 @@
import { FC } from "react";
// types
import { ICycle } from "@plane/types";
// ui
import { LinearProgressIndicator } from "@plane/ui";
// constants
import { WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle";
import { EmptyStateType } from "@/constants/empty-state";
import { EmptyState } from "../empty-state";
export type ActiveCycleProgressProps = {
cycle: ICycle;
};
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
const { cycle } = props;
const progressIndicatorData = WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
color: group.color,
}));
const groupedIssues: any = {
completed: cycle.completed_issues,
started: cycle.started_issues,
unstarted: cycle.unstarted_issues,
backlog: cycle.backlog_issues,
};
return (
<div className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 border border-custom-border-200 rounded-lg">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Progress</h3>
{cycle.total_issues > 0 && (
<span className="flex gap-1 text-sm text-custom-text-400 font-medium whitespace-nowrap rounded-sm px-3 py-1 ">
{`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${
cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue"
} closed`}
</span>
)}
</div>
{cycle.total_issues > 0 && <LinearProgressIndicator size="lg" data={progressIndicatorData} />}
</div>
{cycle.total_issues > 0 ? (
<div className="flex flex-col gap-5">
{Object.keys(groupedIssues).map((group, index) => (
<>
{groupedIssues[group] > 0 && (
<div key={index}>
<div className="flex items-center justify-between gap-2 text-sm">
<div className="flex items-center gap-1.5">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS[index].color,
}}
/>
<span className="text-custom-text-300 capitalize font-medium w-16">{group}</span>
</div>
<span className="text-custom-text-300">{`${groupedIssues[group]} ${
groupedIssues[group] > 1 ? "Issues" : "Issue"
}`}</span>
</div>
</div>
)}
</>
))}
{cycle.cancelled_issues > 0 && (
<span className="flex items-center gap-2 text-sm text-custom-text-300">
<span>
{`${cycle.cancelled_issues} cancelled ${
cycle.cancelled_issues > 1 ? "issues are" : "issue is"
} excluded from this report.`}{" "}
</span>
</span>
)}
</div>
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,19 @@
import { FC } from "react";
// types
import { IProject } from "@plane/types";
// ui
import { ProjectLogo } from "../project";
export type ActiveCyclesProjectTitleProps = {
project: Partial<IProject> | undefined;
};
export const ActiveCyclesProjectTitle: FC<ActiveCyclesProjectTitleProps> = (props) => {
const { project } = props;
return (
<div className="flex items-center gap-1.5">
{project?.logo_props && <ProjectLogo logo={project.logo_props} />}
<h2 className="text-xl font-semibold">{project?.name}</h2>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import { FC } from "react";
import { observer } from "mobx-react";
// types
import { IActiveCycle } from "@plane/types";
// components
import {
ActiveCyclesProjectTitle,
ActiveCycleHeader,
ActiveCycleProgress,
ActiveCycleProductivity,
ActiveCycleStats,
} from "@/components/active-cycles";
// hooks
import { useProject } from "@/hooks/store";
export type ActiveCycleInfoProps = {
cycle: IActiveCycle;
workspaceSlug: string;
projectId: string;
};
export const ActiveCycleInfo: FC<ActiveCycleInfoProps> = observer((props) => {
const { cycle, workspaceSlug, projectId } = props;
const { getProjectById } = useProject();
const projectDetails = getProjectById(projectId);
if (!projectDetails) return null;
return (
<>
<ActiveCyclesProjectTitle project={projectDetails} />
<div className="flex flex-col gap-2 rounded border border-custom-border-200">
<ActiveCycleHeader cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress cycle={cycle} />
<ActiveCycleProductivity cycle={cycle} />
<ActiveCycleStats cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
</div>
</>
);
});

View File

@@ -1,3 +1,5 @@
export * from "./cycles-view";
export * from "./active-cycle-info";
export * from "./active-cycle";
export * from "./applied-filters";
export * from "./board/";

View File

@@ -1,9 +1,8 @@
import { observer } from "mobx-react";
// ui
import { Crown } from "lucide-react";
import { Breadcrumbs, ContrastIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// icons
export const WorkspaceActiveCycleHeader = observer(() => (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
@@ -20,7 +19,9 @@ export const WorkspaceActiveCycleHeader = observer(() => (
}
/>
</Breadcrumbs>
<Crown className="h-3.5 w-3.5 text-amber-400" />
<span className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl text-orange-500 bg-orange-500/20">
Beta
</span>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
export * from "./pro-plan-modal";

View File

@@ -0,0 +1,192 @@
import { FC, Fragment, useState } from "react";
// icons
import { CheckCircle } from "lucide-react";
// ui
import { Dialog, Transition, Tab } from "@headlessui/react";
// store
import { useEventTracker } from "@/hooks/store";
function classNames(...classes: any[]) {
return classes.filter(Boolean).join(" ");
}
const PRICING_CATEGORIES = ["Monthly", "Yearly"];
const MONTHLY_PLAN_ITEMS = [
"White-glove onboarding for your use-cases",
"Bespoke implementation",
"Priority integrations",
"Priority Support and SLAs",
"Early access to all paid features",
"Locked-in discount for a whole year",
];
const YEARLY_PLAN_ITEMS = [
"White-glove onboarding for your use-cases",
"Bespoke implementation",
"Priority integrations",
"Priority Support and SLAs",
"Early access to all paid features",
"Tiered discounts for the second and third years",
];
export type ProPlanModalProps = {
isOpen: boolean;
handleClose: () => void;
};
export const ProPlanModal: FC<ProPlanModalProps> = (props) => {
const { isOpen, handleClose } = props;
// store
const { captureEvent } = useEventTracker();
// states
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [tabIndex, setTabIndex] = useState(0);
const handleProPlaneMonthRedirection = () => {
if (process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL) {
window.open(process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL, "_blank");
captureEvent("pro_plan_modal_month_redirection", {});
}
};
const handleProPlanYearlyRedirection = () => {
if (process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL) {
window.open(process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL, "_blank");
captureEvent("pro_plan_modal_yearly_redirection", {});
}
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" 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-custom-backdrop" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={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="w-full max-w-lg transform overflow-hidden rounded-2xl bg-custom-background-100 p-6 text-left align-middle shadow-xl transition-all border-[0.5px] border-custom-border-100">
<Dialog.Title as="h2" className="text-2xl font-bold leading-6 mt-4 flex justify-center items-center">
Early-adopter pricing for believers
</Dialog.Title>
<div className="mt-2 mb-5">
<p className="text-center text-sm mb-6 px-10 text-custom-text-200">
Build Plane to your specs. You decide what we prioritize and build for everyone. Also get tailored
onboarding + implementation and priority support.
</p>
<Tab.Group>
<div className="flex w-full justify-center">
<Tab.List className="flex space-x-1 rounded-xl bg-custom-background-80 p-1 w-[72%]">
{PRICING_CATEGORIES.map((category, index) => (
<Tab
key={category}
className={({ selected }) =>
classNames(
"w-full rounded-lg py-2 text-sm font-medium leading-5",
"ring-white/60 ring-offset-2 ring-offset-custom-primary-90 focus:outline-none",
selected
? "bg-custom-background-100 text-custom-primary-100 shadow"
: "hover:bg-custom-background-80 text-custom-text-300 hover:text-custom-text-100"
)
}
onClick={() => setTabIndex(index)}
>
<>
{category}
{category === "Yearly" && (
<span className="bg-custom-primary-100 text-white rounded-full px-2 py-1 ml-1 text-xs">
-28%
</span>
)}
</>
</Tab>
))}
</Tab.List>
</div>
<Tab.Panels className="mt-2">
<Tab.Panel className={classNames("rounded-xl bg-custom-background-100 p-3")}>
<p className="ml-4 text-4xl font-bold mb-2">
$7
<span className="text-sm ml-3 text-custom-text-300">/user/month</span>
</p>
<ul>
{MONTHLY_PLAN_ITEMS.map((item) => (
<li key={item} className="relative rounded-md p-3 flex">
<p className="text-sm font-medium leading-5 flex items-center">
<CheckCircle className="h-4 w-4 mr-4" />
<span>{item}</span>
</p>
</li>
))}
</ul>
<div className="flex justify-center w-full">
<div className="relative inline-flex group mt-8">
<div className="absolute transition-all duration-1000 opacity-50 -inset-px bg-gradient-to-r from-[#44BCFF] via-[#FF44EC] to-[#FF675E] rounded-xl blur-lg group-hover:opacity-100 group-hover:-inset-1 group-hover:duration-200 animate-tilt" />
<button
type="button"
className="relative inline-flex items-center justify-center px-8 py-4 text-sm font-medium border-custom-border-100 border-[1.5px] transition-all duration-200 bg-custom-background-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-custom-border-200"
onClick={handleProPlaneMonthRedirection}
>
Become Early Adopter
</button>
</div>
</div>
</Tab.Panel>
<Tab.Panel className={classNames("rounded-xl bg-custom-background-100 p-3")}>
<p className="ml-4 text-4xl font-bold mb-2">
$5
<span className="text-sm ml-3 text-custom-text-300">/user/month</span>
</p>
<ul>
{YEARLY_PLAN_ITEMS.map((item) => (
<li key={item} className="relative rounded-md p-3 flex">
<p className="text-sm font-medium leading-5 flex items-center">
<CheckCircle className="h-4 w-4 mr-4" />
<span>{item}</span>
</p>
</li>
))}
</ul>
<div className="flex justify-center w-full">
<div className="relative inline-flex group mt-8">
<div className="absolute transition-all duration-1000 opacity-50 -inset-px bg-gradient-to-r from-[#44BCFF] via-[#FF44EC] to-[#FF675E] rounded-xl blur-lg group-hover:opacity-100 group-hover:-inset-1 group-hover:duration-200 animate-tilt" />
<button
type="button"
className="relative inline-flex items-center justify-center px-8 py-4 text-sm font-medium border-custom-border-100 border-[1.5px] transition-all duration-200 bg-custom-background-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-custom-border-200"
onClick={handleProPlanYearlyRedirection}
>
Become Early Adopter
</button>
</div>
</div>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
};

View File

@@ -13,11 +13,12 @@ import {
// types
import { IUserLite, TPage } from "@plane/types";
// components
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
import { IssueEmbedCard, PageContentBrowser, PageEditorTitle, PageContentLoader } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
import { useIssueEmbed } from "@/hooks/use-issue-embed";
import { usePageFilters } from "@/hooks/use-page-filters";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services
@@ -79,8 +80,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
members: projectMemberDetails,
user: currentUser ?? undefined,
});
// page filters
const { isFullWidth } = usePageFilters();
// issue-embed
const { fetchIssues } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
@@ -88,6 +92,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
updateMarkings(description_html ?? "<p></p>");
}, [description_html, updateMarkings]);
const handleIssueSearch = async (searchQuery: string) => {
const response = await fetchIssues(searchQuery);
return response;
};
if (pageDescription === undefined) return <PageContentLoader />;
return (
@@ -149,6 +158,24 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
embedHandler={{
issue: {
searchCallback: async (query) =>
new Promise((resolve) => {
setTimeout(async () => {
const response = await handleIssueSearch(query);
resolve(response);
}, 300);
}),
widgetCallback: (issueId) => (
<IssueEmbedCard
issueId={issueId}
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
),
},
}}
/>
)}
/>
@@ -162,6 +189,17 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
mentionHandler={{
highlights: mentionHighlights,
}}
embedHandler={{
issue: {
widgetCallback: (issueId) => (
<IssueEmbedCard
issueId={issueId}
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
),
},
}}
/>
)}
</div>

View File

@@ -0,0 +1 @@
export * from "./issue-embed";

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { AlertTriangle } from "lucide-react";
// types
import { IIssueDisplayProperties } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
// components
import { IssueProperties } from "@/components/issues/issue-layouts/properties/all-properties";
// constants
import { ISSUE_DISPLAY_PROPERTIES } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useIssueDetail, useProject, useUser } from "@/hooks/store";
type Props = {
issueId: string;
projectId: string;
workspaceSlug: string;
};
export const IssueEmbedCard: React.FC<Props> = observer((props) => {
const { issueId, projectId, workspaceSlug } = props;
// states
const [error, setError] = useState<any | null>(null);
// store hooks
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
const { getProjectById } = useProject();
const {
setPeekIssue,
issue: { fetchIssue, getIssueById, updateIssue },
} = useIssueDetail();
// derived values
const projectRole = currentWorkspaceAllProjectsRole?.[projectId];
const projectDetails = getProjectById(projectId);
const issueDetails = getIssueById(issueId);
// auth
const isReadOnly = !!projectRole && projectRole < EUserProjectRoles.MEMBER;
// issue display properties
const displayProperties: IIssueDisplayProperties = {};
ISSUE_DISPLAY_PROPERTIES.forEach((property) => {
displayProperties[property.key] = true;
});
// fetch issue details if not available
useEffect(() => {
if (!issueDetails) {
fetchIssue(workspaceSlug, projectId, issueId)
.then(() => setError(null))
.catch((error) => setError(error));
}
}, [fetchIssue, issueDetails, issueId, projectId, workspaceSlug]);
if (!issueDetails && !error)
return (
<div className="rounded-md p-3 my-2">
<Loader className="px-6">
<Loader.Item height="30px" />
<div className="mt-3 space-y-2">
<Loader.Item height="20px" width="70%" />
<Loader.Item height="20px" width="60%" />
</div>
</Loader>
</div>
);
if (error)
return (
<div className="flex items-center gap-3 rounded-md border-2 border-orange-500 bg-orange-500/10 text-orange-500 px-4 py-3 my-2 text-base">
<AlertTriangle className="text-orange-500 size-8" />
This Issue embed is not found in any project. It can no longer be updated or accessed from here.
</div>
);
return (
<div
className="issue-embed cursor-pointer space-y-2 rounded-md bg-custom-background-90 p-3 my-2"
role="button"
onClick={() =>
setPeekIssue({
issueId,
projectId,
workspaceSlug,
})
}
>
<h5 className="text-xs text-custom-text-300">
{projectDetails?.identifier}-{issueDetails?.sequence_id}
</h5>
<h4 className="text-sm font-medium line-clamp-2 break-words">{issueDetails?.name}</h4>
{issueDetails && (
<IssueProperties
className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5"
issue={issueDetails}
displayProperties={displayProperties}
activeLayout="Page issue embed"
updateIssue={async (projectId, issueId, data) => await updateIssue(workspaceSlug, projectId, issueId, data)}
isReadOnly={isReadOnly}
/>
)}
</div>
);
});

View File

@@ -1,3 +1,4 @@
export * from "./embed";
export * from "./header";
export * from "./summary";
export * from "./editor-body";

View File

@@ -1,10 +1,10 @@
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// headless ui
import { FileText, HelpCircle, MessagesSquare, MoveLeft, Zap } from "lucide-react";
import { Transition } from "@headlessui/react";
// icons
import { FileText, HelpCircle, MessagesSquare, MoveLeft, Zap } from "lucide-react";
// headless ui
import { Transition } from "@headlessui/react";
// ui
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
// hooks
@@ -13,6 +13,7 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
// assets
import packageJson from "package.json";
import { PlaneBadge } from "./plane-badge";
const HELP_OPTIONS = [
{
@@ -64,11 +65,9 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = observe
}`}
>
{!isCollapsed && (
<Tooltip tooltipContent={`Version: v${packageJson.version}`} isMobile={isMobile}>
<div className="w-1/2 cursor-default rounded-md bg-green-500/10 px-2 py-1 text-center text-xs font-medium text-green-500 outline-none leading-6">
Community
</div>
</Tooltip>
<>
<PlaneBadge />
</>
)}
<div className={`flex items-center gap-1 ${isCollapsed ? "flex-col justify-center" : "w-1/2 justify-evenly"}`}>
<Tooltip tooltipContent="Shortcuts" isMobile={isMobile}>

View File

@@ -9,3 +9,6 @@ export * from "./sidebar-dropdown";
export * from "./sidebar-menu";
export * from "./sidebar-quick-action";
export * from "./workspace-active-cycles-upgrade";
// ee imports
export * from "./workspace-active-cycles-list";

View File

@@ -0,0 +1,41 @@
import React, { useState } from "react";
// ui
import { Tooltip, Button } from "@plane/ui";
// components
import { ProPlanModal } from "@/components/license";
// hooks
import { useEventTracker } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// assets
import packageJson from "package.json";
export const PlaneBadge: React.FC = () => {
// states
const [isProPlanModalOpen, setIsProPlanModalOpen] = useState(false);
// hooks
const { captureEvent } = useEventTracker();
const { isMobile } = usePlatformOS();
const handleProPlanModalOpen = () => {
setIsProPlanModalOpen(true);
captureEvent("pro_plan_modal_opened", {});
};
return (
<>
<ProPlanModal isOpen={isProPlanModalOpen} handleClose={() => setIsProPlanModalOpen(false)} />
<Button
variant="outline-primary"
className="w-1/2 cursor-pointer rounded-2xl px-2.5 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanModalOpen}
>
Plane Pro
</Button>
<Tooltip tooltipContent={`Version: v${packageJson.version}`} isMobile={isMobile}>
<div className="w-1/2 cursor-default rounded-md bg-green-500/10 px-2 py-1 text-center text-xs font-medium text-green-500 outline-none leading-6">
Community
</div>
</Tooltip>
</>
);
};

View File

@@ -2,7 +2,6 @@ import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Crown } from "lucide-react";
// ui
import { Tooltip } from "@plane/ui";
// components
@@ -70,7 +69,9 @@ export const WorkspaceSidebarMenu = observer(() => {
}
{!sidebarCollapsed && <p className="leading-5">{link.label}</p>}
{!sidebarCollapsed && link.key === "active-cycles" && (
<Crown className="h-3.5 w-3.5 text-amber-400" />
<span className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl text-orange-500 bg-orange-500/20">
Beta
</span>
)}
</div>
</Tooltip>

View File

@@ -0,0 +1,66 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { Button } from "@plane/ui";
import { ActiveCyclesListPage } from "@/components/active-cycles";
import { EmptyStateType } from "@/constants/empty-state";
import { EmptyState } from "../empty-state";
const perPage = 3;
export const WorkspaceActiveCyclesList = observer(() => {
// state
const [pageCount, setPageCount] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [resultsCount, setResultsCount] = useState(0); // workspaceActiveCycles.results.length
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const activeCyclesPages = [];
const updateTotalPages = (count: number) => {
setTotalPages(count);
};
const updateResultsCount = (count: number) => {
setResultsCount(count);
};
const handleLoadMore = () => {
setPageCount(pageCount + 1);
};
if (!workspaceSlug) {
return null;
}
for (let i = 1; i <= pageCount; i++) {
activeCyclesPages.push(
<ActiveCyclesListPage
cursor={`${perPage}:${i - 1}:0`}
perPage={perPage}
workspaceSlug={workspaceSlug.toString()}
updateTotalPages={updateTotalPages}
updateResultsCount={updateResultsCount}
key={i}
/>
);
}
return (
<div className="h-full w-full overflow-y-scroll bg-custom-background-90 vertical-scrollbar scrollbar-md">
{activeCyclesPages}
{pageCount < totalPages && resultsCount !== 0 && (
<div className="flex items-center justify-center gap-4 text-xs w-full py-5">
<Button variant="outline-primary" size="sm" onClick={handleLoadMore}>
Load More
</Button>
</div>
)}
{resultsCount === 0 && <EmptyState type={EmptyStateType.WORKSPACE_ACTIVE_CYCLES} />}
</div>
);
});

View File

@@ -150,3 +150,27 @@ export const WORKSPACE_ACTIVE_CYCLES_DETAILS = [
icon: Microscope,
},
];
// ee
export const WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS = [
{
key: "completed_issues",
title: "Completed",
color: "#6490FE",
},
{
key: "started_issues",
title: "Started",
color: "#FDD97F",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#FEB055",
},
{
key: "backlog_issues",
title: "Backlog",
color: "#F0F0F3",
},
];

View File

@@ -88,6 +88,7 @@ export enum EmptyStateType {
ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE = "active-cycle-assignee-empty-state",
ACTIVE_CYCLE_LABEL_EMPTY_STATE = "active-cycle-label-empty-state",
WORKSPACE_ACTIVE_CYCLES = "workspace-active-cycles",
DISABLED_PROJECT_INBOX = "disabled-project-inbox",
DISABLED_PROJECT_CYCLE = "disabled-project-cycle",
DISABLED_PROJECT_MODULE = "disabled-project-module",
@@ -608,6 +609,13 @@ const emptyStateDetails = {
title: "Add labels to issues to see the \n breakdown of work by labels.",
path: "/empty-state/active-cycle/label",
},
[EmptyStateType.WORKSPACE_ACTIVE_CYCLES]: {
key: EmptyStateType.WORKSPACE_ACTIVE_CYCLES,
title: "No active cycles",
description:
"Cycles of your projects that includes any period that encompasses today's date within its range. Find the progress and details of all your active cycle here.",
path: "/empty-state/onboarding/workspace-active-cycles",
},
[EmptyStateType.DISABLED_PROJECT_INBOX]: {
key: EmptyStateType.DISABLED_PROJECT_INBOX,
title: "Inbox is not enabled for the project.",

View File

@@ -140,6 +140,8 @@ export const WORKSPACE_LABELS = (workspaceSlug: string) => `WORKSPACE_LABELS_${w
export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`;
// cycles
export const WORKSPACE_ACTIVE_CYCLES_LIST = (workspaceSlug: string, cursor: string, per_page: string) =>
`WORKSPACE_ACTIVE_CYCLES_LIST_${workspaceSlug.toUpperCase()}_${cursor.toUpperCase()}_${per_page.toUpperCase()}`;
export const CYCLES_LIST = (projectId: string) => `CYCLE_LIST_${projectId.toUpperCase()}`;
export const INCOMPLETE_CYCLES_LIST = (projectId: string) => `INCOMPLETE_CYCLES_LIST_${projectId.toUpperCase()}`;
export const CURRENT_CYCLE_LIST = (projectId: string) => `CURRENT_CYCLE_LIST_${projectId.toUpperCase()}`;

View File

@@ -115,6 +115,14 @@ export const PROJECT_SETTINGS_LINKS: {
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels`,
Icon: SettingIcon,
},
{
key: "integrations",
label: "Integrations",
href: `/settings/integrations`,
access: EUserProjectRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`,
Icon: SettingIcon,
},
{
key: "estimates",
label: "Estimates",

View File

@@ -168,6 +168,22 @@ export const WORKSPACE_SETTINGS_LINKS: {
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing`,
Icon: SettingIcon,
},
{
key: "integrations",
label: "Integrations",
href: `/settings/integrations`,
access: EUserWorkspaceRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`,
Icon: SettingIcon,
},
{
key: "import",
label: "Imports",
href: `/settings/imports`,
access: EUserWorkspaceRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/imports`,
Icon: SettingIcon,
},
{
key: "export",
label: "Exports",

View File

@@ -0,0 +1,37 @@
// editor
import { TEmbedItem } from "@plane/document-editor";
// types
import { TPageEmbedResponse } from "@plane/types";
// ui
import { PriorityIcon } from "@plane/ui";
// services
import { PageService } from "@/services/page.service";
const pageService = new PageService();
export const useIssueEmbed = (workspaceSlug: string, projectId: string) => {
const fetchIssues = async (searchQuery: string): Promise<TEmbedItem[]> =>
await pageService
.searchEmbed<TPageEmbedResponse[]>(workspaceSlug, projectId, {
query_type: "issue",
query: searchQuery,
count: 10,
})
.then((res) => {
const structuredIssues: TEmbedItem[] = (res ?? []).map((issue) => ({
id: issue.id,
subTitle: `${issue.project__identifier}-${issue.sequence_id}`,
title: issue.name,
icon: <PriorityIcon priority={issue.priority} />,
}));
return structuredIssues;
})
.catch((err) => {
throw Error(err);
});
return {
fetchIssues,
};
};

View File

@@ -3,13 +3,12 @@ import { observer } from "mobx-react";
// components
import { PageHead } from "@/components/core";
import { WorkspaceActiveCycleHeader } from "@/components/headers";
import { WorkspaceActiveCyclesUpgrade } from "@/components/workspace";
import { WorkspaceActiveCyclesList } from "@/components/workspace";
// layouts
import { useWorkspace } from "@/hooks/store";
import { AppLayout } from "@/layouts/app-layout";
// types
import { NextPageWithLayout } from "@/lib/types";
// hooks
const WorkspaceActiveCyclesPage: NextPageWithLayout = observer(() => {
const { currentWorkspace } = useWorkspace();
@@ -19,7 +18,7 @@ const WorkspaceActiveCyclesPage: NextPageWithLayout = observer(() => {
return (
<>
<PageHead title={pageTitle} />
<WorkspaceActiveCyclesUpgrade />
<WorkspaceActiveCyclesList />
</>
);
});

View File

@@ -0,0 +1,86 @@
import { ReactElement } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { IProject } from "@plane/types";
// hooks
import { PageHead } from "@/components/core";
import { EmptyState } from "@/components/empty-state";
import { ProjectSettingHeader } from "@/components/headers";
import { IntegrationCard } from "@/components/project";
import { IntegrationsSettingsLoader } from "@/components/ui";
// layouts
import { EmptyStateType } from "@/constants/empty-state";
import { PROJECT_DETAILS, WORKSPACE_INTEGRATIONS } from "@/constants/fetch-keys";
import { AppLayout } from "@/layouts/app-layout";
import { ProjectSettingLayout } from "@/layouts/settings-layout";
// services
import { NextPageWithLayout } from "@/lib/types";
import { IntegrationService } from "@/services/integrations";
import { ProjectService } from "@/services/project";
// components
// ui
// types
// fetch-keys
// constants
// services
const integrationService = new IntegrationService();
const projectService = new ProjectService();
const ProjectIntegrationsPage: NextPageWithLayout = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// fetch project details
const { data: projectDetails } = useSWR<IProject>(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId ? () => projectService.getProject(workspaceSlug as string, projectId as string) : null
);
// fetch Integrations list
const { data: workspaceIntegrations } = useSWR(
workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null,
() => (workspaceSlug ? integrationService.getWorkspaceIntegrationsList(workspaceSlug as string) : null)
);
// derived values
const isAdmin = projectDetails?.member_role === 20;
const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Integrations` : undefined;
return (
<>
<PageHead title={pageTitle} />
<div className={`w-full gap-10 overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex items-center border-b border-custom-border-100 py-3.5">
<h3 className="text-xl font-medium">Integrations</h3>
</div>
{workspaceIntegrations ? (
workspaceIntegrations.length > 0 ? (
<div>
{workspaceIntegrations.map((integration) => (
<IntegrationCard key={integration.integration_detail.id} integration={integration} />
))}
</div>
) : (
<div className="h-full w-full py-8">
<EmptyState
type={EmptyStateType.PROJECT_SETTINGS_INTEGRATIONS}
primaryButtonLink={`/${workspaceSlug}/settings/integrations`}
/>
</div>
)
) : (
<IntegrationsSettingsLoader />
)}
</div>
</>
);
});
ProjectIntegrationsPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout withProjectWrapper header={<ProjectSettingHeader title="Integrations Settings" />}>
<ProjectSettingLayout>{page}</ProjectSettingLayout>
</AppLayout>
);
};
export default ProjectIntegrationsPage;

View File

@@ -1,16 +1,16 @@
import { observer } from "mobx-react";
import { observer } from "mobx-react-lite";
// hooks
import { PageHead } from "components/core";
import { WorkspaceSettingHeader } from "components/headers";
import IntegrationGuide from "components/integration/guide";
import { EUserWorkspaceRoles } from "constants/workspace";
import { useUser, useWorkspace } from "hooks/store";
import { PageHead } from "@/components/core";
import { WorkspaceSettingHeader } from "@/components/headers";
import IntegrationGuide from "@/components/integration/guide";
import { EUserWorkspaceRoles } from "@/constants/workspace";
import { useUser, useWorkspace } from "@/hooks/store";
// layouts
import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout";
import { AppLayout } from "@/layouts/app-layout";
import { WorkspaceSettingLayout } from "@/layouts/settings-layout";
// components
// types
import { NextPageWithLayout } from "lib/types";
import { NextPageWithLayout } from "@/lib/types";
// constants
const ImportsPage: NextPageWithLayout = observer(() => {

View File

@@ -1,26 +1,25 @@
import { ReactElement } from "react";
import { observer } from "mobx-react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import useSWR from "swr";
// hooks
// services
// layouts
// components
import { PageHead } from "components/core";
import { WorkspaceSettingHeader } from "components/headers";
import { SingleIntegrationCard } from "components/integration";
import { PageHead } from "@/components/core";
import { WorkspaceSettingHeader } from "@/components/headers";
import { SingleIntegrationCard } from "@/components/integration";
// ui
import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "components/ui";
// types
// fetch-keys
import { APP_INTEGRATIONS } from "constants/fetch-keys";
import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { useUser, useWorkspace } from "hooks/store";
import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout";
import { NextPageWithLayout } from "lib/types";
import { IntegrationService } from "services/integrations";
import { APP_INTEGRATIONS } from "@/constants/fetch-keys";
import { EUserWorkspaceRoles } from "@/constants/workspace";
// hooks
import { useUser, useWorkspace } from "@/hooks/store";
// layouts
import { AppLayout } from "@/layouts/app-layout";
import { WorkspaceSettingLayout } from "@/layouts/settings-layout";
// types
import { NextPageWithLayout } from "@/lib/types";
// services
import { IntegrationService } from "@/services/integrations";
const integrationService = new IntegrationService();
@@ -38,6 +37,10 @@ const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => {
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined;
const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () =>
workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null
);
if (!isAdmin)
return (
<>
@@ -48,10 +51,6 @@ const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => {
</>
);
const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () =>
workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null
);
return (
<>
<PageHead title={pageTitle} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -2,7 +2,7 @@
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
// types
import type { CycleDateCheckData, ICycle, TIssue } from "@plane/types";
import type { CycleDateCheckData, ICycle, IWorkspaceActiveCyclesResponse, TIssue } from "@plane/types";
// helpers
export class CycleService extends APIService {
@@ -10,6 +10,23 @@ export class CycleService extends APIService {
super(API_BASE_URL);
}
async workspaceActiveCycles(
workspaceSlug: string,
cursor: string,
per_page: number
): Promise<IWorkspaceActiveCyclesResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/active-cycles/`, {
params: {
per_page,
cursor,
},
})
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async getWorkspaceCycles(workspaceSlug: string): Promise<ICycle[]> {
return this.get(`/api/workspaces/${workspaceSlug}/cycles/`)
.then((response) => response?.data)

View File

@@ -1,5 +1,5 @@
// types
import { TPage } from "@plane/types";
import { TPage, TPageEmbedType } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
@@ -119,4 +119,22 @@ export class PageService extends APIService {
throw error?.response?.data;
});
}
async searchEmbed<T>(
workspaceSlug: string,
projectId: string,
params: {
query_type: TPageEmbedType;
count?: number;
query: string;
}
): Promise<T | undefined> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/search/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}