feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
"use client";
import type { FC } from "react";
import { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueGroupByToServerOptions, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TGroupedIssues } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
// hooks
import { useCalendarView } from "@/hooks/store/use-calendar-view";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { useIssuesActions } from "@/hooks/use-issues-actions";
// types
import type { IQuickActionProps } from "../list/list-view-types";
import { CalendarChart } from "./calendar";
import { handleDragDrop } from "./utils";
export type CalendarStoreType =
| EIssuesStoreType.PROJECT
| EIssuesStoreType.MODULE
| EIssuesStoreType.CYCLE
| EIssuesStoreType.PROJECT_VIEW
| EIssuesStoreType.TEAM
| EIssuesStoreType.TEAM_VIEW
| EIssuesStoreType.EPIC;
interface IBaseCalendarRoot {
QuickActions: FC<IQuickActionProps>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
isCompletedCycle?: boolean;
viewId?: string | undefined;
isEpic?: boolean;
canEditPropertiesBasedOnProject?: (projectId: string) => boolean;
}
export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
const {
QuickActions,
addIssuesToView,
isCompletedCycle = false,
viewId,
isEpic = false,
canEditPropertiesBasedOnProject,
} = props;
// router
const { workspaceSlug } = useParams();
// hooks
const storeType = isEpic ? EIssuesStoreType.EPIC : (useIssueStoreType() as CalendarStoreType);
const { allowPermissions } = useUserPermissions();
const { issues, issuesFilter, issueMap } = useIssues(storeType);
const {
fetchIssues,
fetchNextIssues,
quickAddIssue,
updateIssue,
removeIssue,
removeIssueFromView,
archiveIssue,
restoreIssue,
updateFilters,
} = useIssuesActions(storeType);
const issueCalendarView = useCalendarView();
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const { enableInlineEditing } = issues?.viewFlags || {};
const displayFilters = issuesFilter.issueFilters?.displayFilters;
const groupedIssueIds = (issues.groupedIssueIds ?? {}) as TGroupedIssues;
const layout = displayFilters?.calendar?.layout ?? "month";
const { startDate, endDate } = issueCalendarView.getStartAndEndDate(layout) ?? {};
useEffect(() => {
if (startDate && endDate && layout) {
fetchIssues(
"init-loader",
{
canGroup: true,
perPageCount: layout === "month" ? 4 : 30,
before: endDate,
after: startDate,
groupedBy: EIssueGroupByToServerOptions["target_date"],
},
viewId
);
}
}, [fetchIssues, storeType, startDate, endDate, layout, viewId]);
const handleDragAndDrop = async (
issueId: string | undefined,
issueProjectId: string | undefined,
sourceDate: string | undefined,
destinationDate: string | undefined
) => {
if (!issueId || !destinationDate || !sourceDate || !issueProjectId) return;
await handleDragDrop(
issueId,
sourceDate,
destinationDate,
workspaceSlug?.toString(),
issueProjectId,
updateIssue
).catch((err) => {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: err?.detail ?? "Failed to perform this action",
});
});
};
const loadMoreIssues = useCallback(
(dateString: string) => {
fetchNextIssues(dateString);
},
[fetchNextIssues]
);
const getPaginationData = useCallback(
(groupId: string | undefined) => issues?.getPaginationData(groupId, undefined),
[issues?.getPaginationData]
);
const getGroupIssueCount = useCallback(
(groupId: string | undefined) => issues?.getGroupIssueCount(groupId, undefined, false),
[issues?.getGroupIssueCount]
);
const canEditProperties = useCallback(
(projectId: string | undefined) => {
const isEditingAllowedBasedOnProject =
canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed;
return enableInlineEditing && isEditingAllowedBasedOnProject;
},
[canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed]
);
return (
<>
<div className="h-full w-full overflow-hidden bg-custom-background-100 pt-4">
<CalendarChart
issuesFilterStore={issuesFilter}
issues={issueMap}
groupedIssueIds={groupedIssueIds}
layout={displayFilters?.calendar?.layout}
showWeekends={displayFilters?.calendar?.show_weekends ?? false}
issueCalendarView={issueCalendarView}
quickActions={({ issue, parentRef, customActionButton, placement }) => (
<QuickActions
parentRef={parentRef}
customActionButton={customActionButton}
issue={issue}
handleDelete={async () => removeIssue(issue.project_id, issue.id)}
handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)}
handleRemoveFromView={async () => removeIssueFromView && removeIssueFromView(issue.project_id, issue.id)}
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
readOnly={!canEditProperties(issue.project_id ?? undefined) || isCompletedCycle}
placements={placement}
/>
)}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
addIssuesToView={addIssuesToView}
quickAddCallback={quickAddIssue}
readOnly={isCompletedCycle}
updateFilters={updateFilters}
handleDragAndDrop={handleDragAndDrop}
canEditProperties={canEditProperties}
isEpic={isEpic}
/>
</div>
</>
);
});

View File

@@ -0,0 +1,264 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
// plane constants
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
// types
import type {
TGroupedIssues,
TIssue,
TIssueMap,
TPaginationData,
ICalendarWeek,
TSupportedFilterForUpdate,
} from "@plane/types";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// ui
import { Spinner } from "@plane/ui";
import { renderFormattedPayloadDate, cn } from "@plane/utils";
// constants
import { MONTHS_LIST } from "@/constants/calendar";
// helpers
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import useSize from "@/hooks/use-window-size";
// store
import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic";
import type { ICycleIssuesFilter } from "@/store/issue/cycle";
import type { ICalendarStore } from "@/store/issue/issue_calendar_view.store";
import type { IModuleIssuesFilter } from "@/store/issue/module";
import type { IProjectIssuesFilter } from "@/store/issue/project";
import type { IProjectViewIssuesFilter } from "@/store/issue/project-views";
// local imports
import { IssueLayoutHOC } from "../issue-layout-HOC";
import type { TRenderQuickActions } from "../list/list-view-types";
import { CalendarHeader } from "./header";
import { CalendarIssueBlocks } from "./issue-blocks";
import { CalendarWeekDays } from "./week-days";
import { CalendarWeekHeader } from "./week-header";
type Props = {
issuesFilterStore:
| IProjectIssuesFilter
| IModuleIssuesFilter
| ICycleIssuesFilter
| IProjectViewIssuesFilter
| IProjectEpicsFilter;
issues: TIssueMap | undefined;
groupedIssueIds: TGroupedIssues;
layout: "month" | "week" | undefined;
showWeekends: boolean;
issueCalendarView: ICalendarStore;
loadMoreIssues: (dateString: string) => void;
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
quickActions: TRenderQuickActions;
handleDragAndDrop: (
issueId: string | undefined,
issueProjectId: string | undefined,
sourceDate: string | undefined,
destinationDate: string | undefined
) => Promise<void>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
readOnly?: boolean;
updateFilters?: (
projectId: string,
filterType: TSupportedFilterTypeForUpdate,
filters: TSupportedFilterForUpdate
) => Promise<void>;
canEditProperties: (projectId: string | undefined) => boolean;
isEpic?: boolean;
};
export const CalendarChart: React.FC<Props> = observer((props) => {
const {
issuesFilterStore,
issues,
groupedIssueIds,
layout,
showWeekends,
issueCalendarView,
loadMoreIssues,
handleDragAndDrop,
quickActions,
quickAddCallback,
addIssuesToView,
getPaginationData,
getGroupIssueCount,
updateFilters,
canEditProperties,
readOnly = false,
isEpic = false,
} = props;
// states
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
//refs
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
// store hooks
const {
issues: { viewFlags },
} = useIssues(EIssuesStoreType.PROJECT);
const [windowWidth] = useSize();
const { enableIssueCreation, enableQuickAdd } = viewFlags || {};
const calendarPayload = issueCalendarView.calendarPayload;
const allWeeksOfActiveMonth = issueCalendarView.allWeeksOfActiveMonth;
const formattedDatePayload = renderFormattedPayloadDate(selectedDate) ?? undefined;
// Enable Auto Scroll for calendar
useEffect(() => {
const element = scrollableContainerRef.current;
if (!element) return;
return combine(
autoScrollForElements({
element,
})
);
}, [scrollableContainerRef?.current]);
if (!calendarPayload || !formattedDatePayload)
return (
<div className="grid h-full w-full place-items-center">
<Spinner />
</div>
);
const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : [];
return (
<>
<div className="flex h-full w-full flex-col overflow-hidden">
<CalendarHeader
setSelectedDate={setSelectedDate}
issuesFilterStore={issuesFilterStore}
updateFilters={updateFilters}
/>
<IssueLayoutHOC layout={EIssueLayoutTypes.CALENDAR}>
<div
className={cn("flex md:h-full w-full flex-col overflow-y-auto", {
"vertical-scrollbar scrollbar-lg": windowWidth > 768,
})}
ref={scrollableContainerRef}
>
<CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} />
<div className="h-full w-full">
{layout === "month" && (
<div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-200">
{allWeeksOfActiveMonth &&
Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => (
<CalendarWeekDays
selectedDate={selectedDate}
setSelectedDate={setSelectedDate}
handleDragAndDrop={handleDragAndDrop}
issuesFilterStore={issuesFilterStore}
key={weekIndex}
week={week}
issues={issues}
groupedIssueIds={groupedIssueIds}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
enableQuickIssueCreate={enableQuickAdd}
disableIssueCreation={!enableIssueCreation}
quickActions={quickActions}
quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView}
readOnly={readOnly}
canEditProperties={canEditProperties}
isEpic={isEpic}
/>
))}
</div>
)}
{layout === "week" && (
<CalendarWeekDays
selectedDate={selectedDate}
setSelectedDate={setSelectedDate}
handleDragAndDrop={handleDragAndDrop}
issuesFilterStore={issuesFilterStore}
week={issueCalendarView.allDaysOfActiveWeek}
issues={issues}
groupedIssueIds={groupedIssueIds}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
enableQuickIssueCreate={enableQuickAdd}
disableIssueCreation={!enableIssueCreation}
quickActions={quickActions}
quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView}
readOnly={readOnly}
canEditProperties={canEditProperties}
isEpic={isEpic}
/>
)}
</div>
{/* mobile view */}
<div className="md:hidden">
<p className="p-4 text-xl font-semibold">
{`${selectedDate.getDate()} ${
MONTHS_LIST[selectedDate.getMonth() + 1].title
}, ${selectedDate.getFullYear()}`}
</p>
<CalendarIssueBlocks
date={selectedDate}
issueIdList={issueIdList}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
quickActions={quickActions}
enableQuickIssueCreate={enableQuickAdd}
disableIssueCreation={!enableIssueCreation}
quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView}
readOnly={readOnly}
canEditProperties={canEditProperties}
isDragDisabled
isMobileView
isEpic={isEpic}
/>
</div>
</div>
</IssueLayoutHOC>
{/* mobile view */}
<div className="md:hidden">
<p className="p-4 text-xl font-semibold">
{`${selectedDate.getDate()} ${
MONTHS_LIST[selectedDate.getMonth() + 1].title
}, ${selectedDate.getFullYear()}`}
</p>
<CalendarIssueBlocks
date={selectedDate}
issueIdList={issueIdList}
quickActions={quickActions}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
enableQuickIssueCreate={enableQuickAdd}
disableIssueCreation={!enableIssueCreation}
quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView}
readOnly={readOnly}
canEditProperties={canEditProperties}
isDragDisabled
isMobileView
isEpic={isEpic}
/>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,222 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays";
import { observer } from "mobx-react";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TGroupedIssues, TIssue, TIssueMap, TPaginationData, ICalendarDate } from "@plane/types";
// types
// ui
// components
import { cn, renderFormattedPayloadDate } from "@plane/utils";
import { highlightIssueOnDrop } from "@/components/issues/issue-layouts/utils";
// helpers
import { MONTHS_LIST } from "@/constants/calendar";
// helpers
// types
import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic";
import type { ICycleIssuesFilter } from "@/store/issue/cycle";
import type { IModuleIssuesFilter } from "@/store/issue/module";
import type { IProjectIssuesFilter } from "@/store/issue/project";
import type { IProjectViewIssuesFilter } from "@/store/issue/project-views";
import type { TRenderQuickActions } from "../list/list-view-types";
import { CalendarIssueBlocks } from "./issue-blocks";
type Props = {
issuesFilterStore:
| IProjectIssuesFilter
| IModuleIssuesFilter
| ICycleIssuesFilter
| IProjectViewIssuesFilter
| IProjectEpicsFilter;
date: ICalendarDate;
issues: TIssueMap | undefined;
groupedIssueIds: TGroupedIssues;
loadMoreIssues: (dateString: string) => void;
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
quickActions: TRenderQuickActions;
handleDragAndDrop: (
issueId: string | undefined,
issueProjectId: string | undefined,
sourceDate: string | undefined,
destinationDate: string | undefined
) => Promise<void>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
readOnly?: boolean;
selectedDate: Date;
setSelectedDate: (date: Date) => void;
canEditProperties: (projectId: string | undefined) => boolean;
isEpic?: boolean;
};
export const CalendarDayTile: React.FC<Props> = observer((props) => {
const {
issuesFilterStore,
date,
issues,
groupedIssueIds,
loadMoreIssues,
getPaginationData,
getGroupIssueCount,
quickActions,
enableQuickIssueCreate,
disableIssueCreation,
quickAddCallback,
addIssuesToView,
readOnly = false,
selectedDate,
handleDragAndDrop,
setSelectedDate,
canEditProperties,
isEpic = false,
} = props;
const [isDraggingOver, setIsDraggingOver] = useState(false);
const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month";
const formattedDatePayload = renderFormattedPayloadDate(date.date);
const dayTileRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = dayTileRef.current;
if (!element) return;
return combine(
dropTargetForElements({
element,
getData: () => ({ date: formattedDatePayload }),
onDragEnter: () => {
setIsDraggingOver(true);
},
onDragLeave: () => {
setIsDraggingOver(false);
},
onDrop: ({ source, self }) => {
setIsDraggingOver(false);
const sourceData = source?.data as { id: string; date: string } | undefined;
const destinationData = self?.data as { date: string } | undefined;
if (!sourceData || !destinationData) return;
const issueDetails = issues?.[sourceData?.id];
if (issueDetails?.start_date) {
const issueStartDate = new Date(issueDetails.start_date);
const targetDate = new Date(destinationData?.date);
const diffInDays = differenceInCalendarDays(targetDate, issueStartDate);
if (diffInDays < 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Due date cannot be before the start date of the work item.",
});
return;
}
}
handleDragAndDrop(
sourceData?.id,
issueDetails?.project_id ?? undefined,
sourceData?.date,
destinationData?.date
);
highlightIssueOnDrop(source?.element?.id, false);
},
})
);
}, [dayTileRef?.current, formattedDatePayload]);
if (!formattedDatePayload) return null;
const issueIds = groupedIssueIds?.[formattedDatePayload];
const isToday = date.date.toDateString() === new Date().toDateString();
const isSelectedDate = date.date.toDateString() == selectedDate.toDateString();
const isWeekend = [0, 6].includes(date.date.getDay());
const isMonthLayout = calendarLayout === "month";
const normalBackground = isWeekend ? "bg-custom-background-90" : "bg-custom-background-100";
const draggingOverBackground = isWeekend ? "bg-custom-background-80" : "bg-custom-background-90";
return (
<>
<div ref={dayTileRef} className="group relative flex h-full w-full flex-col bg-custom-background-90">
{/* header */}
<div
className={`hidden flex-shrink-0 items-center justify-end px-2 py-1.5 text-right text-xs md:flex ${
isMonthLayout // if month layout, highlight current month days
? date.is_current_month
? "font-medium"
: "text-custom-text-300"
: "font-medium" // if week layout, highlight all days
} ${isWeekend ? "bg-custom-background-90" : "bg-custom-background-100"} `}
>
{date.date.getDate() === 1 && MONTHS_LIST[date.date.getMonth() + 1].shortTitle + " "}
{isToday ? (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-primary-100 text-white">
{date.date.getDate()}
</span>
) : (
<>{date.date.getDate()}</>
)}
</div>
{/* content */}
<div className="h-full w-full hidden md:block">
<div
className={cn(
`h-full w-full select-none ${isDraggingOver ? `${draggingOverBackground} opacity-70` : normalBackground}`,
{
"min-h-[5rem]": isMonthLayout,
}
)}
>
<CalendarIssueBlocks
date={date.date}
issueIdList={issueIds}
quickActions={quickActions}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
isDragDisabled={readOnly}
addIssuesToView={addIssuesToView}
disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate}
quickAddCallback={quickAddCallback}
readOnly={readOnly}
canEditProperties={canEditProperties}
isEpic={isEpic}
/>
</div>
</div>
{/* Mobile view content */}
<div
onClick={() => setSelectedDate(date.date)}
className={cn(
"text-sm py-2.5 h-full w-full font-medium mx-auto flex flex-col justify-start items-center md:hidden cursor-pointer opacity-80",
{
"bg-custom-background-100": !isWeekend,
}
)}
>
<div
className={cn("size-6 flex items-center justify-center rounded-full", {
"bg-custom-primary-100 text-white": isSelectedDate,
"bg-custom-primary-100/10 text-custom-primary-100 ": isToday && !isSelectedDate,
})}
>
{date.date.getDate()}
</div>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,2 @@
export * from "./months-dropdown";
export * from "./options-dropdown";

View File

@@ -0,0 +1,155 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { usePopper } from "react-popper";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
//hooks
// icons
// constants
import { getDate } from "@plane/utils";
import { MONTHS_LIST } from "@/constants/calendar";
import { useCalendarView } from "@/hooks/store/use-calendar-view";
import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic";
import type { ICycleIssuesFilter } from "@/store/issue/cycle";
import type { IModuleIssuesFilter } from "@/store/issue/module";
import type { IProjectIssuesFilter } from "@/store/issue/project";
import type { IProjectViewIssuesFilter } from "@/store/issue/project-views";
// helpers
interface Props {
issuesFilterStore:
| IProjectIssuesFilter
| IModuleIssuesFilter
| ICycleIssuesFilter
| IProjectViewIssuesFilter
| IProjectEpicsFilter;
}
export const CalendarMonthsDropdown: React.FC<Props> = observer((props: Props) => {
const { issuesFilterStore } = props;
const issueCalendarView = useCalendarView();
const calendarLayout = issuesFilterStore.issueFilters?.displayFilters?.calendar?.layout ?? "month";
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "auto",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const { activeMonthDate } = issueCalendarView.calendarFilters;
const getWeekLayoutHeader = (): string => {
const allDaysOfActiveWeek = issueCalendarView.allDaysOfActiveWeek;
if (!allDaysOfActiveWeek) return "Week view";
const daysList = Object.keys(allDaysOfActiveWeek);
const firstDay = getDate(daysList[0]);
const lastDay = getDate(daysList[daysList.length - 1]);
if (!firstDay || !lastDay) return "Week view";
if (firstDay.getMonth() === lastDay.getMonth() && firstDay.getFullYear() === lastDay.getFullYear())
return `${MONTHS_LIST[firstDay.getMonth() + 1].title} ${firstDay.getFullYear()}`;
if (firstDay.getFullYear() !== lastDay.getFullYear()) {
return `${MONTHS_LIST[firstDay.getMonth() + 1].shortTitle} ${firstDay.getFullYear()} - ${
MONTHS_LIST[lastDay.getMonth() + 1].shortTitle
} ${lastDay.getFullYear()}`;
} else
return `${MONTHS_LIST[firstDay.getMonth() + 1].shortTitle} - ${
MONTHS_LIST[lastDay.getMonth() + 1].shortTitle
} ${lastDay.getFullYear()}`;
};
const handleDateChange = (date: Date) => {
issueCalendarView.updateCalendarFilters({
activeMonthDate: date,
});
};
return (
<Popover className="relative">
<Popover.Button as={React.Fragment}>
<button
type="button"
ref={setReferenceElement}
className="text-xl font-semibold outline-none"
disabled={calendarLayout === "week"}
>
{calendarLayout === "month"
? `${MONTHS_LIST[activeMonthDate.getMonth() + 1].title} ${activeMonthDate.getFullYear()}`
: getWeekLayoutHeader()}
</button>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="fixed z-50">
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className="w-56 divide-y divide-custom-border-200 rounded border border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg"
>
<div className="flex items-center justify-between gap-2 pb-3">
<button
type="button"
className="grid place-items-center"
onClick={() => {
const previousYear = new Date(activeMonthDate.getFullYear() - 1, activeMonthDate.getMonth(), 1);
handleDateChange(previousYear);
}}
>
<ChevronLeft size={14} />
</button>
<span className="text-xs">{activeMonthDate.getFullYear()}</span>
<button
type="button"
className="grid place-items-center"
onClick={() => {
const nextYear = new Date(activeMonthDate.getFullYear() + 1, activeMonthDate.getMonth(), 1);
handleDateChange(nextYear);
}}
>
<ChevronRight size={14} />
</button>
</div>
<div className="grid grid-cols-4 items-stretch justify-items-stretch gap-4 pt-3">
{Object.values(MONTHS_LIST).map((month, index) => (
<button
key={month.shortTitle}
type="button"
className="rounded py-0.5 text-xs hover:bg-custom-background-80"
onClick={() => {
const newDate = new Date(activeMonthDate.getFullYear(), index, 1);
handleDateChange(newDate);
}}
>
{month.shortTitle}
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
});

View File

@@ -0,0 +1,173 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
import { Check, ChevronUp, MoreVerticalIcon } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
// hooks
// ui
// icons
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
import { EIssueFilterType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TCalendarLayouts, TSupportedFilterForUpdate } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// types
// constants
import { CALENDAR_LAYOUTS } from "@/constants/calendar";
import { useCalendarView } from "@/hooks/store/use-calendar-view";
import useSize from "@/hooks/use-window-size";
import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic";
import type { ICycleIssuesFilter } from "@/store/issue/cycle";
import type { IModuleIssuesFilter } from "@/store/issue/module";
import type { IProjectIssuesFilter } from "@/store/issue/project";
import type { IProjectViewIssuesFilter } from "@/store/issue/project-views";
interface ICalendarHeader {
issuesFilterStore:
| IProjectIssuesFilter
| IModuleIssuesFilter
| ICycleIssuesFilter
| IProjectViewIssuesFilter
| IProjectEpicsFilter;
updateFilters?: (
projectId: string,
filterType: TSupportedFilterTypeForUpdate,
filters: TSupportedFilterForUpdate
) => Promise<void>;
}
export const CalendarOptionsDropdown: React.FC<ICalendarHeader> = observer((props) => {
const { issuesFilterStore, updateFilters } = props;
const { t } = useTranslation();
const { projectId } = useParams();
const issueCalendarView = useCalendarView();
const [windowWidth] = useSize();
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "auto",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const calendarLayout = issuesFilterStore.issueFilters?.displayFilters?.calendar?.layout ?? "month";
const showWeekends = issuesFilterStore.issueFilters?.displayFilters?.calendar?.show_weekends ?? false;
const handleLayoutChange = (layout: TCalendarLayouts, closePopover: any) => {
if (!updateFilters) return;
updateFilters(projectId?.toString(), EIssueFilterType.DISPLAY_FILTERS, {
calendar: {
...issuesFilterStore.issueFilters?.displayFilters?.calendar,
layout,
},
});
issueCalendarView.updateCalendarPayload(
layout === "month"
? issueCalendarView.calendarFilters.activeMonthDate
: issueCalendarView.calendarFilters.activeWeekDate
);
if (windowWidth <= 768) closePopover(); // close the popover on mobile
};
const handleToggleWeekends = () => {
const showWeekends = issuesFilterStore.issueFilters?.displayFilters?.calendar?.show_weekends ?? false;
if (!updateFilters) return;
updateFilters(projectId?.toString(), EIssueFilterType.DISPLAY_FILTERS, {
calendar: {
...issuesFilterStore.issueFilters?.displayFilters?.calendar,
show_weekends: !showWeekends,
},
});
};
return (
<Popover className="relative flex items-center">
{({ open, close: closePopover }) => (
<>
<Popover.Button as={React.Fragment}>
<button type="button" ref={setReferenceElement}>
<div
className={`hidden md:flex items-center gap-1.5 rounded bg-custom-background-80 px-2.5 py-1 text-xs outline-none hover:bg-custom-background-80 ${
open ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<div className="font-medium">{t("common.options")}</div>
<div
className={`flex h-3.5 w-3.5 items-center justify-center transition-all ${open ? "" : "rotate-180"}`}
>
<ChevronUp width={12} strokeWidth={2} />
</div>
</div>
<div className="md:hidden">
<MoreVerticalIcon className="h-4 text-custom-text-200" strokeWidth={2} />
</div>
</button>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="fixed z-50">
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className="absolute right-0 z-10 mt-1 min-w-[12rem] overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 p-1 shadow-custom-shadow-sm"
>
<div>
{Object.entries(CALENDAR_LAYOUTS).map(([layout, layoutDetails]) => (
<button
key={layout}
type="button"
className="flex w-full items-center justify-between gap-2 rounded px-1 py-1.5 text-left text-xs hover:bg-custom-background-80"
onClick={() => handleLayoutChange(layoutDetails.key, closePopover)}
>
{layoutDetails.title}
{calendarLayout === layout && <Check size={12} strokeWidth={2} />}
</button>
))}
<button
type="button"
className="flex w-full items-center justify-between gap-2 rounded px-1 py-1.5 text-left text-xs hover:bg-custom-background-80"
onClick={handleToggleWeekends}
>
{t("common.actions.show_weekends")}
<ToggleSwitch
value={showWeekends}
onChange={() => {
if (windowWidth <= 768) closePopover(); // close the popover on mobile
}}
/>
</button>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
});

View File

@@ -0,0 +1,126 @@
import { observer } from "mobx-react";
// components
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TSupportedFilterForUpdate } from "@plane/types";
import { Row } from "@plane/ui";
// icons
import { useCalendarView } from "@/hooks/store/use-calendar-view";
import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic";
import type { ICycleIssuesFilter } from "@/store/issue/cycle";
import type { IModuleIssuesFilter } from "@/store/issue/module";
import type { IProjectIssuesFilter } from "@/store/issue/project";
import type { IProjectViewIssuesFilter } from "@/store/issue/project-views";
import { CalendarMonthsDropdown, CalendarOptionsDropdown } from "./dropdowns";
interface ICalendarHeader {
issuesFilterStore:
| IProjectIssuesFilter
| IModuleIssuesFilter
| ICycleIssuesFilter
| IProjectViewIssuesFilter
| IProjectEpicsFilter;
updateFilters?: (
projectId: string,
filterType: TSupportedFilterTypeForUpdate,
filters: TSupportedFilterForUpdate
) => Promise<void>;
setSelectedDate: (date: Date) => void;
}
export const CalendarHeader: React.FC<ICalendarHeader> = observer((props) => {
const { issuesFilterStore, updateFilters, setSelectedDate } = props;
const { t } = useTranslation();
const issueCalendarView = useCalendarView();
const calendarLayout = issuesFilterStore.issueFilters?.displayFilters?.calendar?.layout ?? "month";
const { activeMonthDate, activeWeekDate } = issueCalendarView.calendarFilters;
const handlePrevious = () => {
if (calendarLayout === "month") {
const previousMonthYear =
activeMonthDate.getMonth() === 0 ? activeMonthDate.getFullYear() - 1 : activeMonthDate.getFullYear();
const previousMonthMonth = activeMonthDate.getMonth() === 0 ? 11 : activeMonthDate.getMonth() - 1;
const previousMonthFirstDate = new Date(previousMonthYear, previousMonthMonth, 1);
issueCalendarView.updateCalendarFilters({
activeMonthDate: previousMonthFirstDate,
});
} else {
const previousWeekDate = new Date(
activeWeekDate.getFullYear(),
activeWeekDate.getMonth(),
activeWeekDate.getDate() - 7
);
issueCalendarView.updateCalendarFilters({
activeWeekDate: previousWeekDate,
});
}
};
const handleNext = () => {
if (calendarLayout === "month") {
const nextMonthYear =
activeMonthDate.getMonth() === 11 ? activeMonthDate.getFullYear() + 1 : activeMonthDate.getFullYear();
const nextMonthMonth = (activeMonthDate.getMonth() + 1) % 12;
const nextMonthFirstDate = new Date(nextMonthYear, nextMonthMonth, 1);
issueCalendarView.updateCalendarFilters({
activeMonthDate: nextMonthFirstDate,
});
} else {
const nextWeekDate = new Date(
activeWeekDate.getFullYear(),
activeWeekDate.getMonth(),
activeWeekDate.getDate() + 7
);
issueCalendarView.updateCalendarFilters({
activeWeekDate: nextWeekDate,
});
}
};
const handleToday = () => {
const today = new Date();
const firstDayOfCurrentMonth = new Date(today.getFullYear(), today.getMonth(), 1);
issueCalendarView.updateCalendarFilters({
activeMonthDate: firstDayOfCurrentMonth,
activeWeekDate: today,
});
setSelectedDate(today);
};
return (
<Row className="mb-4 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<button type="button" className="grid place-items-center" onClick={handlePrevious}>
<ChevronLeft size={16} strokeWidth={2} />
</button>
<button type="button" className="grid place-items-center" onClick={handleNext}>
<ChevronRight size={16} strokeWidth={2} />
</button>
<CalendarMonthsDropdown issuesFilterStore={issuesFilterStore} />
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
className="rounded bg-custom-background-80 px-2.5 py-1 text-xs font-medium text-custom-text-200 hover:text-custom-text-100"
onClick={handleToday}
>
{t("common.today")}
</button>
<CalendarOptionsDropdown issuesFilterStore={issuesFilterStore} updateFilters={updateFilters} />
</div>
</Row>
);
});

View File

@@ -0,0 +1,71 @@
import React, { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import type { TRenderQuickActions } from "../list/list-view-types";
import { HIGHLIGHT_CLASS } from "../utils";
import { CalendarIssueBlock } from "./issue-block";
// types
type Props = {
issueId: string;
quickActions: TRenderQuickActions;
isDragDisabled: boolean;
isEpic?: boolean;
canEditProperties: (projectId: string | undefined) => boolean;
};
export const CalendarIssueBlockRoot: React.FC<Props> = observer((props) => {
const { issueId, quickActions, isDragDisabled, isEpic = false, canEditProperties } = props;
const issueRef = useRef<HTMLAnchorElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
const {
issue: { getIssueById },
} = useIssueDetail();
const issue = getIssueById(issueId);
const canDrag = !isDragDisabled && canEditProperties(issue?.project_id ?? undefined);
useEffect(() => {
const element = issueRef.current;
if (!element) return;
return combine(
draggable({
element,
canDrag: () => canDrag,
getInitialData: () => ({ id: issue?.id, date: issue?.target_date }),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
})
);
}, [issueRef?.current, issue, canDrag]);
useOutsideClickDetector(issueRef, () => {
issueRef?.current?.classList?.remove(HIGHLIGHT_CLASS);
});
if (!issue) return null;
return (
<CalendarIssueBlock
isDragging={isDragging}
issue={issue}
quickActions={quickActions}
ref={issueRef}
isEpic={isEpic}
/>
);
});

View File

@@ -0,0 +1,159 @@
"use client";
/* eslint-disable react/display-name */
import { useState, useRef, forwardRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { MoreHorizontal } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// types
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssue } from "@plane/types";
// ui
import { ControlLink } from "@plane/ui";
import { cn, generateWorkItemLink } from "@plane/utils";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
// local components
import type { TRenderQuickActions } from "../list/list-view-types";
import type { CalendarStoreType } from "./base-calendar-root";
type Props = {
issue: TIssue;
quickActions: TRenderQuickActions;
isDragging?: boolean;
isEpic?: boolean;
};
export const CalendarIssueBlock = observer(
forwardRef<HTMLAnchorElement, Props>((props, ref) => {
const { issue, quickActions, isDragging = false, isEpic = false } = props;
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// refs
const blockRef = useRef(null);
const menuActionRef = useRef<HTMLDivElement | null>(null);
// hooks
const { workspaceSlug } = useParams();
const { getProjectStates } = useProjectState();
const { getIsIssuePeeked } = useIssueDetail();
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
const { isMobile } = usePlatformOS();
const storeType = useIssueStoreType() as CalendarStoreType;
const { issuesFilter } = useIssues(storeType);
const { getProjectIdentifierById } = useProject();
const stateColor = getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || "";
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// handlers
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug.toString(), issue, isMobile);
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
const customActionButton = (
<div
ref={menuActionRef}
className={`w-full cursor-pointer rounded p-1 text-custom-sidebar-text-400 hover:bg-custom-background-80 ${
isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"
}`}
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
);
const isMenuActionRefAboveScreenBottom =
menuActionRef?.current && menuActionRef?.current?.getBoundingClientRect().bottom < window.innerHeight - 220;
const placement = isMenuActionRefAboveScreenBottom ? "bottom-end" : "top-end";
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!issue?.archived_at,
});
return (
<ControlLink
id={`issue-${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="block w-full text-sm text-custom-text-100 rounded border-b md:border-[1px] border-custom-border-200 hover:border-custom-border-400"
disabled={!!issue?.tempId || isMobile}
ref={ref}
>
<>
{issue?.tempId !== undefined && (
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
)}
<div
ref={blockRef}
className={cn(
"group/calendar-block flex h-10 md:h-8 w-full items-center justify-between gap-1.5 rounded md:px-1 px-4 py-1.5 ",
{
"bg-custom-background-90 shadow-custom-shadow-rg border-custom-primary-100": isDragging,
"bg-custom-background-100 hover:bg-custom-background-90": !isDragging,
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
}
)}
>
<div className="flex h-full items-center gap-1.5 truncate">
<span
className="h-full w-0.5 flex-shrink-0 rounded"
style={{
backgroundColor: stateColor,
}}
/>
{issue.project_id && (
<IssueIdentifier
issueId={issue.id}
projectId={issue.project_id}
textContainerClassName="text-sm md:text-xs text-custom-text-300"
displayProperties={issuesFilter?.issueFilters?.displayProperties}
/>
)}
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<div className="truncate text-sm font-medium md:font-normal md:text-xs">{issue.name}</div>
</Tooltip>
</div>
<div
className={cn("flex-shrink-0 size-5", {
"hidden group-hover/calendar-block:block": !isMobile,
block: isMenuActive,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{quickActions({
issue,
parentRef: blockRef,
customActionButton,
placement,
})}
</div>
</div>
</>
</ControlLink>
);
})
);
CalendarIssueBlock.displayName = "CalendarIssueBlock";

View File

@@ -0,0 +1,111 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TIssue, TPaginationData } from "@plane/types";
// components
import { renderFormattedPayloadDate } from "@plane/utils";
// helpers
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import type { TRenderQuickActions } from "../list/list-view-types";
import { CalendarIssueBlockRoot } from "./issue-block-root";
import { CalendarQuickAddIssueActions } from "./quick-add-issue-actions";
// types
type Props = {
date: Date;
loadMoreIssues: (dateString: string) => void;
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
issueIdList: string[];
quickActions: TRenderQuickActions;
isDragDisabled?: boolean;
enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
readOnly?: boolean;
isMobileView?: boolean;
canEditProperties: (projectId: string | undefined) => boolean;
isEpic?: boolean;
};
export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
const {
date,
issueIdList,
quickActions,
loadMoreIssues,
isDragDisabled = false,
enableQuickIssueCreate,
disableIssueCreation,
quickAddCallback,
addIssuesToView,
readOnly,
isMobileView = false,
canEditProperties,
isEpic = false,
} = props;
const formattedDatePayload = renderFormattedPayloadDate(date);
const { t } = useTranslation();
const {
issues: { getGroupIssueCount, getPaginationData, getIssueLoader },
} = useIssuesStore();
if (!formattedDatePayload) return null;
const dayIssueCount = getGroupIssueCount(formattedDatePayload, undefined, false);
const nextPageResults = getPaginationData(formattedDatePayload, undefined)?.nextPageResults;
const isPaginating = !!getIssueLoader(formattedDatePayload);
const shouldLoadMore =
nextPageResults === undefined && dayIssueCount !== undefined
? issueIdList?.length < dayIssueCount
: !!nextPageResults;
return (
<>
{issueIdList?.map((issueId) => (
<div key={issueId} className="relative cursor-pointer p-1 px-2">
<CalendarIssueBlockRoot
issueId={issueId}
quickActions={quickActions}
isDragDisabled={isDragDisabled || isMobileView}
canEditProperties={canEditProperties}
isEpic={isEpic}
/>
</div>
))}
{isPaginating && (
<div className="p-1 px-2">
<div className="flex h-10 md:h-8 w-full items-center justify-between gap-1.5 rounded md:px-1 px-4 py-1.5 bg-custom-background-80 animate-pulse" />
</div>
)}
{enableQuickIssueCreate && !disableIssueCreation && !readOnly && (
<div className="border-b border-custom-border-200 px-1 py-1 md:border-none md:px-2">
<CalendarQuickAddIssueActions
prePopulatedData={{
target_date: formattedDatePayload,
}}
quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView}
isEpic={isEpic}
/>
</div>
)}
{shouldLoadMore && !isPaginating && (
<div className="flex items-center px-2.5 py-1">
<button
type="button"
className="w-min whitespace-nowrap rounded text-xs px-1.5 py-1 font-medium hover:bg-custom-background-80 text-custom-primary-100 hover:text-custom-primary-200"
onClick={() => loadMoreIssues(formattedDatePayload)}
>
{t("common.load_more")}
</button>
</div>
)}
</>
);
});

View File

@@ -0,0 +1,142 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { PlusIcon } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { setPromiseToast } from "@plane/propel/toast";
import type { ISearchIssueResponse, TIssue } from "@plane/types";
import { EIssueLayoutTypes } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { QuickAddIssueRoot } from "../quick-add";
type TCalendarQuickAddIssueActions = {
prePopulatedData?: Partial<TIssue>;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
onOpen?: () => void;
isEpic?: boolean;
};
export const CalendarQuickAddIssueActions: FC<TCalendarQuickAddIssueActions> = observer((props) => {
const { prePopulatedData, quickAddCallback, addIssuesToView, onOpen, isEpic = false } = props;
const { t } = useTranslation();
// router
const { workspaceSlug, projectId, moduleId } = useParams();
// states
const [isOpen, setIsOpen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isExistingIssueModalOpen, setIsExistingIssueModalOpen] = useState(false);
const { updateIssue } = useIssueDetail();
// derived values
const ExistingIssuesListModalPayload = addIssuesToView
? moduleId
? { module: moduleId.toString(), target_date: "none" }
: { cycle: true, target_date: "none" }
: { target_date: "none" };
const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return;
const issueIds = data.map((i) => i.id);
const addExistingIssuesPromise = Promise.all(
data.map((issue) => updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, prePopulatedData ?? {}))
).then(() => addIssuesToView?.(issueIds));
setPromiseToast(addExistingIssuesPromise, {
loading: t("issue.adding", { count: issueIds.length }),
success: {
title: t("toast.success"),
message: () => t("entity.add.success", { entity: t("issue.label", { count: 2 }) }),
},
error: {
title: t("toast.error"),
message: (err) => err?.message || t("common.errors.default.message"),
},
});
};
const handleNewIssue = () => {
setIsOpen(true);
if (onOpen) onOpen();
};
const handleExistingIssue = () => {
setIsExistingIssueModalOpen(true);
};
if (!projectId) return null;
return (
<>
{workspaceSlug && projectId && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isOpen={isExistingIssueModalOpen}
handleClose={() => setIsExistingIssueModalOpen(false)}
searchParams={ExistingIssuesListModalPayload}
handleOnSubmit={handleAddIssuesToView}
shouldHideIssue={(issue) => {
if (issue.start_date && prePopulatedData?.target_date) {
const issueStartDate = new Date(issue.start_date);
const targetDate = new Date(prePopulatedData.target_date);
const diffInDays = differenceInCalendarDays(targetDate, issueStartDate);
if (diffInDays < 0) return true;
}
return false;
}}
/>
)}
<QuickAddIssueRoot
isQuickAddOpen={isOpen}
setIsQuickAddOpen={(isOpen) => setIsOpen(isOpen)}
layout={EIssueLayoutTypes.CALENDAR}
prePopulatedData={prePopulatedData}
quickAddCallback={quickAddCallback}
customQuickAddButton={
<div
className={cn(
"md:opacity-0 rounded md:border-[0.5px] border-custom-border-200 md:group-hover:opacity-100",
{
block: isMenuOpen,
}
)}
>
<CustomMenu
placement="bottom-start"
menuButtonOnClick={() => setIsMenuOpen(true)}
onMenuClose={() => setIsMenuOpen(false)}
className="w-full"
customButtonClassName="w-full"
customButton={
<div className="flex w-full items-center gap-x-[6px] rounded-md px-2 py-1.5 text-custom-text-350 hover:text-custom-text-300">
<PlusIcon className="h-3.5 w-3.5 stroke-2 flex-shrink-0" />
<span className="text-sm font-medium flex-shrink-0">
{isEpic ? t("epic.add.label") : t("issue.add.label")}
</span>
</div>
}
>
<CustomMenu.MenuItem onClick={handleNewIssue}>
{isEpic ? t("epic.add.label") : t("issue.add.label")}
</CustomMenu.MenuItem>
{!isEpic && (
<CustomMenu.MenuItem onClick={handleExistingIssue}>{t("issue.add.existing")}</CustomMenu.MenuItem>
)}
</CustomMenu>
</div>
}
isEpic={isEpic}
/>
</>
);
});

View File

@@ -0,0 +1,39 @@
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EIssuesStoreType } from "@plane/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
// components
import { CycleIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseCalendarRoot } from "../base-calendar-root";
export const CycleCalendarLayout: React.FC = observer(() => {
const { currentProjectCompletedCycleIds } = useCycle();
const { workspaceSlug, projectId, cycleId } = useParams();
const { issues } = useIssues(EIssuesStoreType.CYCLE);
if (!cycleId) return null;
const isCompletedCycle =
cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false;
const addIssuesToView = useCallback(
(issueIds: string[]) => {
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
},
[issues?.addIssueToCycle, workspaceSlug, projectId, cycleId]
);
return (
<BaseCalendarRoot
QuickActions={CycleIssueQuickActions}
addIssuesToView={addIssuesToView}
isCompletedCycle={isCompletedCycle}
viewId={cycleId?.toString()}
/>
);
});

View File

@@ -0,0 +1,34 @@
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssuesStoreType } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
// local imports
import { ModuleIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseCalendarRoot } from "../base-calendar-root";
export const ModuleCalendarLayout: React.FC = observer(() => {
const { workspaceSlug, projectId, moduleId } = useParams();
const { issues } = useIssues(EIssuesStoreType.MODULE);
if (!moduleId) return null;
const addIssuesToView = useCallback(
(issueIds: string[]) => {
if (!workspaceSlug || !projectId || !moduleId) throw new Error();
return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds);
},
[issues?.addIssuesToModule, workspaceSlug, projectId, moduleId]
);
return (
<BaseCalendarRoot
QuickActions={ModuleIssueQuickActions}
addIssuesToView={addIssuesToView}
viewId={moduleId?.toString()}
/>
);
});

View File

@@ -0,0 +1,31 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { ProjectIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseCalendarRoot } from "../base-calendar-root";
export const CalendarLayout: React.FC = observer(() => {
// router
const { workspaceSlug } = useParams();
// hooks
const { allowPermissions } = useUserPermissions();
// derived values
const canEditPropertiesBasedOnProject = (projectId: string) =>
allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString(),
projectId
);
return (
<BaseCalendarRoot
QuickActions={ProjectIssueQuickActions}
canEditPropertiesBasedOnProject={canEditPropertiesBasedOnProject}
/>
);
});

View File

@@ -0,0 +1,11 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// local imports
import { ProjectIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseCalendarRoot } from "../base-calendar-root";
export const ProjectViewCalendarLayout: React.FC = observer(() => {
const { viewId } = useParams();
return <BaseCalendarRoot QuickActions={ProjectIssueQuickActions} viewId={viewId.toString()} />;
});

View File

@@ -0,0 +1,21 @@
import type { TIssue } from "@plane/types";
export const handleDragDrop = async (
issueId: string,
sourceDate: string,
destinationDate: string,
workspaceSlug: string | undefined,
projectId: string | undefined,
updateIssue?: (projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>
) => {
if (!workspaceSlug || !projectId || !updateIssue) return;
if (sourceDate === destinationDate) return;
const updatedIssue = {
id: issueId,
target_date: destinationDate,
};
return await updateIssue(projectId, updatedIssue.id, updatedIssue);
};

View File

@@ -0,0 +1,122 @@
import { observer } from "mobx-react";
// plane imports
import type { TGroupedIssues, TIssue, TIssueMap, TPaginationData, ICalendarDate, ICalendarWeek } from "@plane/types";
import { cn, getOrderedDays, renderFormattedPayloadDate } from "@plane/utils";
// hooks
import { useUserProfile } from "@/hooks/store/user";
// types
import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic";
import type { ICycleIssuesFilter } from "@/store/issue/cycle";
import type { IModuleIssuesFilter } from "@/store/issue/module";
import type { IProjectIssuesFilter } from "@/store/issue/project";
import type { IProjectViewIssuesFilter } from "@/store/issue/project-views";
import type { TRenderQuickActions } from "../list/list-view-types";
import { CalendarDayTile } from "./day-tile";
type Props = {
issuesFilterStore:
| IProjectIssuesFilter
| IModuleIssuesFilter
| ICycleIssuesFilter
| IProjectViewIssuesFilter
| IProjectEpicsFilter;
issues: TIssueMap | undefined;
groupedIssueIds: TGroupedIssues;
week: ICalendarWeek | undefined;
quickActions: TRenderQuickActions;
loadMoreIssues: (dateString: string) => void;
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
handleDragAndDrop: (
issueId: string | undefined,
issueProjectId: string | undefined,
sourceDate: string | undefined,
destinationDate: string | undefined
) => Promise<void>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
readOnly?: boolean;
selectedDate: Date;
setSelectedDate: (date: Date) => void;
canEditProperties: (projectId: string | undefined) => boolean;
isEpic?: boolean;
};
export const CalendarWeekDays: React.FC<Props> = observer((props) => {
const {
issuesFilterStore,
issues,
groupedIssueIds,
handleDragAndDrop,
week,
loadMoreIssues,
getPaginationData,
getGroupIssueCount,
quickActions,
enableQuickIssueCreate,
disableIssueCreation,
quickAddCallback,
addIssuesToView,
readOnly = false,
selectedDate,
setSelectedDate,
canEditProperties,
isEpic = false,
} = props;
// hooks
const { data } = useUserProfile();
const startOfWeek = data?.start_of_the_week;
const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month";
const showWeekends = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.show_weekends ?? false;
if (!week) return null;
const shouldShowDay = (dayDate: Date) => {
if (showWeekends) return true;
const day = dayDate.getDay();
return !(day === 0 || day === 6);
};
const sortedWeekDays = getOrderedDays(Object.values(week), (item) => item.date.getDay(), startOfWeek);
return (
<div
className={cn("grid divide-custom-border-200 md:divide-x-[0.5px]", {
"grid-cols-7": showWeekends,
"grid-cols-5": !showWeekends,
"h-full": calendarLayout !== "month",
})}
>
{sortedWeekDays.map((date: ICalendarDate) => {
if (!shouldShowDay(date.date)) return null;
return (
<CalendarDayTile
selectedDate={selectedDate}
setSelectedDate={setSelectedDate}
issuesFilterStore={issuesFilterStore}
key={renderFormattedPayloadDate(date.date)}
date={date}
issues={issues}
groupedIssueIds={groupedIssueIds}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
quickActions={quickActions}
enableQuickIssueCreate={enableQuickIssueCreate}
disableIssueCreation={disableIssueCreation}
quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView}
readOnly={readOnly}
handleDragAndDrop={handleDragAndDrop}
canEditProperties={canEditProperties}
isEpic={isEpic}
/>
);
})}
</div>
);
});

View File

@@ -0,0 +1,47 @@
import { observer } from "mobx-react";
import { EStartOfTheWeek } from "@plane/types";
import { getOrderedDays } from "@plane/utils";
import { DAYS_LIST } from "@/constants/calendar";
// helpers
// hooks
import { useUserProfile } from "@/hooks/store/user";
type Props = {
isLoading: boolean;
showWeekends: boolean;
};
export const CalendarWeekHeader: React.FC<Props> = observer((props) => {
const { isLoading, showWeekends } = props;
// hooks
const { data } = useUserProfile();
const startOfWeek = data?.start_of_the_week;
// derived
const orderedDays = getOrderedDays(Object.values(DAYS_LIST), (item) => item.value, startOfWeek);
return (
<div
className={`relative sticky top-0 z-[1] grid md:divide-x-[0.5px] divide-custom-border-200 text-sm font-medium ${
showWeekends ? "grid-cols-7" : "grid-cols-5"
}`}
>
{isLoading && (
<div className="absolute h-[1.5px] w-3/4 animate-[bar-loader_2s_linear_infinite] bg-custom-primary-100" />
)}
{orderedDays.map((day) => {
if (!showWeekends && (day.value === EStartOfTheWeek.SUNDAY || day.value === EStartOfTheWeek.SATURDAY))
return null;
return (
<div
key={day.shortTitle}
className="flex h-11 items-center justify-center md:justify-end bg-custom-background-90 px-4"
>
{day.shortTitle}
</div>
);
})}
</div>
);
});

View File

@@ -0,0 +1,71 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType, EUserProjectRoles } from "@plane/types";
// components
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
import { useAppRouter } from "@/hooks/use-app-router";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const ProjectArchivedEmptyState: React.FC = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
// plane hooks
const { t } = useTranslation();
// store hooks
const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
const { allowPermissions } = useUserPermissions();
// derived values
const archivedWorkItemFilter = projectId
? useWorkItemFilterInstance(EIssuesStoreType.ARCHIVED, projectId)
: undefined;
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
const additionalPath = archivedWorkItemFilter?.hasActiveFilters ? (activeLayout ?? "list") : undefined;
const canPerformEmptyStateActions = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT
);
const emptyFilterResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/empty-filters/",
additionalPath: additionalPath,
});
const archivedIssuesResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/archived/empty-issues",
});
return (
<div className="relative h-full w-full overflow-y-auto">
{archivedWorkItemFilter?.hasActiveFilters ? (
<DetailedEmptyState
title={t("project_issues.empty_state.issues_empty_filter.title")}
assetPath={emptyFilterResolvedPath}
secondaryButton={{
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
onClick: archivedWorkItemFilter?.clearFilters,
disabled: !canPerformEmptyStateActions || !archivedWorkItemFilter,
}}
/>
) : (
<DetailedEmptyState
title={t("project_issues.empty_state.no_archived_issues.title")}
description={t("project_issues.empty_state.no_archived_issues.description")}
assetPath={archivedIssuesResolvedPath}
primaryButton={{
text: t("project_issues.empty_state.no_archived_issues.primary_button.text"),
onClick: () => router.push(`/${workspaceSlug}/settings/projects/${projectId}/automations`),
disabled: !canPerformEmptyStateActions,
}}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,135 @@
"use client";
import { useState } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { ISearchIssueResponse } from "@plane/types";
import { EIssuesStoreType, EUserProjectRoles } from "@plane/types";
// components
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { captureClick } from "@/helpers/event-tracker.helper";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const CycleEmptyState: React.FC = observer(() => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, cycleId: routerCycleId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
const cycleId = routerCycleId ? routerCycleId.toString() : undefined;
// states
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
// plane hooks
const { t } = useTranslation();
// store hooks
const { getCycleById } = useCycle();
const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
// derived values
const cycleWorkItemFilter = cycleId ? useWorkItemFilterInstance(EIssuesStoreType.CYCLE, cycleId) : undefined;
const cycleDetails = cycleId ? getCycleById(cycleId) : undefined;
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
const isCompletedCycleSnapshotAvailable = !isEmpty(cycleDetails?.progress_snapshot ?? {});
const isCompletedAndEmpty = isCompletedCycleSnapshotAvailable || cycleDetails?.status?.toLowerCase() === "completed";
const additionalPath = activeLayout ?? "list";
const canPerformEmptyStateActions = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT
);
const emptyFilterResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/empty-filters/",
additionalPath: additionalPath,
});
const noIssueResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/cycle-issues/",
additionalPath: additionalPath,
});
const completedNoIssuesResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/cycle/completed-no-issues",
});
const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId || !cycleId) return;
const issueIds = data.map((i) => i.id);
await issues
.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Work items added to the cycle successfully.",
})
)
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Selected work items could not be added to the cycle. Please try again.",
})
);
};
return (
<div className="relative h-full w-full overflow-y-auto">
<ExistingIssuesListModal
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
isOpen={cycleIssuesListModal}
handleClose={() => setCycleIssuesListModal(false)}
searchParams={{ cycle: true }}
handleOnSubmit={handleAddIssuesToCycle}
/>
<div className="grid h-full w-full place-items-center">
{isCompletedAndEmpty ? (
<DetailedEmptyState
title={t("project_cycles.empty_state.completed_no_issues.title")}
description={t("project_cycles.empty_state.completed_no_issues.description")}
assetPath={completedNoIssuesResolvedPath}
/>
) : cycleWorkItemFilter?.hasActiveFilters ? (
<DetailedEmptyState
title={t("project_issues.empty_state.issues_empty_filter.title")}
assetPath={emptyFilterResolvedPath}
secondaryButton={{
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
onClick: cycleWorkItemFilter?.clearFilters,
disabled: !canPerformEmptyStateActions || !cycleWorkItemFilter,
}}
/>
) : (
<DetailedEmptyState
title={t("project_cycles.empty_state.no_issues.title")}
description={t("project_cycles.empty_state.no_issues.description")}
assetPath={noIssueResolvedPath}
primaryButton={{
text: t("project_cycles.empty_state.no_issues.primary_button.text"),
onClick: () => {
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.EMPTY_STATE_ADD_BUTTON.CYCLE });
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
},
disabled: !canPerformEmptyStateActions,
}}
secondaryButton={{
text: t("project_cycles.empty_state.no_issues.secondary_button.text"),
onClick: () => setCycleIssuesListModal(true),
disabled: !canPerformEmptyStateActions,
}}
/>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,82 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType, EUserWorkspaceRoles } from "@plane/types";
// components
import { ComicBoxButton } from "@/components/empty-state/comic-box-button";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const GlobalViewEmptyState: React.FC = observer(() => {
const { globalViewId } = useParams();
// plane imports
const { t } = useTranslation();
// store hooks
const { workspaceProjectIds } = useProject();
const { toggleCreateIssueModal, toggleCreateProjectModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
// derived values
const hasMemberLevelPermission = allowPermissions(
[EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const isDefaultView = ["all-issues", "assigned", "created", "subscribed"].includes(globalViewId?.toString() ?? "");
const currentView = isDefaultView && globalViewId ? globalViewId : "custom-view";
const resolvedCurrentView = currentView?.toString();
const noProjectResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/projects" });
const globalViewsResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/all-issues/",
additionalPath: resolvedCurrentView,
});
if (workspaceProjectIds?.length === 0) {
return (
<DetailedEmptyState
size="sm"
title={t("workspace_projects.empty_state.no_projects.title")}
description={t("workspace_projects.empty_state.no_projects.description")}
assetPath={noProjectResolvedPath}
customPrimaryButton={
<ComicBoxButton
label={t("workspace_projects.empty_state.no_projects.primary_button.text")}
title={t("workspace_projects.empty_state.no_projects.primary_button.comic.title")}
description={t("workspace_projects.empty_state.no_projects.primary_button.comic.description")}
onClick={() => {
toggleCreateProjectModal(true);
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.EMPTY_STATE_ADD_BUTTON.GLOBAL_VIEW });
}}
disabled={!hasMemberLevelPermission}
/>
}
/>
);
}
return (
<DetailedEmptyState
size="sm"
title={t(`workspace_views.empty_state.${resolvedCurrentView}.title`)}
description={t(`workspace_views.empty_state.${resolvedCurrentView}.description`)}
assetPath={globalViewsResolvedPath}
primaryButton={
["subscribed", "custom-view"].includes(resolvedCurrentView) === false
? {
text: t(`workspace_views.empty_state.${resolvedCurrentView}.primary_button.text`),
onClick: () => {
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.EMPTY_STATE_ADD_BUTTON.GLOBAL_VIEW });
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
},
disabled: !hasMemberLevelPermission,
}
: undefined
}
/>
);
});

View File

@@ -0,0 +1,47 @@
// plane web components
import { EIssuesStoreType } from "@plane/types";
import { TeamEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states/team-issues";
import { TeamProjectWorkItemEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states/team-project";
import { TeamViewEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states/team-view-issues";
// components
import { ProjectArchivedEmptyState } from "./archived-issues";
import { CycleEmptyState } from "./cycle";
import { GlobalViewEmptyState } from "./global-view";
import { ModuleEmptyState } from "./module";
import { ProfileViewEmptyState } from "./profile-view";
import { ProjectEpicsEmptyState } from "./project-epic";
import { ProjectEmptyState } from "./project-issues";
import { ProjectViewEmptyState } from "./project-view";
interface Props {
storeType: EIssuesStoreType;
}
export const IssueLayoutEmptyState = (props: Props) => {
switch (props.storeType) {
case EIssuesStoreType.PROJECT:
return <ProjectEmptyState />;
case EIssuesStoreType.PROJECT_VIEW:
return <ProjectViewEmptyState />;
case EIssuesStoreType.ARCHIVED:
return <ProjectArchivedEmptyState />;
case EIssuesStoreType.CYCLE:
return <CycleEmptyState />;
case EIssuesStoreType.MODULE:
return <ModuleEmptyState />;
case EIssuesStoreType.GLOBAL:
return <GlobalViewEmptyState />;
case EIssuesStoreType.PROFILE:
return <ProfileViewEmptyState />;
case EIssuesStoreType.EPIC:
return <ProjectEpicsEmptyState />;
case EIssuesStoreType.TEAM:
return <TeamEmptyState />;
case EIssuesStoreType.TEAM_VIEW:
return <TeamViewEmptyState />;
case EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS:
return <TeamProjectWorkItemEmptyState />;
default:
return null;
}
};

View File

@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { ISearchIssueResponse } from "@plane/types";
import { EIssuesStoreType, EUserProjectRoles } from "@plane/types";
// components
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const ModuleEmptyState: React.FC = observer(() => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, moduleId: routerModuleId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
const moduleId = routerModuleId ? routerModuleId.toString() : undefined;
// states
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
// plane hooks
const { t } = useTranslation();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE);
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
// derived values
const moduleWorkItemFilter = moduleId ? useWorkItemFilterInstance(EIssuesStoreType.MODULE, moduleId) : undefined;
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
const additionalPath = activeLayout ?? "list";
const canPerformEmptyStateActions = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT
);
const emptyFilterResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/empty-filters/",
additionalPath: additionalPath,
});
const moduleIssuesResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/module-issues/",
additionalPath: additionalPath,
});
const handleAddIssuesToModule = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId || !moduleId) return;
const issueIds = data.map((i) => i.id);
await issues
.addIssuesToModule(workspaceSlug.toString(), projectId?.toString(), moduleId.toString(), issueIds)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Work items added to the module successfully.",
})
)
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Selected work items could not be added to the module. Please try again.",
})
);
};
return (
<div className="relative h-full w-full overflow-y-auto">
<ExistingIssuesListModal
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
isOpen={moduleIssuesListModal}
handleClose={() => setModuleIssuesListModal(false)}
searchParams={{ module: moduleId != undefined ? moduleId.toString() : "" }}
handleOnSubmit={handleAddIssuesToModule}
/>
<div className="grid h-full w-full place-items-center">
{moduleWorkItemFilter?.hasActiveFilters ? (
<DetailedEmptyState
title={t("project_issues.empty_state.issues_empty_filter.title")}
assetPath={emptyFilterResolvedPath}
secondaryButton={{
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
onClick: moduleWorkItemFilter?.clearFilters,
disabled: !canPerformEmptyStateActions || !moduleWorkItemFilter,
}}
/>
) : (
<DetailedEmptyState
title={t("project_module.empty_state.no_issues.title")}
description={t("project_module.empty_state.no_issues.description")}
assetPath={moduleIssuesResolvedPath}
primaryButton={{
text: t("project_module.empty_state.no_issues.primary_button.text"),
onClick: () => {
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.EMPTY_STATE_ADD_BUTTON.MODULE });
toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
},
disabled: !canPerformEmptyStateActions,
}}
secondaryButton={{
text: t("project_module.empty_state.no_issues.secondary_button.text"),
onClick: () => setModuleIssuesListModal(true),
disabled: !canPerformEmptyStateActions,
}}
/>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { useTranslation } from "@plane/i18n";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
// constants
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// TODO: If projectViewId changes, everything breaks. Figure out a better way to handle this.
export const ProfileViewEmptyState: React.FC = observer(() => {
// plane hooks
const { t } = useTranslation();
// store hooks
const { profileViewId } = useParams();
// derived values
const resolvedPath = useResolvedAssetPath({
basePath: "/empty-state/profile/",
additionalPath: profileViewId?.toString(),
});
if (!profileViewId) return null;
return (
<DetailedEmptyState
title={t(`profile.empty_state.${profileViewId.toString()}.title`)}
description={t(`profile.empty_state.${profileViewId.toString()}.description`)}
assetPath={resolvedPath}
/>
);
});

View File

@@ -0,0 +1 @@
export const ProjectEpicsEmptyState: React.FC = () => <></>;

View File

@@ -0,0 +1,77 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType, EUserProjectRoles } from "@plane/types";
// components
import { ComicBoxButton } from "@/components/empty-state/comic-box-button";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const ProjectEmptyState: React.FC = observer(() => {
// router
const { projectId: routerProjectId } = useParams();
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
// plane imports
const { t } = useTranslation();
// store hooks
const { toggleCreateIssueModal } = useCommandPalette();
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
const { allowPermissions } = useUserPermissions();
// derived values
const projectWorkItemFilter = projectId ? useWorkItemFilterInstance(EIssuesStoreType.PROJECT, projectId) : undefined;
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
const additionalPath = projectWorkItemFilter?.hasActiveFilters ? (activeLayout ?? "list") : undefined;
const canPerformEmptyStateActions = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT
);
const emptyFilterResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/empty-filters/",
additionalPath: additionalPath,
});
const projectIssuesResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/onboarding/issues",
});
return (
<div className="relative h-full w-full overflow-y-auto">
{projectWorkItemFilter?.hasActiveFilters ? (
<DetailedEmptyState
title={t("project_issues.empty_state.issues_empty_filter.title")}
assetPath={emptyFilterResolvedPath}
secondaryButton={{
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
onClick: projectWorkItemFilter?.clearFilters,
disabled: !canPerformEmptyStateActions || !projectWorkItemFilter,
}}
/>
) : (
<DetailedEmptyState
title={t("project_issues.empty_state.no_issues.title")}
description={t("project_issues.empty_state.no_issues.description")}
assetPath={projectIssuesResolvedPath}
customPrimaryButton={
<ComicBoxButton
label={t("project_issues.empty_state.no_issues.primary_button.text")}
title={t("project_issues.empty_state.no_issues.primary_button.comic.title")}
description={t("project_issues.empty_state.no_issues.primary_button.comic.description")}
onClick={() => {
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.EMPTY_STATE_ADD_BUTTON.WORK_ITEMS });
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
}}
disabled={!canPerformEmptyStateActions}
/>
}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,46 @@
import { observer } from "mobx-react";
import { PlusIcon } from "lucide-react";
// components
import { EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";
import { EmptyState } from "@/components/common/empty-state";
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUserPermissions } from "@/hooks/store/user";
// assets
import emptyIssue from "@/public/empty-state/issue.svg";
export const ProjectViewEmptyState: React.FC = observer(() => {
// store hooks
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
// auth
const isCreatingIssueAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<div className="grid h-full w-full place-items-center">
<EmptyState
title="View work items will appear here"
description="Work items help you track individual pieces of work. With work items, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={
isCreatingIssueAllowed
? {
text: "New work item",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.EMPTY_STATE_ADD_BUTTON.PROJECT_VIEW });
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT_VIEW);
},
}
: undefined
}
/>
</div>
);
});

View File

@@ -0,0 +1,50 @@
"use client";
import { observer } from "mobx-react";
import { X } from "lucide-react";
import { CycleGroupIcon } from "@plane/propel/icons";
import type { TCycleGroups } from "@plane/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
// ui
// types
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedCycleFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
// store hooks
const { getCycleById } = useCycle();
return (
<>
{values.map((cycleId) => {
const cycleDetails = getCycleById(cycleId) ?? null;
if (!cycleDetails) return null;
const cycleStatus = (cycleDetails?.status ? cycleDetails?.status.toLocaleLowerCase() : "draft") as TCycleGroups;
return (
<div key={cycleId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs truncate">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3 w-3 flex-shrink-0" />
<span className="normal-case truncate">{cycleDetails.name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(cycleId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,52 @@
import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
// helpers
import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants";
import { renderFormattedDate, capitalizeFirstLetter } from "@plane/utils";
// constants
type Props = {
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedDateFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values } = props;
const getDateLabel = (value: string): string => {
let dateLabel = "";
const dateDetails = DATE_AFTER_FILTER_OPTIONS.find((d) => d.value === value);
if (dateDetails) dateLabel = dateDetails.name;
else {
const dateParts = value.split(";");
if (dateParts.length === 2) {
const [date, time] = dateParts;
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
}
}
return dateLabel;
};
return (
<>
{values.map((date) => (
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<span className="normal-case">{getDateLabel(date)}</span>
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(date)}
>
<X size={10} strokeWidth={2} />
</button>
</div>
))}
</>
);
});

View File

@@ -0,0 +1,9 @@
export * from "./date";
export * from "./label";
export * from "./members";
export * from "./priority";
export * from "./project";
export * from "./module";
export * from "./cycle";
export * from "./state";
export * from "./state-group";

View File

@@ -0,0 +1,48 @@
import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
import type { IIssueLabel } from "@plane/types";
// types
type Props = {
handleRemove: (val: string) => void;
labels: IIssueLabel[] | undefined;
values: string[];
editable: boolean | undefined;
};
export const AppliedLabelsFilters: React.FC<Props> = observer((props) => {
const { handleRemove, labels, values, editable } = props;
return (
<>
{values.map((labelId) => {
const labelDetails = labels?.find((l) => l.id === labelId);
if (!labelDetails) return null;
return (
<div key={labelId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: labelDetails.color,
}}
/>
<span className="normal-case">{labelDetails.name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(labelId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,55 @@
"use client";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedMembersFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
const {
workspace: { getWorkspaceMemberDetails },
} = useMember();
return (
<>
{values.map((memberId) => {
const memberDetails = getWorkspaceMemberDetails(memberId)?.member;
if (!memberDetails) return null;
return (
<div key={memberId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<Avatar
name={memberDetails.display_name}
src={getFileURL(memberDetails.avatar_url)}
showTooltip={false}
size={"sm"}
/>
<span className="normal-case">{memberDetails.display_name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(memberId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,46 @@
"use client";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// hooks
import { ModuleIcon } from "@plane/propel/icons";
import { useModule } from "@/hooks/store/use-module";
// ui
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedModuleFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
// store hooks
const { getModuleById } = useModule();
return (
<>
{values.map((moduleId) => {
const moduleDetails = getModuleById(moduleId) ?? null;
if (!moduleDetails) return null;
return (
<div key={moduleId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs truncate">
<ModuleIcon className="h-3 w-3 flex-shrink-0" />
<span className="normal-case truncate">{moduleDetails.name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(moduleId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,39 @@
"use client";
import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
import { PriorityIcon } from "@plane/propel/icons";
import type { TIssuePriorities } from "@plane/types";
// types
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedPriorityFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
return (
<>
{values.map((priority) => (
<div key={priority} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<PriorityIcon priority={priority as TIssuePriorities} className={`h-3 w-3`} />
{priority}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(priority)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});

View File

@@ -0,0 +1,46 @@
import { observer } from "mobx-react";
import { X } from "lucide-react";
// components
import { Logo } from "@/components/common/logo";
// hooks
import { useProject } from "@/hooks/store/use-project";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedProjectFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
// store hooks
const { projectMap } = useProject();
return (
<>
{values.map((projectId) => {
const projectDetails = projectMap?.[projectId] ?? null;
if (!projectDetails) return null;
return (
<div key={projectId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={projectDetails.logo_props} size={12} />
</span>
<span className="normal-case">{projectDetails.name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(projectId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,36 @@
"use client";
import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/propel/icons";
import type { TStateGroups } from "@plane/types";
type Props = {
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedStateGroupFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values } = props;
return (
<>
{values.map((stateGroup) => (
<div key={stateGroup} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<StateGroupIcon stateGroup={stateGroup as TStateGroups} size={EIconSize.SM} />
{stateGroup}
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(stateGroup)}
>
<X size={10} strokeWidth={2} />
</button>
</div>
))}
</>
);
});

View File

@@ -0,0 +1,51 @@
"use client";
import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
// plane imports
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/propel/icons";
import type { IState } from "@plane/types";
type Props = {
handleRemove: (val: string) => void;
states: IState[];
values: string[];
editable: boolean | undefined;
};
export const AppliedStateFilters: React.FC<Props> = observer((props) => {
const { handleRemove, states, values, editable } = props;
return (
<>
{values.map((stateId) => {
const stateDetails = states?.find((s) => s.id === stateId);
if (!stateDetails) return null;
return (
<div key={stateId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<StateGroupIcon
color={stateDetails.color}
stateGroup={stateDetails.group}
size={EIconSize.SM}
percentage={stateDetails?.order}
/>
{stateDetails.name}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(stateId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,139 @@
import React from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import type {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
ILayoutDisplayFiltersOptions,
TIssueGroupByOptions,
} from "@plane/types";
// components
import {
FilterDisplayProperties,
FilterExtraOptions,
FilterGroupBy,
FilterOrderBy,
FilterSubGroupBy,
} from "@/components/issues/issue-layouts/filters";
type Props = {
displayFilters: IIssueDisplayFilterOptions | undefined;
displayProperties: IIssueDisplayProperties;
handleDisplayFiltersUpdate: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial<IIssueDisplayProperties>) => void;
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
ignoreGroupedFilters?: Partial<TIssueGroupByOptions>[];
cycleViewDisabled?: boolean;
moduleViewDisabled?: boolean;
isEpic?: boolean;
};
export const DisplayFiltersSelection: React.FC<Props> = observer((props) => {
const {
displayFilters,
displayProperties,
handleDisplayFiltersUpdate,
handleDisplayPropertiesUpdate,
layoutDisplayFiltersOptions,
ignoreGroupedFilters = [],
cycleViewDisabled = false,
moduleViewDisabled = false,
isEpic = false,
} = props;
const isDisplayFilterEnabled = (displayFilter: keyof IIssueDisplayFilterOptions) =>
Object.keys(layoutDisplayFiltersOptions?.display_filters ?? {}).includes(displayFilter);
const computedIgnoreGroupedFilters: Partial<TIssueGroupByOptions>[] = [];
if (cycleViewDisabled) {
ignoreGroupedFilters.push("cycle");
}
if (moduleViewDisabled) {
ignoreGroupedFilters.push("module");
}
return (
<div className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5">
{/* display properties */}
{layoutDisplayFiltersOptions?.display_properties && layoutDisplayFiltersOptions.display_properties.length > 0 && (
<div className="py-2">
<FilterDisplayProperties
displayProperties={displayProperties}
displayPropertiesToRender={layoutDisplayFiltersOptions.display_properties}
handleUpdate={handleDisplayPropertiesUpdate}
cycleViewDisabled={cycleViewDisabled}
moduleViewDisabled={moduleViewDisabled}
isEpic={isEpic}
/>
</div>
)}
{/* group by */}
{isDisplayFilterEnabled("group_by") && (
<div className="py-2">
<FilterGroupBy
displayFilters={displayFilters}
groupByOptions={layoutDisplayFiltersOptions?.display_filters.group_by ?? []}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
group_by: val,
})
}
ignoreGroupedFilters={[...ignoreGroupedFilters, ...computedIgnoreGroupedFilters]}
/>
</div>
)}
{/* sub-group by */}
{isDisplayFilterEnabled("sub_group_by") &&
displayFilters?.group_by !== null &&
displayFilters?.layout === "kanban" && (
<div className="py-2">
<FilterSubGroupBy
displayFilters={displayFilters}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
sub_group_by: val,
})
}
subGroupByOptions={layoutDisplayFiltersOptions?.display_filters.sub_group_by ?? []}
ignoreGroupedFilters={[...ignoreGroupedFilters, ...computedIgnoreGroupedFilters]}
/>
</div>
)}
{/* order by */}
{isDisplayFilterEnabled("order_by") && !isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
<div className="py-2">
<FilterOrderBy
selectedOrderBy={displayFilters?.order_by}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
order_by: val,
})
}
orderByOptions={layoutDisplayFiltersOptions?.display_filters.order_by ?? []}
/>
</div>
)}
{/* Options */}
{layoutDisplayFiltersOptions?.extra_options.access && (
<div className="py-2">
<FilterExtraOptions
selectedExtraOptions={{
show_empty_groups: displayFilters?.show_empty_groups ?? true,
sub_issue: displayFilters?.sub_issue ?? true,
}}
handleUpdate={(key, val) =>
handleDisplayFiltersUpdate({
[key]: val,
})
}
enabledExtraOptions={layoutDisplayFiltersOptions?.extra_options.values}
/>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,94 @@
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane constants
import { ISSUE_DISPLAY_PROPERTIES } from "@plane/constants";
// plane i18n
import { useTranslation } from "@plane/i18n";
// types
import type { IIssueDisplayProperties } from "@plane/types";
// plane web helpers
import { shouldRenderDisplayProperty } from "@/plane-web/helpers/issue-filter.helper";
// components
import { FilterHeader } from "../helpers/filter-header";
type Props = {
displayProperties: IIssueDisplayProperties;
displayPropertiesToRender: (keyof IIssueDisplayProperties)[];
handleUpdate: (updatedDisplayProperties: Partial<IIssueDisplayProperties>) => void;
cycleViewDisabled?: boolean;
moduleViewDisabled?: boolean;
isEpic?: boolean;
};
export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
const {
displayProperties,
displayPropertiesToRender,
handleUpdate,
cycleViewDisabled = false,
moduleViewDisabled = false,
isEpic = false,
} = props;
// hooks
const { t } = useTranslation();
// router
const { workspaceSlug, projectId: routerProjectId } = useParams();
// states
const [previewEnabled, setPreviewEnabled] = React.useState(true);
// derived values
const projectId = !!routerProjectId ? routerProjectId?.toString() : undefined;
// Filter out "cycle" and "module" keys if cycleViewDisabled or moduleViewDisabled is true
// Also filter out display properties that should not be rendered
const filteredDisplayProperties = ISSUE_DISPLAY_PROPERTIES.filter((property) => {
if (!displayPropertiesToRender.includes(property.key)) return false;
switch (property.key) {
case "cycle":
return !cycleViewDisabled;
case "modules":
return !moduleViewDisabled;
default:
return shouldRenderDisplayProperty({ workspaceSlug: workspaceSlug?.toString(), projectId, key: property.key });
}
}).map((property) => {
if (isEpic && property.key === "sub_issue_count") {
return { ...property, titleTranslationKey: "issue.display.properties.work_item_count" };
}
return property;
});
return (
<>
<FilterHeader
title={t("issue.display.properties.label")}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div className="mt-1 flex flex-wrap items-center gap-2">
{filteredDisplayProperties.map((displayProperty) => (
<>
<button
key={displayProperty.key}
type="button"
className={`rounded border px-2 py-0.5 text-xs transition-all ${
displayProperties?.[displayProperty.key]
? "border-custom-primary-100 bg-custom-primary-100 text-white"
: "border-custom-border-200 hover:bg-custom-background-80"
}`}
onClick={() =>
handleUpdate({
[displayProperty.key]: !displayProperties?.[displayProperty.key],
})
}
>
{t(displayProperty.titleTranslationKey)}
</button>
</>
))}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,54 @@
import React from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { IIssueDisplayFilterOptions, TIssueExtraOptions } from "@plane/types";
// components
import { FilterOption } from "@/components/issues/issue-layouts/filters";
// constants
const ISSUE_EXTRA_OPTIONS: {
key: TIssueExtraOptions;
titleTranslationKey: string;
}[] = [
{
key: "sub_issue",
titleTranslationKey: "issue.display.extra.show_sub_issues",
}, // in spreadsheet its always false
{
key: "show_empty_groups",
titleTranslationKey: "issue.display.extra.show_empty_groups",
}, // filter on front-end
];
type Props = {
selectedExtraOptions: {
sub_issue: boolean;
show_empty_groups: boolean;
};
handleUpdate: (key: keyof IIssueDisplayFilterOptions, val: boolean) => void;
enabledExtraOptions: TIssueExtraOptions[];
};
export const FilterExtraOptions: React.FC<Props> = observer((props) => {
const { selectedExtraOptions, handleUpdate, enabledExtraOptions } = props;
// hooks
const { t } = useTranslation();
const isExtraOptionEnabled = (option: TIssueExtraOptions) => enabledExtraOptions.includes(option);
return (
<div>
{ISSUE_EXTRA_OPTIONS.map((option) => {
if (!isExtraOptionEnabled(option.key)) return null;
return (
<FilterOption
key={option.key}
isChecked={selectedExtraOptions?.[option.key] ? true : false}
onClick={() => handleUpdate(option.key, !selectedExtraOptions?.[option.key])}
title={t(option.titleTranslationKey)}
/>
);
})}
</div>
);
});

View File

@@ -0,0 +1,57 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { ISSUE_GROUP_BY_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IIssueDisplayFilterOptions, TIssueGroupByOptions } from "@plane/types";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
type Props = {
displayFilters: IIssueDisplayFilterOptions | undefined;
groupByOptions: TIssueGroupByOptions[];
handleUpdate: (val: TIssueGroupByOptions) => void;
ignoreGroupedFilters: Partial<TIssueGroupByOptions>[];
};
export const FilterGroupBy: React.FC<Props> = observer((props) => {
const { displayFilters, groupByOptions, handleUpdate, ignoreGroupedFilters } = props;
// hooks
const { t } = useTranslation();
const [previewEnabled, setPreviewEnabled] = useState(true);
const selectedGroupBy = displayFilters?.group_by ?? null;
const selectedSubGroupBy = displayFilters?.sub_group_by ?? null;
return (
<>
<FilterHeader
title={t("common.group_by")}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{ISSUE_GROUP_BY_OPTIONS.filter((option) => groupByOptions.includes(option.key)).map((groupBy) => {
if (
displayFilters?.layout === "kanban" &&
selectedSubGroupBy !== null &&
groupBy.key === selectedSubGroupBy
)
return null;
if (ignoreGroupedFilters.includes(groupBy?.key)) return null;
return (
<FilterOption
key={groupBy?.key}
isChecked={selectedGroupBy === groupBy?.key ? true : false}
onClick={() => handleUpdate(groupBy.key)}
title={t(groupBy.titleTranslationKey)}
multiple={false}
/>
);
})}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,6 @@
export * from "./display-filters-selection";
export * from "./display-properties";
export * from "./extra-options";
export * from "./group-by";
export * from "./order-by";
export * from "./sub-group-by";

View File

@@ -0,0 +1,47 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { ISSUE_ORDER_BY_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TIssueOrderByOptions } from "@plane/types";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
type Props = {
selectedOrderBy: TIssueOrderByOptions | undefined;
handleUpdate: (val: TIssueOrderByOptions) => void;
orderByOptions: TIssueOrderByOptions[];
};
export const FilterOrderBy: React.FC<Props> = observer((props) => {
const { selectedOrderBy, handleUpdate, orderByOptions } = props;
// hooks
const { t } = useTranslation();
const [previewEnabled, setPreviewEnabled] = useState(true);
const activeOrderBy = selectedOrderBy ?? "-created_at";
return (
<>
<FilterHeader
title={t("common.order_by.label")}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{ISSUE_ORDER_BY_OPTIONS.filter((option) => orderByOptions.includes(option.key)).map((orderBy) => (
<FilterOption
key={orderBy?.key}
isChecked={activeOrderBy === orderBy?.key ? true : false}
onClick={() => handleUpdate(orderBy.key)}
title={t(orderBy.titleTranslationKey)}
multiple={false}
/>
))}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,55 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { ISSUE_GROUP_BY_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IIssueDisplayFilterOptions, TIssueGroupByOptions } from "@plane/types";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// constants
type Props = {
displayFilters: IIssueDisplayFilterOptions;
handleUpdate: (val: TIssueGroupByOptions) => void;
subGroupByOptions: TIssueGroupByOptions[];
ignoreGroupedFilters: Partial<TIssueGroupByOptions>[];
};
export const FilterSubGroupBy: React.FC<Props> = observer((props) => {
// hooks
const { t } = useTranslation();
const { displayFilters, handleUpdate, subGroupByOptions, ignoreGroupedFilters } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const selectedGroupBy = displayFilters.group_by ?? null;
const selectedSubGroupBy = displayFilters.sub_group_by ?? null;
return (
<>
<FilterHeader
title="Sub-group by"
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{ISSUE_GROUP_BY_OPTIONS.filter((option) => subGroupByOptions.includes(option.key)).map((subGroupBy) => {
if (selectedGroupBy !== null && subGroupBy.key === selectedGroupBy) return null;
if (ignoreGroupedFilters.includes(subGroupBy?.key)) return null;
return (
<FilterOption
key={subGroupBy?.key}
isChecked={selectedSubGroupBy === subGroupBy?.key ? true : false}
onClick={() => handleUpdate(subGroupBy.key)}
title={t(subGroupBy.titleTranslationKey)}
multiple={false}
/>
);
})}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,111 @@
"use client";
import { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// plane ui
import { Avatar, Loader } from "@plane/ui";
// components
import { getFileURL } from "@plane/utils";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
memberIds: string[] | undefined;
searchQuery: string;
};
export const FilterAssignees: React.FC<Props> = observer((props: Props) => {
const { appliedFilters, handleUpdate, memberIds, searchQuery } = props;
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// store hooks
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (memberIds || []).filter((memberId) =>
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(memberId) => !(appliedFilters ?? []).includes(memberId),
(memberId) => memberId !== currentUser?.id,
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Assignee${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`assignees-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={
<Avatar
name={member.display_name}
src={getFileURL(member.avatar_url)}
showTooltip={false}
size="md"
/>
}
title={currentUser?.id === member.id ? "You" : member?.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,104 @@
"use client";
import { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// plane ui
import { Avatar, Loader } from "@plane/ui";
// components
import { getFileURL } from "@plane/utils";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
memberIds: string[] | undefined;
searchQuery: string;
};
export const FilterCreatedBy: React.FC<Props> = observer((props: Props) => {
const { appliedFilters, handleUpdate, memberIds, searchQuery } = props;
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// store hooks
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const sortedOptions = useMemo(() => {
const filteredOptions = (memberIds || []).filter((memberId) =>
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(memberId) => !(appliedFilters ?? []).includes(memberId),
(memberId) => memberId !== currentUser?.id,
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Created by${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`created-by-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={<Avatar name={member.display_name} src={getFileURL(member.avatar_url)} size="md" />}
title={currentUser?.id === member.id ? "You" : member?.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,107 @@
"use client";
import React, { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { CycleGroupIcon } from "@plane/propel/icons";
import type { TCycleGroups } from "@plane/types";
// components
import { Loader } from "@plane/ui";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
import { useCycle } from "@/hooks/store/use-cycle";
// ui
// types
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterCycle: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
// hooks
const { projectId } = useParams();
const { getCycleById, getProjectCycleIds } = useCycle();
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
const cycleIds = projectId ? getProjectCycleIds(projectId.toString()) : undefined;
const cycles = cycleIds?.map((projectId) => getCycleById(projectId)!) ?? null;
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (cycles || []).filter((cycle) =>
cycle.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(cycle) => !appliedFilters?.includes(cycle.id),
(cycle) => cycle.name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
const cycleStatus = (status: TCycleGroups | undefined) =>
(status ? status.toLocaleLowerCase() : "draft") as TCycleGroups;
return (
<>
<FilterHeader
title={`Cycle ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((cycle) => (
<FilterOption
key={cycle.id}
isChecked={appliedFilters?.includes(cycle.id) ? true : false}
onClick={() => handleUpdate(cycle.id)}
icon={
<CycleGroupIcon cycleGroup={cycleStatus(cycle?.status)} className="h-3.5 w-3.5 flex-shrink-0" />
}
title={cycle.name}
activePulse={cycleStatus(cycle?.status) === "current" ? true : false}
/>
))}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,75 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
// constants
import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants";
// components
import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterDueDate: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const isCustomDateSelected = () => {
const isCustomFateApplied = appliedFilters?.filter((f) => f.includes("-")) || [];
return isCustomFateApplied.length > 0 ? true : false;
};
const handleCustomDate = () => {
if (isCustomDateSelected()) {
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
handleUpdate(updateAppliedFilters);
} else setIsDateFilterModalOpen(true);
};
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Due date"
/>
)}
<FilterHeader
title={`Due date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,12 @@
export * from "./assignee";
export * from "./mentions";
export * from "./created-by";
export * from "./due-date";
export * from "./labels";
export * from "./priority";
export * from "./project";
export * from "./start-date";
export * from "./state-group";
export * from "./state";
export * from "./cycle";
export * from "./module";

View File

@@ -0,0 +1,96 @@
"use client";
import React, { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
import type { IIssueLabel } from "@plane/types";
// components
import { Loader } from "@plane/ui";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// ui
// types
const LabelIcons = ({ color }: { color: string }) => (
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
);
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
labels: IIssueLabel[] | undefined;
searchQuery: string;
};
export const FilterLabels: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, labels, searchQuery } = props;
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (labels || []).filter((label) =>
label.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(label) => !(appliedFilters ?? []).includes(label.id),
(label) => label.name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Label${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((label) => (
<FilterOption
key={label?.id}
isChecked={appliedFilters?.includes(label?.id) ? true : false}
onClick={() => handleUpdate(label?.id)}
icon={<LabelIcons color={label.color} />}
title={label.name}
/>
))}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,111 @@
"use client";
import { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// plane ui
import { Loader, Avatar } from "@plane/ui";
// components
import { getFileURL } from "@plane/utils";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
memberIds: string[] | undefined;
searchQuery: string;
};
export const FilterMentions: React.FC<Props> = observer((props: Props) => {
const { appliedFilters, handleUpdate, memberIds, searchQuery } = props;
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// store hooks
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (memberIds || []).filter((memberId) =>
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(memberId) => !(appliedFilters ?? []).includes(memberId),
(memberId) => memberId !== currentUser?.id,
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Mention${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`mentions-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={
<Avatar
name={member?.display_name}
src={getFileURL(member?.avatar_url)}
showTooltip={false}
size={"md"}
/>
}
title={currentUser?.id === member.id ? "You" : member?.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,97 @@
"use client";
import React, { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { ModuleIcon } from "@plane/propel/icons";
import { Loader } from "@plane/ui";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
import { useModule } from "@/hooks/store/use-module";
// ui
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterModule: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
// hooks
const { projectId } = useParams();
const { getModuleById, getProjectModuleIds } = useModule();
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
const moduleIds = projectId ? getProjectModuleIds(projectId.toString()) : undefined;
const modules = moduleIds?.map((moduleId) => getModuleById(moduleId)!) ?? null;
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (modules || []).filter((module) =>
module.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(module) => !appliedFilters?.includes(module.id),
(module) => module.name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Module ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((cycle) => (
<FilterOption
key={cycle.id}
isChecked={appliedFilters?.includes(cycle.id) ? true : false}
onClick={() => handleUpdate(cycle.id)}
icon={<ModuleIcon className="h-3 w-3 flex-shrink-0" />}
title={cycle.name}
/>
))}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,54 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
// plane constants
import { ISSUE_PRIORITIES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { PriorityIcon } from "@plane/propel/icons";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterPriority: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
// hooks
const { t } = useTranslation();
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = ISSUE_PRIORITIES.filter((p) => p.key.includes(searchQuery.toLowerCase()));
return (
<>
<FilterHeader
title={`Priority ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((priority) => (
<FilterOption
key={priority.key}
isChecked={appliedFilters?.includes(priority.key) ? true : false}
onClick={() => handleUpdate(priority.key)}
icon={<PriorityIcon priority={priority.key} className="h-3.5 w-3.5" />}
title={priority.title}
/>
))
) : (
<p className="text-xs italic text-custom-text-400">{t("common.search.no_matches_found")}</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,98 @@
"use client";
import React, { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// ui
import { Loader } from "@plane/ui";
// components
import { Logo } from "@/components/common/logo";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// hooks
import { useProject } from "@/hooks/store/use-project";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterProjects: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// store
const { getProjectById, joinedProjectIds } = useProject();
// derived values
const projects = joinedProjectIds?.map((projectId) => getProjectById(projectId)!) ?? null;
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (projects || []).filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(project) => !(appliedFilters ?? []).includes(project.id),
(project) => project.name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Project${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((project) => (
<FilterOption
key={`project-${project.id}`}
isChecked={appliedFilters?.includes(project.id) ? true : false}
onClick={() => handleUpdate(project.id)}
icon={
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={project.logo_props} size={12} />
</span>
}
title={project.name}
/>
))}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,74 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
// constants
import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants";
// components
import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterStartDate: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const isCustomDateSelected = () => {
const isCustomFateApplied = appliedFilters?.filter((f) => f.includes("-")) || [];
return isCustomFateApplied.length > 0 ? true : false;
};
const handleCustomDate = () => {
if (isCustomDateSelected()) {
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
handleUpdate(updateAppliedFilters);
} else setIsDateFilterModalOpen(true);
};
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Start date"
/>
)}
<FilterHeader
title={`Start date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,71 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { STATE_GROUPS } from "@plane/constants";
import { StateGroupIcon } from "@plane/propel/icons";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterStateGroup: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = Object.values(STATE_GROUPS).filter((s) => s.key.includes(searchQuery.toLowerCase()));
const handleViewToggle = () => {
if (!filteredOptions) return;
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
else setItemsToRender(filteredOptions.length);
};
return (
<>
<FilterHeader
title={`State group${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.slice(0, itemsToRender).map((stateGroup) => (
<FilterOption
key={stateGroup.key}
isChecked={appliedFilters?.includes(stateGroup.key) ? true : false}
onClick={() => handleUpdate(stateGroup.key)}
icon={<StateGroupIcon stateGroup={stateGroup.key} />}
title={stateGroup.label}
/>
))}
{filteredOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,96 @@
"use client";
import React, { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/propel/icons";
import type { IState } from "@plane/types";
// components
import { Loader } from "@plane/ui";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// ui
// types
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
states: IState[] | undefined;
};
export const FilterState: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery, states } = props;
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (states ?? []).filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()));
return sortBy(filteredOptions, [(s) => !(appliedFilters ?? []).includes(s.id)]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`State${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((state) => (
<FilterOption
key={state.id}
isChecked={appliedFilters?.includes(state.id) ? true : false}
onClick={() => handleUpdate(state.id)}
icon={
<StateGroupIcon
stateGroup={state.group}
color={state.color}
size={EIconSize.MD}
percentage={state?.order}
/>
}
title={state.name}
/>
))}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,124 @@
"use client";
import React, { Fragment, useState } from "react";
import type { Placement } from "@popperjs/core";
import { usePopper } from "react-popper";
// icons
import { ChevronUp } from "lucide-react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// ui
import { Button } from "@plane/propel/button";
type Props = {
children: React.ReactNode;
icon?: React.ReactNode;
miniIcon?: React.ReactNode;
title?: string;
placement?: Placement;
disabled?: boolean;
tabIndex?: number;
menuButton?: React.ReactNode;
isFiltersApplied?: boolean;
};
export const FiltersDropdown: React.FC<Props> = (props) => {
const {
children,
miniIcon,
icon,
title = "Dropdown",
placement,
disabled = false,
tabIndex,
menuButton,
isFiltersApplied = false,
} = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | HTMLDivElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto",
});
return (
<Popover as="div">
{({ open }) => {
if (open) {
}
return (
<>
<Popover.Button as={React.Fragment}>
{menuButton ? (
<button role="button" ref={setReferenceElement}>
{menuButton}
</button>
) : (
<div ref={setReferenceElement}>
<div className="hidden @4xl:flex">
<Button
disabled={disabled}
variant="neutral-primary"
size="sm"
prependIcon={icon}
appendIcon={
<ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} />
}
tabIndex={tabIndex}
className="relative"
>
<>
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
<span>{title}</span>
</div>
{isFiltersApplied && (
<span className="absolute h-2 w-2 -right-0.5 -top-0.5 bg-custom-primary-100 rounded-full" />
)}
</>
</Button>
</div>
<div className="flex @4xl:hidden">
<Button
disabled={disabled}
ref={setReferenceElement}
variant="neutral-primary"
size="sm"
tabIndex={tabIndex}
className="relative px-2"
>
{miniIcon || title}
</Button>
</div>
</div>
)}
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
{/** translate-y-0 is a hack to create new stacking context. Required for safari */}
<Popover.Panel className="fixed z-10 translate-y-0">
<div
className="overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg my-1"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex max-h-[30rem] lg:max-h-[37.5rem] w-[18.75rem] flex-col overflow-hidden">
{children}
</div>
</div>
</Popover.Panel>
</Transition>
</>
);
}}
</Popover>
);
};

View File

@@ -0,0 +1,22 @@
import React from "react";
// lucide icons
import { ChevronDown, ChevronUp } from "lucide-react";
interface IFilterHeader {
title: string;
isPreviewEnabled: boolean;
handleIsPreviewEnabled: () => void;
}
export const FilterHeader = ({ title, isPreviewEnabled, handleIsPreviewEnabled }: IFilterHeader) => (
<div className="sticky top-0 flex items-center justify-between gap-2 bg-custom-background-100">
<div className="flex-grow truncate text-xs font-medium text-custom-text-400">{title}</div>
<button
type="button"
className="grid h-5 w-5 flex-shrink-0 place-items-center rounded hover:bg-custom-background-80"
onClick={handleIsPreviewEnabled}
>
{isPreviewEnabled ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
);

View File

@@ -0,0 +1,39 @@
import React from "react";
// lucide icons
import { Check } from "lucide-react";
type Props = {
icon?: React.ReactNode;
isChecked: boolean;
title: React.ReactNode;
onClick?: () => void;
multiple?: boolean;
activePulse?: boolean;
};
export const FilterOption: React.FC<Props> = (props) => {
const { icon, isChecked, multiple = true, onClick, title, activePulse = false } = props;
return (
<button
type="button"
className="flex w-full items-center gap-2 rounded p-1.5 hover:bg-custom-background-80"
onClick={onClick}
>
<div
className={`grid h-3 w-3 flex-shrink-0 place-items-center border bg-custom-background-90 ${
isChecked ? "border-custom-primary-100 bg-custom-primary-100 text-white" : "border-custom-border-300"
} ${multiple ? "rounded-sm" : "rounded-full"}`}
>
{isChecked && <Check size={10} strokeWidth={3} />}
</div>
<div className="flex items-center gap-2 truncate">
{icon && <div className="grid w-5 flex-shrink-0 place-items-center">{icon}</div>}
<div className="flex-grow truncate text-xs text-custom-text-200">{title}</div>
</div>
{activePulse && (
<div className="flex-shrink-0 text-xs w-2 h-2 rounded-full bg-custom-primary-100 animate-pulse ml-auto" />
)}
</button>
);
};

View File

@@ -0,0 +1,3 @@
export * from "./dropdown";
export * from "./filter-header";
export * from "./filter-option";

View File

@@ -0,0 +1,5 @@
export * from "./display-filters";
export * from "./filters";
export * from "./helpers";
export * from "./layout-selection";
export * from "./mobile-layout-selection";

View File

@@ -0,0 +1,55 @@
"use client";
import React from "react";
// plane constants
import { ISSUE_LAYOUTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import type { EIssueLayoutTypes } from "@plane/types";
// ui
// types
import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon";
import { usePlatformOS } from "@/hooks/use-platform-os";
// hooks
type Props = {
layouts: EIssueLayoutTypes[];
onChange: (layout: EIssueLayoutTypes) => void;
selectedLayout: EIssueLayoutTypes | undefined;
};
export const LayoutSelection: React.FC<Props> = (props) => {
const { layouts, onChange, selectedLayout } = props;
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const handleOnChange = (layoutKey: EIssueLayoutTypes) => {
if (selectedLayout !== layoutKey) {
onChange(layoutKey);
}
};
return (
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
{ISSUE_LAYOUTS.filter((l) => layouts.includes(l.key)).map((layout) => (
<Tooltip key={layout.key} tooltipContent={t(layout.i18n_title)} isMobile={isMobile}>
<button
type="button"
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
selectedLayout == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`}
onClick={() => handleOnChange(layout.key)}
>
<IssueLayoutIcon
layout={layout.key}
size={14}
strokeWidth={2}
className={`h-3.5 w-3.5 ${
selectedLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"
}`}
/>
</button>
</Tooltip>
))}
</div>
);
};

View File

@@ -0,0 +1,55 @@
import { ChevronDown } from "lucide-react";
import { ISSUE_LAYOUTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { EIssueLayoutTypes } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { IssueLayoutIcon } from "../../layout-icon";
export const MobileLayoutSelection = ({
layouts,
onChange,
activeLayout,
}: {
layouts: EIssueLayoutTypes[];
onChange: (layout: EIssueLayoutTypes) => void;
activeLayout?: EIssueLayoutTypes;
isMobile?: boolean;
}) => {
const { t } = useTranslation();
return (
<CustomMenu
maxHeight={"md"}
className="flex flex-grow justify-center text-sm text-custom-text-200"
placement="bottom-start"
customButton={
activeLayout ? (
<Button variant="neutral-primary" size="sm" className="relative px-2">
<IssueLayoutIcon layout={activeLayout} size={14} strokeWidth={2} className={`h-3.5 w-3.5`} />
<ChevronDown className="size-3 text-custom-text-200 my-auto" strokeWidth={2} />
</Button>
) : (
<div className="flex flex-start text-sm text-custom-text-200">
{t("common.layout")}
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200 my-auto" strokeWidth={2} />
</div>
)
}
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
closeOnSelect
>
{ISSUE_LAYOUTS.filter((l) => layouts.includes(l.key)).map((layout, index) => (
<CustomMenu.MenuItem
key={index}
onClick={() => {
onChange(layout.key);
}}
className="flex items-center gap-2"
>
<IssueLayoutIcon layout={layout.key} className="h-3 w-3" />
<div className="text-custom-text-300">{t(layout.i18n_label)}</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./header";
export * from "./applied-filters";

View File

@@ -0,0 +1,151 @@
import React, { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { ALL_ISSUES, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { EIssuesStoreType, IBlockUpdateData, TIssue } from "@plane/types";
import { EIssueLayoutTypes } from "@plane/types";
import { renderFormattedPayloadDate } from "@plane/utils";
// components
import { ETimeLineTypeType, TimeLineTypeContext } from "@/components/gantt-chart/contexts";
import { GanttChartRoot } from "@/components/gantt-chart/root";
import { IssueGanttSidebar } from "@/components/gantt-chart/sidebar/issues/sidebar";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { useIssuesActions } from "@/hooks/use-issues-actions";
import { useTimeLineChart } from "@/hooks/use-timeline-chart";
// plane web hooks
import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status";
import { IssueLayoutHOC } from "../issue-layout-HOC";
import { GanttQuickAddIssueButton, QuickAddIssueRoot } from "../quick-add";
import { IssueGanttBlock } from "./blocks";
interface IBaseGanttRoot {
viewId?: string | undefined;
isCompletedCycle?: boolean;
isEpic?: boolean;
}
export type GanttStoreType =
| EIssuesStoreType.PROJECT
| EIssuesStoreType.MODULE
| EIssuesStoreType.CYCLE
| EIssuesStoreType.PROJECT_VIEW
| EIssuesStoreType.EPIC;
export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGanttRoot) => {
const { viewId, isCompletedCycle = false, isEpic = false } = props;
const { t } = useTranslation();
// router
const { workspaceSlug, projectId } = useParams();
const storeType = useIssueStoreType() as GanttStoreType;
const { issues, issuesFilter } = useIssues(storeType);
const { fetchIssues, fetchNextIssues, updateIssue, quickAddIssue } = useIssuesActions(storeType);
const { initGantt } = useTimeLineChart(ETimeLineTypeType.ISSUE);
// store hooks
const { allowPermissions } = useUserPermissions();
const appliedDisplayFilters = issuesFilter.issueFilters?.displayFilters;
// plane web hooks
const isBulkOperationsEnabled = useBulkOperationStatus();
// derived values
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + 1);
useEffect(() => {
fetchIssues("init-loader", { canGroup: false, perPageCount: 100 }, viewId);
}, [fetchIssues, storeType, viewId]);
useEffect(() => {
initGantt();
}, []);
const issuesIds = (issues.groupedIssueIds?.[ALL_ISSUES] as string[]) ?? [];
const nextPageResults = issues.getPaginationData(undefined, undefined)?.nextPageResults;
const { enableIssueCreation } = issues?.viewFlags || {};
const loadMoreIssues = useCallback(() => {
fetchNextIssues();
}, [fetchNextIssues]);
const updateIssueBlockStructure = async (issue: TIssue, data: IBlockUpdateData) => {
if (!workspaceSlug) return;
const payload: any = { ...data };
if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder;
updateIssue && (await updateIssue(issue.project_id, issue.id, payload));
};
const isAllowed = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT);
const updateBlockDates = useCallback(
(
updates: {
id: string;
start_date?: string;
target_date?: string;
}[]
) =>
issues.updateIssueDates(workspaceSlug.toString(), updates, projectId.toString()).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: "Error while updating work item dates, Please try again Later",
});
}),
[issues, projectId, workspaceSlug]
);
const quickAdd =
enableIssueCreation && isAllowed && !isCompletedCycle ? (
<QuickAddIssueRoot
layout={EIssueLayoutTypes.GANTT}
QuickAddButton={GanttQuickAddIssueButton}
containerClassName="sticky bottom-0 z-[1]"
prePopulatedData={{
start_date: renderFormattedPayloadDate(new Date()),
target_date: renderFormattedPayloadDate(targetDate),
}}
quickAddCallback={quickAddIssue}
isEpic={isEpic}
/>
) : undefined;
return (
<IssueLayoutHOC layout={EIssueLayoutTypes.GANTT}>
<TimeLineTypeContext.Provider value={ETimeLineTypeType.ISSUE}>
<div className="h-full w-full">
<GanttChartRoot
border={false}
title={isEpic ? t("epic.label", { count: 2 }) : t("issue.label", { count: 2 })}
loaderTitle={isEpic ? t("epic.label", { count: 2 }) : t("issue.label", { count: 2 })}
blockIds={issuesIds}
blockUpdateHandler={updateIssueBlockStructure}
blockToRender={(data: TIssue) => <IssueGanttBlock issueId={data.id} isEpic={isEpic} />}
sidebarToRender={(props) => <IssueGanttSidebar {...props} showAllBlocks isEpic={isEpic} />}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={appliedDisplayFilters?.order_by === "sort_order" && isAllowed}
enableAddBlock={isAllowed}
enableSelection={isBulkOperationsEnabled && isAllowed}
quickAdd={quickAdd}
loadMoreBlocks={loadMoreIssues}
canLoadMoreBlocks={nextPageResults}
updateBlockDates={updateBlockDates}
showAllBlocks
enableDependency
isEpic={isEpic}
/>
</div>
</TimeLineTypeContext.Provider>
</IssueLayoutHOC>
);
});

View File

@@ -0,0 +1,154 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { Tooltip } from "@plane/propel/tooltip";
import { ControlLink } from "@plane/ui";
import { findTotalDaysInRange, generateWorkItemLink } from "@plane/utils";
// components
import { SIDEBAR_WIDTH } from "@/components/gantt-chart/constants";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
//
import { IssueStats } from "@/plane-web/components/issues/issue-layouts/issue-stats";
import { getBlockViewDetails } from "../utils";
import type { GanttStoreType } from "./base-gantt-root";
type Props = {
issueId: string;
isEpic?: boolean;
};
export const IssueGanttBlock: React.FC<Props> = observer((props) => {
const { issueId, isEpic } = props;
// router
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
// store hooks
const { getProjectStates } = useProjectState();
const {
issue: { getIssueById },
} = useIssueDetail();
// hooks
const { isMobile } = usePlatformOS();
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
// derived values
const issueDetails = getIssueById(issueId);
const stateDetails =
issueDetails && getProjectStates(issueDetails?.project_id)?.find((state) => state?.id == issueDetails?.state_id);
const { message, blockStyle } = getBlockViewDetails(issueDetails, stateDetails?.color ?? "");
const handleIssuePeekOverview = () => handleRedirection(workspaceSlug, issueDetails, isMobile);
const duration = findTotalDaysInRange(issueDetails?.start_date, issueDetails?.target_date) || 0;
return (
<Tooltip
isMobile={isMobile}
tooltipContent={
<div className="space-y-1">
<h5>{issueDetails?.name}</h5>
<div>{message}</div>
</div>
}
position="top-start"
disabled={!message}
>
<div
id={`issue-${issueId}`}
className="relative flex h-full w-full cursor-pointer items-center rounded space-between"
style={blockStyle}
onClick={handleIssuePeekOverview}
>
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50 " />
<div
className="sticky w-auto overflow-hidden truncate px-2.5 py-1 text-sm text-custom-text-100 flex-1"
style={{ left: `${SIDEBAR_WIDTH}px` }}
>
{issueDetails?.name}
</div>
{isEpic && (
<IssueStats
issueId={issueId}
className="sticky mx-2 font-medium text-custom-text-100 overflow-hidden truncate w-auto justify-end flex-shrink-0"
showProgressText={duration >= 2}
/>
)}
</div>
</Tooltip>
);
});
// rendering issues on gantt sidebar
export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
const { issueId, isEpic = false } = props;
// router
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { isMobile } = usePlatformOS();
const storeType = useIssueStoreType() as GanttStoreType;
const { issuesFilter } = useIssues(storeType);
const { getProjectIdentifierById } = useProject();
// handlers
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
// derived values
const issueDetails = getIssueById(issueId);
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
const handleIssuePeekOverview = (e: any) => {
e.stopPropagation(true);
e.preventDefault();
handleRedirection(workspaceSlug, issueDetails, isMobile);
};
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails?.project_id,
issueId,
projectIdentifier,
sequenceId: issueDetails?.sequence_id,
isEpic,
});
return (
<ControlLink
id={`issue-${issueId}`}
href={workItemLink}
onClick={handleIssuePeekOverview}
className="line-clamp-1 w-full cursor-pointer text-sm text-custom-text-100"
disabled={!!issueDetails?.tempId}
>
<div className="relative flex h-full w-full cursor-pointer items-center gap-2">
{issueDetails?.project_id && (
<IssueIdentifier
issueId={issueDetails.id}
projectId={issueDetails.project_id}
textContainerClassName="text-xs text-custom-text-300"
displayProperties={issuesFilter?.issueFilters?.displayProperties}
/>
)}
<Tooltip tooltipContent={issueDetails?.name} isMobile={isMobile}>
<span className="flex-grow truncate text-sm font-medium">{issueDetails?.name}</span>
</Tooltip>
</div>
</ControlLink>
);
});

View File

@@ -0,0 +1 @@
export * from "./base-gantt-root";

View File

@@ -0,0 +1,88 @@
import { useRef } from "react";
import { AlertCircle } from "lucide-react";
// plane imports
import { ISSUE_ORDER_BY_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TIssueOrderByOptions } from "@plane/types";
// helpers
import { cn } from "@plane/utils";
// plane web imports
import { WorkFlowDisabledOverlay } from "@/plane-web/components/workflow";
type Props = {
dragColumnOrientation: "justify-start" | "justify-center" | "justify-end";
workflowDisabledSource?: string;
canOverlayBeVisible: boolean;
isDropDisabled: boolean;
dropErrorMessage?: string;
orderBy: TIssueOrderByOptions | undefined;
isDraggingOverColumn: boolean;
isEpic?: boolean;
};
export const GroupDragOverlay = (props: Props) => {
const {
dragColumnOrientation,
canOverlayBeVisible,
workflowDisabledSource,
isDropDisabled,
dropErrorMessage,
orderBy,
isDraggingOverColumn,
isEpic = false,
} = props;
// hooks
const { t } = useTranslation();
// refs
const messageContainerRef = useRef<HTMLDivElement>(null);
const shouldOverlayBeVisible = isDraggingOverColumn && canOverlayBeVisible;
const readableOrderBy = t(
ISSUE_ORDER_BY_OPTIONS.find((orderByObj) => orderByObj.key === orderBy)?.titleTranslationKey || ""
);
return (
<div
ref={messageContainerRef}
className={cn(
`absolute top-0 left-0 h-full w-full items-center text-sm font-medium text-custom-text-300 rounded bg-custom-background-80/85 ${dragColumnOrientation}`,
{
"flex flex-col border-[1px] border-custom-border-300 z-[2]": shouldOverlayBeVisible,
"bg-red-200/60": workflowDisabledSource && isDropDisabled,
},
{ hidden: !shouldOverlayBeVisible }
)}
>
{workflowDisabledSource ? (
<WorkFlowDisabledOverlay
messageContainerRef={messageContainerRef}
workflowDisabledSource={workflowDisabledSource}
shouldOverlayBeVisible={shouldOverlayBeVisible}
/>
) : (
<div
className={cn("p-3 my-8 flex flex-col rounded items-center", {
"text-custom-text-200": shouldOverlayBeVisible,
"text-custom-text-error": isDropDisabled,
})}
>
{dropErrorMessage ? (
<div className="flex items-center">
<AlertCircle width={13} height={13} /> &nbsp;
<span>{dropErrorMessage}</span>
</div>
) : (
<>
{readableOrderBy && (
<span>
{t("issue.layouts.ordered_by_label")} <span className="font-semibold">{t(readableOrderBy)}</span>.
</span>
)}
<span>{t("entity.drop_here_to_move", { entity: isEpic ? "epic" : "work item" })}</span>
</>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,56 @@
import { observer } from "mobx-react";
// plane imports
import { EIssueLayoutTypes } from "@plane/types";
// components
import { CalendarLayoutLoader } from "@/components/ui/loader/layouts/calendar-layout-loader";
import { GanttLayoutLoader } from "@/components/ui/loader/layouts/gantt-layout-loader";
import { KanbanLayoutLoader } from "@/components/ui/loader/layouts/kanban-layout-loader";
import { ListLayoutLoader } from "@/components/ui/loader/layouts/list-layout-loader";
import { SpreadsheetLayoutLoader } from "@/components/ui/loader/layouts/spreadsheet-layout-loader";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
// local imports
import { IssueLayoutEmptyState } from "./empty-states";
const ActiveLoader = (props: { layout: EIssueLayoutTypes }) => {
const { layout } = props;
switch (layout) {
case EIssueLayoutTypes.LIST:
return <ListLayoutLoader />;
case EIssueLayoutTypes.KANBAN:
return <KanbanLayoutLoader />;
case EIssueLayoutTypes.SPREADSHEET:
return <SpreadsheetLayoutLoader />;
case EIssueLayoutTypes.CALENDAR:
return <CalendarLayoutLoader />;
case EIssueLayoutTypes.GANTT:
return <GanttLayoutLoader />;
default:
return null;
}
};
interface Props {
children: string | React.ReactNode | React.ReactNode[];
layout: EIssueLayoutTypes;
}
export const IssueLayoutHOC = observer((props: Props) => {
const { layout } = props;
const storeType = useIssueStoreType();
const { issues } = useIssues(storeType);
const issueCount = issues.getGroupIssueCount(undefined, undefined, false);
if (issues?.getIssueLoader() === "init-loader" || issueCount === undefined) {
return <ActiveLoader layout={layout} />;
}
if (issues.getGroupIssueCount(undefined, undefined, false) === 0 && layout !== EIssueLayoutTypes.CALENDAR) {
return <IssueLayoutEmptyState storeType={storeType} />;
}
return <>{props.children}</>;
});

View File

@@ -0,0 +1,305 @@
"use client";
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EIssueFilterType, EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import type { EIssuesStoreType } from "@plane/types";
import { EIssueServiceType, EIssueLayoutTypes } from "@plane/types";
//constants
//hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useKanbanView } from "@/hooks/store/use-kanban-view";
import { useUserPermissions } from "@/hooks/store/user";
import { useGroupIssuesDragNDrop } from "@/hooks/use-group-dragndrop";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { useIssuesActions } from "@/hooks/use-issues-actions";
// store
// ui
// types
import { DeleteIssueModal } from "../../delete-issue-modal";
import { IssueLayoutHOC } from "../issue-layout-HOC";
import type { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types";
//components
import { getSourceFromDropPayload } from "../utils";
import { KanBan } from "./default";
import { KanBanSwimLanes } from "./swimlanes";
export type KanbanStoreType =
| EIssuesStoreType.PROJECT
| EIssuesStoreType.MODULE
| EIssuesStoreType.CYCLE
| EIssuesStoreType.PROJECT_VIEW
| EIssuesStoreType.PROFILE
| EIssuesStoreType.TEAM
| EIssuesStoreType.TEAM_VIEW
| EIssuesStoreType.EPIC;
export interface IBaseKanBanLayout {
QuickActions: FC<IQuickActionProps>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
canEditPropertiesBasedOnProject?: (projectId: string) => boolean;
isCompletedCycle?: boolean;
viewId?: string | undefined;
isEpic?: boolean;
}
export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBaseKanBanLayout) => {
const {
QuickActions,
addIssuesToView,
canEditPropertiesBasedOnProject,
isCompletedCycle = false,
viewId,
isEpic = false,
} = props;
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const storeType = useIssueStoreType() as KanbanStoreType;
const { allowPermissions } = useUserPermissions();
const { issueMap, issuesFilter, issues } = useIssues(storeType);
const {
issue: { getIssueById },
} = useIssueDetail(isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
const {
fetchIssues,
fetchNextIssues,
quickAddIssue,
updateIssue,
removeIssue,
removeIssueFromView,
archiveIssue,
restoreIssue,
updateFilters,
} = useIssuesActions(storeType);
const deleteAreaRef = useRef<HTMLDivElement | null>(null);
const [isDragOverDelete, setIsDragOverDelete] = useState(false);
const { isDragging } = useKanbanView();
const displayFilters = issuesFilter?.issueFilters?.displayFilters;
const displayProperties = issuesFilter?.issueFilters?.displayProperties;
const sub_group_by = displayFilters?.sub_group_by;
const group_by = displayFilters?.group_by;
const orderBy = displayFilters?.order_by;
useEffect(() => {
fetchIssues("init-loader", { canGroup: true, perPageCount: sub_group_by ? 10 : 30 }, viewId);
}, [fetchIssues, storeType, group_by, sub_group_by, viewId]);
const fetchMoreIssues = useCallback(
(groupId?: string, subgroupId?: string) => {
if (issues?.getIssueLoader(groupId, subgroupId) !== "pagination") {
fetchNextIssues(groupId, subgroupId);
}
},
[fetchNextIssues]
);
const groupedIssueIds = issues?.groupedIssueIds;
const userDisplayFilters = displayFilters || null;
const KanBanView = sub_group_by ? KanBanSwimLanes : KanBan;
const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {};
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
// states
const [draggedIssueId, setDraggedIssueId] = useState<string | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const handleOnDrop = useGroupIssuesDragNDrop(storeType, orderBy, group_by, sub_group_by);
const canEditProperties = useCallback(
(projectId: string | undefined) => {
const isEditingAllowedBasedOnProject =
canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed;
return enableInlineEditing && isEditingAllowedBasedOnProject;
},
[canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed]
);
// Enable Auto Scroll for Main Kanban
useEffect(() => {
const element = scrollableContainerRef.current;
if (!element) return;
return combine(
autoScrollForElements({
element,
})
);
}, []);
// Make the Issue Delete Box a Drop Target
useEffect(() => {
const element = deleteAreaRef.current;
if (!element) return;
return combine(
dropTargetForElements({
element,
getData: () => ({ columnId: "issue-trash-box", groupId: "issue-trash-box", type: "DELETE" }),
onDragEnter: () => {
setIsDragOverDelete(true);
},
onDragLeave: () => {
setIsDragOverDelete(false);
},
onDrop: (payload) => {
setIsDragOverDelete(false);
const source = getSourceFromDropPayload(payload);
if (!source) return;
setDraggedIssueId(source.id);
setDeleteIssueModal(true);
},
})
);
}, [setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]);
const renderQuickActions: TRenderQuickActions = useCallback(
({ issue, parentRef, customActionButton }) => (
<QuickActions
parentRef={parentRef}
customActionButton={customActionButton}
issue={issue}
handleDelete={async () => removeIssue(issue.project_id, issue.id)}
handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)}
handleRemoveFromView={async () => removeIssueFromView && removeIssueFromView(issue.project_id, issue.id)}
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
readOnly={!canEditProperties(issue.project_id ?? undefined) || isCompletedCycle}
/>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[isCompletedCycle, canEditProperties, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue]
);
const handleDeleteIssue = async () => {
const draggedIssue = getIssueById(draggedIssueId ?? "");
if (!draggedIssueId || !draggedIssue) return;
await removeIssue(draggedIssue.project_id, draggedIssueId)
.then(() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: draggedIssueId },
});
})
.catch(() => {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: draggedIssueId },
});
})
.finally(() => {
setDeleteIssueModal(false);
setDraggedIssueId(undefined);
});
};
const handleCollapsedGroups = useCallback(
(toggle: "group_by" | "sub_group_by", value: string) => {
if (workspaceSlug) {
let collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || [];
if (collapsedGroups.includes(value)) {
collapsedGroups = collapsedGroups.filter((_value) => _value != value);
} else {
collapsedGroups.push(value);
}
updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, {
[toggle]: collapsedGroups,
});
}
},
[workspaceSlug, issuesFilter, projectId, updateFilters]
);
const collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] };
return (
<>
<DeleteIssueModal
dataId={draggedIssueId}
isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)}
onSubmit={handleDeleteIssue}
isEpic={isEpic}
/>
{/* drag and delete component */}
<div
className={`fixed left-1/2 -translate-x-1/2 ${
isDragging ? "z-40" : ""
} top-3 mx-3 flex w-72 items-center justify-center`}
ref={deleteAreaRef}
>
<div
className={`${
isDragging ? `opacity-100` : `opacity-0`
} flex w-full items-center justify-center rounded border-2 border-red-500/20 bg-custom-background-100 px-3 py-5 text-xs font-medium italic text-red-500 ${
isDragOverDelete ? "bg-red-500 opacity-70 blur-2xl" : ""
} transition duration-300`}
>
Drop here to delete the work item.
</div>
</div>
<IssueLayoutHOC layout={EIssueLayoutTypes.KANBAN}>
<div
className={`horizontal-scrollbar scrollbar-lg relative flex h-full w-full bg-custom-background-90 ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`}
ref={scrollableContainerRef}
>
<div className="relative h-full w-max min-w-full bg-custom-background-90">
<div className="h-full w-max">
<KanBanView
issuesMap={issueMap}
groupedIssueIds={groupedIssueIds ?? {}}
getGroupIssueCount={issues.getGroupIssueCount}
displayProperties={displayProperties}
sub_group_by={sub_group_by}
group_by={group_by}
orderBy={orderBy}
updateIssue={updateIssue}
quickActions={renderQuickActions}
handleCollapsedGroups={handleCollapsedGroups}
collapsedGroups={collapsedGroups}
enableQuickIssueCreate={enableQuickAdd}
showEmptyGroup={userDisplayFilters?.show_empty_groups ?? true}
quickAddCallback={quickAddIssue}
disableIssueCreation={!enableIssueCreation || !isEditingAllowed || isCompletedCycle}
canEditProperties={canEditProperties}
addIssuesToView={addIssuesToView}
scrollableContainerRef={scrollableContainerRef}
handleOnDrop={handleOnDrop}
loadMoreIssues={fetchMoreIssues}
isEpic={isEpic}
/>
</div>
</div>
</div>
</IssueLayoutHOC>
</>
);
});

View File

@@ -0,0 +1,305 @@
"use client";
import type { MutableRefObject } from "react";
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane helpers
import { MoreHorizontal } from "lucide-react";
import { useOutsideClickDetector } from "@plane/hooks";
// types
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import { ControlLink, DropIndicator } from "@plane/ui";
import { cn, generateWorkItemLink } from "@plane/utils";
// components
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { HIGHLIGHT_CLASS, getIssueBlockId } from "@/components/issues/issue-layouts/utils";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useKanbanView } from "@/hooks/store/use-kanban-view";
import { useProject } from "@/hooks/store/use-project";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
// local components
import { IssueStats } from "@/plane-web/components/issues/issue-layouts/issue-stats";
import type { TRenderQuickActions } from "../list/list-view-types";
import { IssueProperties } from "../properties/all-properties";
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
interface IssueBlockProps {
issueId: string;
groupId: string;
subGroupId: string;
issuesMap: IIssueMap;
displayProperties: IIssueDisplayProperties | undefined;
draggableId: string;
canDropOverIssue: boolean;
canDragIssuesInCurrentGrouping: boolean;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
shouldRenderByDefault?: boolean;
isEpic?: boolean;
}
interface IssueDetailsBlockProps {
cardRef: React.RefObject<HTMLElement>;
issue: TIssue;
displayProperties: IIssueDisplayProperties | undefined;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
isReadOnly: boolean;
isEpic?: boolean;
}
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props) => {
const { cardRef, issue, updateIssue, quickActions, isReadOnly, displayProperties, isEpic = false } = props;
// refs
const menuActionRef = useRef<HTMLDivElement | null>(null);
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// hooks
const { isMobile } = usePlatformOS();
const customActionButton = (
<div
ref={menuActionRef}
className={`flex items-center h-full w-full cursor-pointer rounded p-1 text-custom-sidebar-text-400 hover:bg-custom-background-80 ${
isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"
}`}
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
);
// derived values
const subIssueCount = issue?.sub_issues_count ?? 0;
const handleEventPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
};
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
return (
<>
<div className="relative">
{issue.project_id && (
<IssueIdentifier
issueId={issue.id}
projectId={issue.project_id}
textContainerClassName="line-clamp-1 text-xs text-custom-text-300"
displayProperties={displayProperties}
/>
)}
<div
className={cn("absolute -top-1 right-0", {
"hidden group-hover/kanban-block:block": !isMobile,
"!block": isMenuActive,
})}
onClick={handleEventPropagation}
>
{quickActions({
issue,
parentRef: cardRef,
customActionButton,
})}
</div>
</div>
<Tooltip tooltipContent={issue.name} isMobile={isMobile} renderByDefault={false}>
<div className="w-full line-clamp-1 text-sm text-custom-text-100">
<span>{issue.name}</span>
</div>
</Tooltip>
<IssueProperties
className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5"
issue={issue}
displayProperties={displayProperties}
activeLayout="Kanban"
updateIssue={updateIssue}
isReadOnly={isReadOnly}
isEpic={isEpic}
/>
{isEpic && displayProperties && (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="sub_issue_count"
shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!subIssueCount}
>
<IssueStats issueId={issue.id} className="mt-2 font-medium text-custom-text-350" />
</WithDisplayPropertiesHOC>
)}
</>
);
});
export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
const {
issueId,
groupId,
subGroupId,
issuesMap,
displayProperties,
canDropOverIssue,
canDragIssuesInCurrentGrouping,
updateIssue,
quickActions,
canEditProperties,
scrollableContainerRef,
shouldRenderByDefault,
isEpic = false,
} = props;
const cardRef = useRef<HTMLAnchorElement | null>(null);
// router
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
// hooks
const { getProjectIdentifierById } = useProject();
const { getIsIssuePeeked } = useIssueDetail(isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
const { isMobile } = usePlatformOS();
// handlers
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
const issue = issuesMap[issueId];
const { setIsDragging: setIsKanbanDragging } = useKanbanView();
const [isDraggingOverBlock, setIsDraggingOverBlock] = useState(false);
const [isCurrentBlockDragging, setIsCurrentBlockDragging] = useState(false);
const canEditIssueProperties = canEditProperties(issue?.project_id ?? undefined);
const isDragAllowed = canDragIssuesInCurrentGrouping && !issue?.tempId && canEditIssueProperties;
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!issue?.archived_at,
});
useOutsideClickDetector(cardRef, () => {
cardRef?.current?.classList?.remove(HIGHLIGHT_CLASS);
});
// Make Issue block both as as Draggable and,
// as a DropTarget for other issues being dragged to get the location of drop
useEffect(() => {
const element = cardRef.current;
if (!element) return;
return combine(
draggable({
element,
dragHandle: element,
canDrag: () => isDragAllowed,
getInitialData: () => ({ id: issue?.id, type: "ISSUE" }),
onDragStart: () => {
setIsCurrentBlockDragging(true);
setIsKanbanDragging(true);
},
onDrop: () => {
setIsKanbanDragging(false);
setIsCurrentBlockDragging(false);
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) => source?.data?.id !== issue?.id && canDropOverIssue,
getData: () => ({ id: issue?.id, type: "ISSUE" }),
onDragEnter: () => {
setIsDraggingOverBlock(true);
},
onDragLeave: () => {
setIsDraggingOverBlock(false);
},
onDrop: () => {
setIsDraggingOverBlock(false);
},
})
);
}, [cardRef?.current, issue?.id, isDragAllowed, canDropOverIssue, setIsCurrentBlockDragging, setIsDraggingOverBlock]);
if (!issue) return null;
return (
<>
<DropIndicator isVisible={!isCurrentBlockDragging && isDraggingOverBlock} />
<div
id={`issue-${issueId}`}
// make Z-index higher at the beginning of drag, to have a issue drag image of issue block without any overlaps
className={cn("group/kanban-block relative mb-2", { "z-[1]": isCurrentBlockDragging })}
onDragStart={() => {
if (isDragAllowed) setIsCurrentBlockDragging(true);
else {
setToast({
type: TOAST_TYPE.WARNING,
title: "Cannot move work item",
message: !canEditIssueProperties
? "You are not allowed to move this work item"
: "Drag and drop is disabled for the current grouping",
});
}
}}
>
<ControlLink
id={getIssueBlockId(issueId, groupId, subGroupId)}
href={workItemLink}
ref={cardRef}
className={cn(
"block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
{ "hover:cursor-pointer": isDragAllowed },
{ "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id) },
{ "bg-custom-background-80 z-[100]": isCurrentBlockDragging }
)}
onClick={() => handleIssuePeekOverview(issue)}
disabled={!!issue?.tempId}
>
<RenderIfVisible
classNames="space-y-2 px-3 py-2"
root={scrollableContainerRef}
defaultHeight="100px"
horizontalOffset={100}
verticalOffset={200}
defaultValue={shouldRenderByDefault}
>
<KanbanIssueDetailsBlock
cardRef={cardRef}
issue={issue}
displayProperties={displayProperties}
updateIssue={updateIssue}
quickActions={quickActions}
isReadOnly={!canEditIssueProperties}
isEpic={isEpic}
/>
</RenderIfVisible>
</ControlLink>
</div>
</>
);
});
KanbanIssueBlock.displayName = "KanbanIssueBlock";

View File

@@ -0,0 +1,75 @@
import type { MutableRefObject } from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
// local imports
import type { TRenderQuickActions } from "../list/list-view-types";
import { KanbanIssueBlock } from "./block";
interface IssueBlocksListProps {
sub_group_id: string;
groupId: string;
issuesMap: IIssueMap;
issueIds: string[];
displayProperties: IIssueDisplayProperties | undefined;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
canEditProperties: (projectId: string | undefined) => boolean;
canDropOverIssue: boolean;
canDragIssuesInCurrentGrouping: boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
isEpic?: boolean;
}
export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = observer((props) => {
const {
sub_group_id,
groupId,
issuesMap,
issueIds,
displayProperties,
canDropOverIssue,
canDragIssuesInCurrentGrouping,
updateIssue,
quickActions,
canEditProperties,
scrollableContainerRef,
isEpic = false,
} = props;
return (
<>
{issueIds && issueIds.length > 0 ? (
<>
{issueIds.map((issueId, index) => {
if (!issueId) return null;
let draggableId = issueId;
if (groupId) draggableId = `${draggableId}__${groupId}`;
if (sub_group_id) draggableId = `${draggableId}__${sub_group_id}`;
return (
<KanbanIssueBlock
key={draggableId}
issueId={issueId}
groupId={groupId}
subGroupId={sub_group_id}
shouldRenderByDefault={index <= 10}
issuesMap={issuesMap}
displayProperties={displayProperties}
updateIssue={updateIssue}
quickActions={quickActions}
draggableId={draggableId}
canDropOverIssue={canDropOverIssue}
canDragIssuesInCurrentGrouping={canDragIssuesInCurrentGrouping}
canEditProperties={canEditProperties}
scrollableContainerRef={scrollableContainerRef}
isEpic={isEpic}
/>
);
})}
</>
) : null}
</>
);
});

View File

@@ -0,0 +1,236 @@
import type { MutableRefObject } from "react";
import { observer } from "mobx-react";
// i18n
import { useTranslation } from "@plane/i18n";
import type {
GroupByColumnTypes,
IGroupByColumn,
TGroupedIssues,
TIssue,
IIssueDisplayProperties,
IIssueMap,
TSubGroupedIssues,
TIssueKanbanFilters,
TIssueGroupByOptions,
TIssueOrderByOptions,
} from "@plane/types";
// constants
import { ContentWrapper } from "@plane/ui";
// components
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { KanbanColumnLoader } from "@/components/ui/loader/layouts/kanban-layout-loader";
// hooks
import { useKanbanView } from "@/hooks/store/use-kanban-view";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
// types
// parent components
import { useWorkFlowFDragNDrop } from "@/plane-web/components/workflow";
import type { TRenderQuickActions } from "../list/list-view-types";
import type { GroupDropLocation } from "../utils";
import { getGroupByColumns, isWorkspaceLevel, getApproximateCardHeight } from "../utils";
// components
import { HeaderGroupByCard } from "./headers/group-by-card";
import { KanbanGroup } from "./kanban-group";
export interface IKanBan {
issuesMap: IIssueMap;
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
displayProperties: IIssueDisplayProperties | undefined;
sub_group_by: TIssueGroupByOptions | undefined;
group_by: TIssueGroupByOptions | undefined;
orderBy: TIssueOrderByOptions | undefined;
isDropDisabled?: boolean;
dropErrorMessage?: string | undefined;
sub_group_id?: string;
sub_group_index?: number;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
enableQuickIssueCreate?: boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
disableIssueCreation?: boolean;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
showEmptyGroup?: boolean;
subGroupIndex?: number;
isEpic?: boolean;
}
export const KanBan: React.FC<IKanBan> = observer((props) => {
const {
issuesMap,
groupedIssueIds,
getGroupIssueCount,
displayProperties,
sub_group_by,
group_by,
sub_group_id = "null",
updateIssue,
quickActions,
collapsedGroups,
handleCollapsedGroups,
enableQuickIssueCreate,
quickAddCallback,
loadMoreIssues,
disableIssueCreation,
addIssuesToView,
canEditProperties,
scrollableContainerRef,
handleOnDrop,
showEmptyGroup = true,
orderBy,
isDropDisabled,
dropErrorMessage,
subGroupIndex = 0,
isEpic = false,
} = props;
// i18n
const { t } = useTranslation();
// store hooks
const storeType = useIssueStoreType();
const issueKanBanView = useKanbanView();
// derived values
const isDragDisabled = !issueKanBanView?.getCanUserDragDrop(group_by, sub_group_by);
const { getIsWorkflowWorkItemCreationDisabled } = useWorkFlowFDragNDrop(group_by, sub_group_by);
const list = getGroupByColumns({
groupBy: group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
isEpic: isEpic,
});
if (!list) return null;
const visibilityGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => {
if (sub_group_by) {
const groupVisibility = {
showGroup: true,
showIssues: true,
};
if (!showEmptyGroup) {
groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0;
}
return groupVisibility;
} else {
const groupVisibility = {
showGroup: true,
showIssues: true,
};
if (!showEmptyGroup) {
if ((getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0) groupVisibility.showGroup = true;
else groupVisibility.showGroup = false;
}
if (collapsedGroups?.group_by.includes(_list.id)) groupVisibility.showIssues = false;
return groupVisibility;
}
};
const isGroupByCreatedBy = group_by === "created_by";
const approximateCardHeight = getApproximateCardHeight(displayProperties);
const isSubGroup = !!sub_group_id && sub_group_id !== "null";
return (
<ContentWrapper className={`flex-row relative gap-4 !pt-2 !pb-0`}>
{list &&
list.length > 0 &&
list.map((subList: IGroupByColumn, groupIndex) => {
const groupByVisibilityToggle = visibilityGroupBy(subList);
if (groupByVisibilityToggle.showGroup === false) return <></>;
const issueIds = isSubGroup
? ((groupedIssueIds as TSubGroupedIssues)?.[subList.id]?.[sub_group_id] ?? [])
: ((groupedIssueIds as TGroupedIssues)?.[subList.id] ?? []);
const issueLength = issueIds?.length as number;
const groupHeight = issueLength * approximateCardHeight;
return (
<div
key={subList.id}
className={`group relative flex flex-shrink-0 flex-col ${
groupByVisibilityToggle.showIssues ? `w-[350px]` : ``
} `}
>
{sub_group_by === null && (
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-custom-background-90 py-1">
<HeaderGroupByCard
sub_group_by={sub_group_by}
group_by={group_by}
column_id={subList.id}
icon={subList.icon}
title={subList.name}
count={getGroupIssueCount(subList.id, undefined, false) ?? 0}
issuePayload={subList.payload}
disableIssueCreation={
disableIssueCreation ||
isGroupByCreatedBy ||
getIsWorkflowWorkItemCreationDisabled(subList.id, sub_group_id)
}
addIssuesToView={addIssuesToView}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
isEpic={isEpic}
/>
</div>
)}
{groupByVisibilityToggle.showIssues && (
<RenderIfVisible
verticalOffset={100}
horizontalOffset={100}
root={scrollableContainerRef}
classNames="h-full min-h-[120px]"
defaultHeight={`${groupHeight}px`}
placeholderChildren={
<KanbanColumnLoader
ignoreHeader
cardHeight={approximateCardHeight}
cardsInColumn={issueLength !== undefined && issueLength < 3 ? issueLength : 3}
shouldAnimate={false}
/>
}
defaultValue={groupIndex < 5 && subGroupIndex < 2}
useIdletime
>
<KanbanGroup
groupId={subList.id}
issuesMap={issuesMap}
groupedIssueIds={groupedIssueIds}
displayProperties={displayProperties}
sub_group_by={sub_group_by}
group_by={group_by}
orderBy={orderBy}
sub_group_id={sub_group_id}
isDragDisabled={isDragDisabled}
isDropDisabled={!!subList.isDropDisabled || !!isDropDisabled}
dropErrorMessage={subList.dropErrorMessage ?? dropErrorMessage}
updateIssue={updateIssue}
quickActions={quickActions}
enableQuickIssueCreate={enableQuickIssueCreate}
quickAddCallback={quickAddCallback}
disableIssueCreation={disableIssueCreation}
canEditProperties={canEditProperties}
scrollableContainerRef={scrollableContainerRef}
loadMoreIssues={loadMoreIssues}
handleOnDrop={handleOnDrop}
isEpic={isEpic}
/>
</RenderIfVisible>
)}
</div>
);
})}
</ContentWrapper>
);
});

View File

@@ -0,0 +1,196 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// lucide icons
import { Minimize2, Maximize2, Circle, Plus } from "lucide-react";
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, ISearchIssueResponse, TIssueKanbanFilters, TIssueGroupByOptions } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
// constants
import { captureClick } from "@/helpers/event-tracker.helper";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal";
// types
// Plane-web
import { WorkFlowGroupTree } from "@/plane-web/components/workflow";
interface IHeaderGroupByCard {
sub_group_by: TIssueGroupByOptions | undefined;
group_by: TIssueGroupByOptions | undefined;
column_id: string;
icon?: React.ReactNode;
title: string;
count: number;
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
issuePayload: Partial<TIssue>;
disableIssueCreation?: boolean;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
isEpic?: boolean;
}
export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
const {
group_by,
sub_group_by,
column_id,
icon,
title,
count,
collapsedGroups,
handleCollapsedGroups,
issuePayload,
disableIssueCreation,
addIssuesToView,
isEpic = false,
} = props;
const verticalAlignPosition = sub_group_by ? false : collapsedGroups?.group_by.includes(column_id);
// states
const [isOpen, setIsOpen] = React.useState(false);
const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false);
// hooks
const storeType = useIssueStoreType();
// router
const { workspaceSlug, projectId, moduleId, cycleId } = useParams();
const renderExistingIssueModal = moduleId || cycleId;
const ExistingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true };
const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return;
const issues = data.map((i) => i.id);
try {
await addIssuesToView?.(issues);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Work items added to the cycle successfully.",
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Selected work items could not be added to the cycle. Please try again.",
});
}
};
return (
<>
{isEpic ? (
<CreateUpdateEpicModal isOpen={isOpen} onClose={() => setIsOpen(false)} data={issuePayload} />
) : (
<CreateUpdateIssueModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
data={issuePayload}
storeType={storeType}
/>
)}
{renderExistingIssueModal && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
isOpen={openExistingIssueListModal}
handleClose={() => setOpenExistingIssueListModal(false)}
searchParams={ExistingIssuesListModalPayload}
handleOnSubmit={handleAddIssuesToView}
/>
)}
<div
className={`relative flex flex-shrink-0 gap-2 py-1.5 ${
verticalAlignPosition ? `w-[44px] flex-col items-center` : `w-full flex-row items-center`
}`}
>
<div className="flex h-[25px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
{icon ? icon : <Circle width={14} strokeWidth={2} />}
</div>
<div
className={`relative flex gap-1 ${
verticalAlignPosition ? `flex-col items-center` : `w-full flex-row items-baseline overflow-hidden`
}`}
>
<div
className={`line-clamp-1 inline-block overflow-hidden truncate font-medium text-custom-text-100 ${
verticalAlignPosition ? `vertical-lr max-h-[400px]` : ``
}`}
>
{title}
</div>
<div
className={`flex-shrink-0 text-sm font-medium text-custom-text-300 ${verticalAlignPosition ? `pr-0.5` : `pl-2`}`}
>
{count || 0}
</div>
</div>
<WorkFlowGroupTree groupBy={group_by} groupId={column_id} />
{sub_group_by === null && (
<div
className="flex h-[20px] w-[20px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80"
onClick={() => handleCollapsedGroups("group_by", column_id)}
>
{verticalAlignPosition ? (
<Maximize2 width={14} strokeWidth={2} />
) : (
<Minimize2 width={14} strokeWidth={2} />
)}
</div>
)}
{!disableIssueCreation &&
(renderExistingIssueModal ? (
<CustomMenu
customButton={
<span className="flex h-[20px] w-[20px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80">
<Plus height={14} width={14} strokeWidth={2} />
</span>
}
placement="bottom-end"
>
<CustomMenu.MenuItem
onClick={() => {
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.create });
setIsOpen(true);
}}
>
<span className="flex items-center justify-start gap-2">Create work item</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.add_existing });
setOpenExistingIssueListModal(true);
}}
>
<span className="flex items-center justify-start gap-2">Add an existing work item</span>
</CustomMenu.MenuItem>
</CustomMenu>
) : (
<div
className="flex h-[20px] w-[20px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80"
onClick={() => {
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.create });
setIsOpen(true);
}}
>
<Plus width={14} strokeWidth={2} />
</div>
))}
</div>
</>
);
});

View File

@@ -0,0 +1,48 @@
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Circle, ChevronDown, ChevronUp } from "lucide-react";
// Plane
import type { TIssueGroupByOptions, TIssueKanbanFilters } from "@plane/types";
// Plane-web
import { WorkFlowGroupTree } from "@/plane-web/components/workflow";
// mobx
interface IHeaderSubGroupByCard {
icon?: React.ReactNode;
title: string;
count: number;
column_id: string;
collapsedGroups: TIssueKanbanFilters;
sub_group_by: TIssueGroupByOptions | undefined;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
}
export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props) => {
const { icon, title, count, column_id, collapsedGroups, sub_group_by, handleCollapsedGroups } = props;
return (
<div
className={`relative flex w-full flex-shrink-0 flex-row items-center gap-1 rounded-sm py-1.5 cursor-pointer`}
onClick={() => handleCollapsedGroups("sub_group_by", column_id)}
>
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80">
{collapsedGroups?.sub_group_by.includes(column_id) ? (
<ChevronDown width={14} strokeWidth={2} />
) : (
<ChevronUp width={14} strokeWidth={2} />
)}
</div>
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
{icon ? icon : <Circle width={14} strokeWidth={2} />}
</div>
<div className="flex flex-shrink-0 items-center gap-1 text-sm">
<div className="line-clamp-1 text-custom-text-100">{title}</div>
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
</div>
<WorkFlowGroupTree groupBy={sub_group_by} groupId={column_id} />
</div>
);
});

View File

@@ -0,0 +1,329 @@
"use client";
import type { MutableRefObject } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
// plane constants
import { DRAG_ALLOWED_GROUPS } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
//types
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type {
TGroupedIssues,
TIssue,
IIssueDisplayProperties,
IIssueMap,
TSubGroupedIssues,
TIssueGroupByOptions,
TIssueOrderByOptions,
} from "@plane/types";
import { EIssueLayoutTypes } from "@plane/types";
import { cn } from "@plane/utils";
import type { GroupDropLocation } from "@/components/issues/issue-layouts/utils";
import {
highlightIssueOnDrop,
getSourceFromDropPayload,
getDestinationFromDropPayload,
getIssueBlockId,
} from "@/components/issues/issue-layouts/utils";
import { KanbanIssueBlockLoader } from "@/components/ui/loader/layouts/kanban-layout-loader";
// hooks
import { useProjectState } from "@/hooks/store/use-project-state";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
// Plane-web
import { useWorkFlowFDragNDrop } from "@/plane-web/components/workflow";
//
import { GroupDragOverlay } from "../group-drag-overlay";
import type { TRenderQuickActions } from "../list/list-view-types";
import { KanbanQuickAddIssueButton, QuickAddIssueRoot } from "../quick-add";
import { KanbanIssueBlocksList } from "./blocks-list";
interface IKanbanGroup {
groupId: string;
issuesMap: IIssueMap;
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
displayProperties: IIssueDisplayProperties | undefined;
sub_group_by: TIssueGroupByOptions | undefined;
group_by: TIssueGroupByOptions | undefined;
sub_group_id: string;
isDragDisabled: boolean;
isDropDisabled: boolean;
dropErrorMessage: string | undefined;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
enableQuickIssueCreate?: boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
disableIssueCreation?: boolean;
canEditProperties: (projectId: string | undefined) => boolean;
groupByVisibilityToggle?: boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
orderBy: TIssueOrderByOptions | undefined;
isEpic?: boolean;
}
export const KanbanGroup = observer((props: IKanbanGroup) => {
const {
groupId,
sub_group_id,
group_by,
orderBy,
sub_group_by,
issuesMap,
displayProperties,
groupedIssueIds,
isDropDisabled,
dropErrorMessage,
updateIssue,
quickActions,
canEditProperties,
loadMoreIssues,
enableQuickIssueCreate,
disableIssueCreation,
quickAddCallback,
scrollableContainerRef,
handleOnDrop,
isEpic = false,
} = props;
// i18n
const { t } = useTranslation();
// hooks
const projectState = useProjectState();
const {
issues: { getGroupIssueCount, getPaginationData, getIssueLoader },
} = useIssuesStore();
const [intersectionElement, setIntersectionElement] = useState<HTMLSpanElement | null>(null);
const columnRef = useRef<HTMLDivElement | null>(null);
const containerRef = sub_group_by && scrollableContainerRef ? scrollableContainerRef : columnRef;
const loadMoreIssuesInThisGroup = useCallback(() => {
loadMoreIssues(groupId, sub_group_id === "null" ? undefined : sub_group_id);
}, [loadMoreIssues, groupId, sub_group_id]);
const isPaginating = !!getIssueLoader(groupId, sub_group_id);
useIntersectionObserver(
containerRef,
isPaginating ? null : intersectionElement,
loadMoreIssuesInThisGroup,
`0% 100% 100% 100%`
);
const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false);
const { workflowDisabledSource, isWorkflowDropDisabled, handleWorkFlowState, getIsWorkflowWorkItemCreationDisabled } =
useWorkFlowFDragNDrop(group_by, sub_group_by);
// Enable Kanban Columns as Drop Targets
useEffect(() => {
const element = columnRef.current;
if (!element) return;
return combine(
dropTargetForElements({
element,
getData: () => ({ groupId, subGroupId: sub_group_id, columnId: `${groupId}__${sub_group_id}`, type: "COLUMN" }),
onDragEnter: (payload) => {
const source = getSourceFromDropPayload(payload);
setIsDraggingOverColumn(true);
// handle if dragging a workflowState
if (source) {
handleWorkFlowState(source?.groupId, groupId, source?.subGroupId, sub_group_id);
}
},
onDragLeave: () => {
setIsDraggingOverColumn(false);
},
onDragStart: (payload) => {
const source = getSourceFromDropPayload(payload);
setIsDraggingOverColumn(true);
// handle if dragging a workflowState
if (source) {
handleWorkFlowState(source?.groupId, groupId, source?.subGroupId, sub_group_id);
}
},
onDrop: (payload) => {
setIsDraggingOverColumn(false);
const source = getSourceFromDropPayload(payload);
const destination = getDestinationFromDropPayload(payload);
if (!source || !destination) return;
if ((isWorkflowDropDisabled || isDropDisabled) && dropErrorMessage) {
setToast({
type: TOAST_TYPE.WARNING,
title: t("common.warning"),
message: dropErrorMessage,
});
return;
}
handleOnDrop(source, destination);
highlightIssueOnDrop(
getIssueBlockId(source.id, destination?.groupId, destination?.subGroupId),
orderBy !== "sort_order"
);
},
}),
autoScrollForElements({
element,
})
);
}, [
columnRef,
groupId,
sub_group_id,
setIsDraggingOverColumn,
orderBy,
isDropDisabled,
isWorkflowDropDisabled,
dropErrorMessage,
handleOnDrop,
]);
const prePopulateQuickAddData = (
groupByKey: string | undefined,
subGroupByKey: string | undefined | null,
groupValue: string,
subGroupValue: string
) => {
const defaultState = projectState.projectStates?.find((state) => state.default);
let preloadedData: object = { state_id: defaultState?.id };
if (groupByKey) {
if (groupByKey === "state") {
preloadedData = { ...preloadedData, state_id: groupValue };
} else if (groupByKey === "priority") {
preloadedData = { ...preloadedData, priority: groupValue };
} else if (groupByKey === "cycle") {
preloadedData = { ...preloadedData, cycle_id: groupValue };
} else if (groupByKey === "module") {
preloadedData = { ...preloadedData, module_ids: [groupValue] };
} else if (groupByKey === "labels" && groupValue != "None") {
preloadedData = { ...preloadedData, label_ids: [groupValue] };
} else if (groupByKey === "assignees" && groupValue != "None") {
preloadedData = { ...preloadedData, assignee_ids: [groupValue] };
} else if (groupByKey === "created_by") {
preloadedData = { ...preloadedData };
} else {
preloadedData = { ...preloadedData, [groupByKey]: groupValue };
}
}
if (subGroupByKey) {
if (subGroupByKey === "state") {
preloadedData = { ...preloadedData, state_id: subGroupValue };
} else if (subGroupByKey === "priority") {
preloadedData = { ...preloadedData, priority: subGroupValue };
} else if (subGroupByKey === "cycle") {
preloadedData = { ...preloadedData, cycle_id: subGroupValue };
} else if (subGroupByKey === "module") {
preloadedData = { ...preloadedData, module_ids: [subGroupValue] };
} else if (subGroupByKey === "labels" && subGroupValue != "None") {
preloadedData = { ...preloadedData, label_ids: [subGroupValue] };
} else if (subGroupByKey === "assignees" && subGroupValue != "None") {
preloadedData = { ...preloadedData, assignee_ids: [subGroupValue] };
} else if (subGroupByKey === "created_by") {
preloadedData = { ...preloadedData };
} else {
preloadedData = { ...preloadedData, [subGroupByKey]: subGroupValue };
}
}
return preloadedData;
};
const isSubGroup = !!sub_group_id && sub_group_id !== "null";
const issueIds = isSubGroup
? ((groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[sub_group_id] ?? [])
: ((groupedIssueIds as TGroupedIssues)?.[groupId] ?? []);
const groupIssueCount = getGroupIssueCount(groupId, sub_group_id, false) ?? 0;
const nextPageResults = getPaginationData(groupId, sub_group_id)?.nextPageResults;
const loadMore = isPaginating ? (
<KanbanIssueBlockLoader />
) : (
<div
className="w-full sticky bottom-0 p-3 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 hover:underline cursor-pointer"
onClick={loadMoreIssuesInThisGroup}
>
{t("common.load_more")} &darr;
</div>
);
const shouldLoadMore = nextPageResults === undefined ? issueIds?.length < groupIssueCount : !!nextPageResults;
const canOverlayBeVisible = isWorkflowDropDisabled || orderBy !== "sort_order" || isDropDisabled;
const shouldOverlayBeVisible = isDraggingOverColumn && canOverlayBeVisible;
const canDragIssuesInCurrentGrouping =
!!group_by &&
DRAG_ALLOWED_GROUPS.includes(group_by) &&
(!!sub_group_by ? DRAG_ALLOWED_GROUPS.includes(sub_group_by) : true);
return (
<div
id={`${groupId}__${sub_group_id}`}
className={cn(
"relative h-full transition-all min-h-[120px]",
{ "bg-custom-background-80 rounded": isDraggingOverColumn },
{ "vertical-scrollbar scrollbar-md": !sub_group_by && !shouldOverlayBeVisible }
)}
ref={columnRef}
>
<GroupDragOverlay
dragColumnOrientation={sub_group_by ? "justify-start" : "justify-center"}
canOverlayBeVisible={canOverlayBeVisible}
isDropDisabled={isWorkflowDropDisabled || isDropDisabled}
workflowDisabledSource={workflowDisabledSource}
dropErrorMessage={dropErrorMessage}
orderBy={orderBy}
isDraggingOverColumn={isDraggingOverColumn}
isEpic={isEpic}
/>
<KanbanIssueBlocksList
sub_group_id={sub_group_id}
groupId={groupId}
issuesMap={issuesMap}
issueIds={issueIds || []}
displayProperties={displayProperties}
updateIssue={updateIssue}
quickActions={quickActions}
canEditProperties={canEditProperties}
scrollableContainerRef={scrollableContainerRef}
canDropOverIssue={!canOverlayBeVisible}
canDragIssuesInCurrentGrouping={canDragIssuesInCurrentGrouping}
isEpic={isEpic}
/>
{shouldLoadMore && (isSubGroup ? <>{loadMore}</> : <KanbanIssueBlockLoader ref={setIntersectionElement} />)}
{enableQuickIssueCreate &&
!disableIssueCreation &&
!getIsWorkflowWorkItemCreationDisabled(groupId, sub_group_id) && (
<div className="w-full bg-custom-background-90 py-0.5 sticky bottom-0">
<QuickAddIssueRoot
layout={EIssueLayoutTypes.KANBAN}
QuickAddButton={KanbanQuickAddIssueButton}
prePopulatedData={{
...(group_by && prePopulateQuickAddData(group_by, sub_group_by, groupId, sub_group_id)),
}}
quickAddCallback={quickAddCallback}
isEpic={isEpic}
/>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,52 @@
import React, { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { CycleIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseKanBanRoot } from "../base-kanban-root";
export const CycleKanBanLayout: React.FC = observer(() => {
const { workspaceSlug, projectId, cycleId } = useParams();
// store
const { issues } = useIssues(EIssuesStoreType.CYCLE);
const { currentProjectCompletedCycleIds } = useCycle();
const { allowPermissions } = useUserPermissions();
const isCompletedCycle =
cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false;
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const canEditIssueProperties = useCallback(
() => !isCompletedCycle && isEditingAllowed,
[isCompletedCycle, isEditingAllowed]
);
const addIssuesToView = useCallback(
(issueIds: string[]) => {
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
},
[issues?.addIssueToCycle, workspaceSlug, projectId, cycleId]
);
return (
<BaseKanBanRoot
QuickActions={CycleIssueQuickActions}
addIssuesToView={addIssuesToView}
canEditPropertiesBasedOnProject={canEditIssueProperties}
isCompletedCycle={isCompletedCycle}
viewId={cycleId?.toString()}
/>
);
});

View File

@@ -0,0 +1,28 @@
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssuesStoreType } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
// local imports
import { ModuleIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseKanBanRoot } from "../base-kanban-root";
export const ModuleKanBanLayout: React.FC = observer(() => {
const { workspaceSlug, projectId, moduleId } = useParams();
// store
const { issues } = useIssues(EIssuesStoreType.MODULE);
return (
<BaseKanBanRoot
QuickActions={ModuleIssueQuickActions}
addIssuesToView={(issueIds: string[]) => {
if (!workspaceSlug || !projectId || !moduleId) throw new Error();
return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds);
}}
viewId={moduleId?.toString()}
/>
);
});

View File

@@ -0,0 +1,31 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { ProjectIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseKanBanRoot } from "../base-kanban-root";
export const ProfileIssuesKanBanLayout: React.FC = observer(() => {
// router
const { workspaceSlug, profileViewId } = useParams();
const { allowPermissions } = useUserPermissions();
const canEditPropertiesBasedOnProject = (projectId: string) =>
allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId
);
return (
<BaseKanBanRoot
QuickActions={ProjectIssueQuickActions}
canEditPropertiesBasedOnProject={canEditPropertiesBasedOnProject}
viewId={profileViewId?.toString()}
/>
);
});

View File

@@ -0,0 +1,31 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { ProjectIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseKanBanRoot } from "../base-kanban-root";
export const KanBanLayout: React.FC = observer(() => {
// router
const { workspaceSlug } = useParams();
// hooks
const { allowPermissions } = useUserPermissions();
// derived values
const canEditPropertiesBasedOnProject = (projectId: string) =>
allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString(),
projectId
);
return (
<BaseKanBanRoot
QuickActions={ProjectIssueQuickActions}
canEditPropertiesBasedOnProject={canEditPropertiesBasedOnProject}
/>
);
});

View File

@@ -0,0 +1,15 @@
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
// constant
// types
import { ProjectIssueQuickActions } from "../../quick-action-dropdowns";
// components
import { BaseKanBanRoot } from "../base-kanban-root";
export const ProjectViewKanBanLayout: React.FC = observer(() => {
const { viewId } = useParams();
return <BaseKanBanRoot QuickActions={ProjectIssueQuickActions} viewId={viewId.toString()} />;
});

View File

@@ -0,0 +1,328 @@
import type { MutableRefObject } from "react";
import { observer } from "mobx-react";
import type {
GroupByColumnTypes,
IGroupByColumn,
TGroupedIssues,
TIssue,
IIssueDisplayProperties,
IIssueMap,
TSubGroupedIssues,
TIssueKanbanFilters,
TIssueGroupByOptions,
TIssueOrderByOptions,
} from "@plane/types";
// UI
import { Row } from "@plane/ui";
// hooks
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
// components
import { useWorkFlowFDragNDrop } from "@/plane-web/components/workflow";
import type { TRenderQuickActions } from "../list/list-view-types";
import type { GroupDropLocation } from "../utils";
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
import { KanBan } from "./default";
import { HeaderGroupByCard } from "./headers/group-by-card";
import { HeaderSubGroupByCard } from "./headers/sub-group-by-card";
// types
// constants
interface ISubGroupSwimlaneHeader {
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
sub_group_by: TIssueGroupByOptions | undefined;
group_by: TIssueGroupByOptions | undefined;
list: IGroupByColumn[];
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
showEmptyGroup: boolean;
}
const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGroup: boolean): boolean => {
let subGroupHeaderVisibility = true;
if (showEmptyGroup) subGroupHeaderVisibility = true;
else {
if (subGroupIssueCount > 0) subGroupHeaderVisibility = true;
else subGroupHeaderVisibility = false;
}
return subGroupHeaderVisibility;
};
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = observer(
({ getGroupIssueCount, sub_group_by, group_by, list, collapsedGroups, handleCollapsedGroups, showEmptyGroup }) => {
const { getIsWorkflowWorkItemCreationDisabled } = useWorkFlowFDragNDrop(group_by, sub_group_by);
return (
<div className="relative flex h-max min-h-full w-full items-center gap-4">
{list &&
list.length > 0 &&
list.map((_list: IGroupByColumn) => {
const groupCount = getGroupIssueCount(_list?.id, undefined, false) ?? 0;
const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(groupCount, showEmptyGroup);
if (subGroupByVisibilityToggle === false) return <></>;
return (
<div key={`${sub_group_by}_${_list.id}`} className="flex w-[350px] flex-shrink-0 flex-col">
<HeaderGroupByCard
sub_group_by={sub_group_by}
group_by={group_by}
column_id={_list.id}
icon={_list.icon}
title={_list.name}
count={groupCount}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
issuePayload={_list.payload}
disableIssueCreation={getIsWorkflowWorkItemCreationDisabled(_list.id)}
/>
</div>
);
})}
</div>
);
}
);
interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
issuesMap: IIssueMap;
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
showEmptyGroup: boolean;
displayProperties: IIssueDisplayProperties | undefined;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
disableIssueCreation?: boolean;
enableQuickIssueCreate: boolean;
orderBy: TIssueOrderByOptions | undefined;
canEditProperties: (projectId: string | undefined) => boolean;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
}
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
const {
issuesMap,
groupedIssueIds,
getGroupIssueCount,
sub_group_by,
group_by,
list,
updateIssue,
quickActions,
displayProperties,
collapsedGroups,
handleCollapsedGroups,
loadMoreIssues,
showEmptyGroup,
enableQuickIssueCreate,
disableIssueCreation,
canEditProperties,
addIssuesToView,
quickAddCallback,
scrollableContainerRef,
handleOnDrop,
orderBy,
} = props;
const visibilitySubGroupBy = (
_list: IGroupByColumn,
subGroupCount: number
): { showGroup: boolean; showIssues: boolean } => {
const subGroupVisibility = {
showGroup: true,
showIssues: true,
};
if (showEmptyGroup) subGroupVisibility.showGroup = true;
else {
if (subGroupCount > 0) subGroupVisibility.showGroup = true;
else subGroupVisibility.showGroup = false;
}
if (collapsedGroups?.sub_group_by.includes(_list.id)) subGroupVisibility.showIssues = false;
return subGroupVisibility;
};
return (
<div className="relative h-max min-h-full w-full">
{list &&
list.length > 0 &&
list.map((_list: IGroupByColumn, subGroupIndex) => {
const issueCount = getGroupIssueCount(undefined, _list.id, true) ?? 0;
const subGroupByVisibilityToggle = visibilitySubGroupBy(_list, issueCount);
if (subGroupByVisibilityToggle.showGroup === false) return <></>;
return (
<div key={_list.id} className="flex flex-shrink-0 flex-col">
<div className="sticky top-[50px] z-[3] py-1 flex w-full items-center bg-custom-background-100 border-y-[0.5px] border-custom-border-200">
<Row className="sticky left-0 flex-shrink-0">
<HeaderSubGroupByCard
column_id={_list.id}
icon={_list.icon}
title={_list.name}
count={issueCount}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
sub_group_by={sub_group_by}
/>
</Row>
</div>
{subGroupByVisibilityToggle.showIssues && (
<div className="relative">
<KanBan
issuesMap={issuesMap}
groupedIssueIds={groupedIssueIds}
getGroupIssueCount={getGroupIssueCount}
displayProperties={displayProperties}
sub_group_by={sub_group_by}
group_by={group_by}
sub_group_id={_list.id}
subGroupIndex={subGroupIndex}
updateIssue={updateIssue}
quickActions={quickActions}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
showEmptyGroup={showEmptyGroup}
enableQuickIssueCreate={enableQuickIssueCreate}
disableIssueCreation={disableIssueCreation}
canEditProperties={canEditProperties}
addIssuesToView={addIssuesToView}
quickAddCallback={quickAddCallback}
scrollableContainerRef={scrollableContainerRef}
loadMoreIssues={loadMoreIssues}
handleOnDrop={handleOnDrop}
orderBy={orderBy}
isDropDisabled={_list.isDropDisabled}
dropErrorMessage={_list.dropErrorMessage}
/>
</div>
)}
</div>
);
})}
</div>
);
});
export interface IKanBanSwimLanes {
issuesMap: IIssueMap;
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
displayProperties: IIssueDisplayProperties | undefined;
sub_group_by: TIssueGroupByOptions | undefined;
group_by: TIssueGroupByOptions | undefined;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
showEmptyGroup: boolean;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
disableIssueCreation?: boolean;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
enableQuickIssueCreate: boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
orderBy: TIssueOrderByOptions | undefined;
}
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
const {
issuesMap,
groupedIssueIds,
getGroupIssueCount,
displayProperties,
sub_group_by,
group_by,
orderBy,
updateIssue,
quickActions,
collapsedGroups,
handleCollapsedGroups,
loadMoreIssues,
showEmptyGroup,
handleOnDrop,
disableIssueCreation,
enableQuickIssueCreate,
canEditProperties,
addIssuesToView,
quickAddCallback,
scrollableContainerRef,
} = props;
// store hooks
const storeType = useIssueStoreType();
// derived values
const groupByList = getGroupByColumns({
groupBy: group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
});
const subGroupByList = getGroupByColumns({
groupBy: sub_group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
});
if (!groupByList || !subGroupByList) return null;
return (
<div className="relative">
<Row className="sticky top-0 z-[4] h-[50px] bg-custom-background-90">
<SubGroupSwimlaneHeader
getGroupIssueCount={getGroupIssueCount}
group_by={group_by}
sub_group_by={sub_group_by}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
list={groupByList}
showEmptyGroup={showEmptyGroup}
/>
</Row>
{sub_group_by && (
<SubGroupSwimlane
issuesMap={issuesMap}
list={subGroupByList}
groupedIssueIds={groupedIssueIds}
getGroupIssueCount={getGroupIssueCount}
displayProperties={displayProperties}
group_by={group_by}
sub_group_by={sub_group_by}
orderBy={orderBy}
updateIssue={updateIssue}
quickActions={quickActions}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
loadMoreIssues={loadMoreIssues}
showEmptyGroup={showEmptyGroup}
handleOnDrop={handleOnDrop}
disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate}
addIssuesToView={addIssuesToView}
canEditProperties={canEditProperties}
quickAddCallback={quickAddCallback}
scrollableContainerRef={scrollableContainerRef}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,20 @@
import type { LucideProps } from "lucide-react";
import { List, Kanban, Calendar, Sheet, GanttChartSquare } from "lucide-react";
import { EIssueLayoutTypes } from "@plane/types";
export const IssueLayoutIcon = ({ layout, ...props }: { layout: EIssueLayoutTypes } & LucideProps) => {
switch (layout) {
case EIssueLayoutTypes.LIST:
return <List {...props} />;
case EIssueLayoutTypes.KANBAN:
return <Kanban {...props} />;
case EIssueLayoutTypes.CALENDAR:
return <Calendar {...props} />;
case EIssueLayoutTypes.SPREADSHEET:
return <Sheet {...props} />;
case EIssueLayoutTypes.GANTT:
return <GanttChartSquare {...props} />;
default:
return null;
}
};

View File

@@ -0,0 +1,176 @@
import type { FC } from "react";
import { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane constants
import { EIssueFilterType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
// types
import type { EIssuesStoreType, GroupByColumnTypes, TGroupedIssues, TIssueKanbanFilters } from "@plane/types";
import { EIssueLayoutTypes } from "@plane/types";
// constants
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
// hooks
import { useGroupIssuesDragNDrop } from "@/hooks/use-group-dragndrop";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { useIssuesActions } from "@/hooks/use-issues-actions";
// components
import { IssueLayoutHOC } from "../issue-layout-HOC";
import { List } from "./default";
// types
import type { IQuickActionProps, TRenderQuickActions } from "./list-view-types";
type ListStoreType =
| EIssuesStoreType.PROJECT
| EIssuesStoreType.MODULE
| EIssuesStoreType.CYCLE
| EIssuesStoreType.PROJECT_VIEW
| EIssuesStoreType.PROFILE
| EIssuesStoreType.ARCHIVED
| EIssuesStoreType.WORKSPACE_DRAFT
| EIssuesStoreType.TEAM
| EIssuesStoreType.TEAM_VIEW
| EIssuesStoreType.EPIC;
interface IBaseListRoot {
QuickActions: FC<IQuickActionProps>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
canEditPropertiesBasedOnProject?: (projectId: string) => boolean;
viewId?: string | undefined;
isCompletedCycle?: boolean;
isEpic?: boolean;
}
export const BaseListRoot = observer((props: IBaseListRoot) => {
const {
QuickActions,
viewId,
addIssuesToView,
canEditPropertiesBasedOnProject,
isCompletedCycle = false,
isEpic = false,
} = props;
// router
const storeType = useIssueStoreType() as ListStoreType;
//stores
const { issuesFilter, issues } = useIssues(storeType);
const {
fetchIssues,
fetchNextIssues,
quickAddIssue,
updateIssue,
removeIssue,
removeIssueFromView,
archiveIssue,
restoreIssue,
} = useIssuesActions(storeType);
// mobx store
const { allowPermissions } = useUserPermissions();
const { issueMap } = useIssues();
const displayFilters = issuesFilter?.issueFilters?.displayFilters;
const displayProperties = issuesFilter?.issueFilters?.displayProperties;
const orderBy = displayFilters?.order_by || undefined;
const group_by = (displayFilters?.group_by || null) as GroupByColumnTypes | null;
const showEmptyGroup = displayFilters?.show_empty_groups ?? false;
const { workspaceSlug, projectId } = useParams();
const { updateFilters } = useIssuesActions(storeType);
const collapsedGroups =
issuesFilter?.issueFilters?.kanbanFilters || ({ group_by: [], sub_group_by: [] } as TIssueKanbanFilters);
useEffect(() => {
fetchIssues("init-loader", { canGroup: true, perPageCount: group_by ? 50 : 100 }, viewId);
}, [fetchIssues, storeType, group_by, viewId]);
const groupedIssueIds = issues?.groupedIssueIds as TGroupedIssues | undefined;
// auth
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {};
const canEditProperties = useCallback(
(projectId: string | undefined) => {
const isEditingAllowedBasedOnProject =
canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed;
return !!enableInlineEditing && isEditingAllowedBasedOnProject;
},
[canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed]
);
const handleOnDrop = useGroupIssuesDragNDrop(storeType, orderBy, group_by);
const renderQuickActions: TRenderQuickActions = useCallback(
({ issue, parentRef }) => (
<QuickActions
parentRef={parentRef}
issue={issue}
handleDelete={async () => removeIssue(issue.project_id, issue.id)}
handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)}
handleRemoveFromView={async () => removeIssueFromView && removeIssueFromView(issue.project_id, issue.id)}
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
readOnly={!canEditProperties(issue.project_id ?? undefined) || isCompletedCycle}
/>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[isCompletedCycle, canEditProperties, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue]
);
const loadMoreIssues = useCallback(
(groupId?: string) => {
fetchNextIssues(groupId);
},
[fetchNextIssues]
);
// kanbanFilters and EIssueFilterType.KANBAN_FILTERS are used because the state is shared between kanban view and list view
const handleCollapsedGroups = useCallback(
(value: string) => {
if (workspaceSlug) {
let collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters?.group_by || [];
if (collapsedGroups.includes(value)) {
collapsedGroups = collapsedGroups.filter((_value) => _value != value);
} else {
collapsedGroups.push(value);
}
updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, {
group_by: collapsedGroups,
} as TIssueKanbanFilters);
}
},
[workspaceSlug, issuesFilter, projectId, updateFilters]
);
return (
<IssueLayoutHOC layout={EIssueLayoutTypes.LIST}>
<div className={`relative size-full bg-custom-background-90`}>
<List
issuesMap={issueMap}
displayProperties={displayProperties}
group_by={group_by}
orderBy={orderBy}
updateIssue={updateIssue}
quickActions={renderQuickActions}
groupedIssueIds={groupedIssueIds ?? {}}
loadMoreIssues={loadMoreIssues}
showEmptyGroup={showEmptyGroup}
quickAddCallback={quickAddIssue}
enableIssueQuickAdd={!!enableQuickAdd}
canEditProperties={canEditProperties}
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
addIssuesToView={addIssuesToView}
isCompletedCycle={isCompletedCycle}
handleOnDrop={handleOnDrop}
handleCollapsedGroups={handleCollapsedGroups}
collapsedGroups={collapsedGroups}
isEpic={isEpic}
/>
</div>
</IssueLayoutHOC>
);
});

View File

@@ -0,0 +1,186 @@
"use client";
import type { FC, MutableRefObject } from "react";
import React, { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// types
import type { IIssueDisplayProperties, TIssue, TIssueMap } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// components
import { DropIndicator } from "@plane/ui";
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { ListLoaderItemRow } from "@/components/ui/loader/layouts/list-layout-loader";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import type { TSelectionHelper } from "@/hooks/use-multiple-select";
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import { HIGHLIGHT_CLASS, getIssueBlockId, isIssueNew } from "../utils";
import { IssueBlock } from "./block";
import type { TRenderQuickActions } from "./list-view-types";
type Props = {
issueId: string;
issuesMap: TIssueMap;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
canEditProperties: (projectId: string | undefined) => boolean;
displayProperties: IIssueDisplayProperties | undefined;
nestingLevel: number;
spacingLeft?: number;
containerRef: MutableRefObject<HTMLDivElement | null>;
selectionHelpers: TSelectionHelper;
groupId: string;
isDragAllowed: boolean;
canDropOverIssue: boolean;
isParentIssueBeingDragged?: boolean;
isLastChild?: boolean;
shouldRenderByDefault?: boolean;
isEpic?: boolean;
};
export const IssueBlockRoot: FC<Props> = observer((props) => {
const {
issueId,
issuesMap,
groupId,
updateIssue,
quickActions,
canEditProperties,
displayProperties,
nestingLevel,
spacingLeft = 14,
containerRef,
isDragAllowed,
canDropOverIssue,
isParentIssueBeingDragged = false,
isLastChild = false,
selectionHelpers,
shouldRenderByDefault,
isEpic = false,
} = props;
// states
const [isExpanded, setExpanded] = useState<boolean>(false);
const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined);
const [isCurrentBlockDragging, setIsCurrentBlockDragging] = useState(false);
// ref
const issueBlockRef = useRef<HTMLDivElement | null>(null);
// hooks
const { isMobile } = usePlatformOS();
// store hooks
const { subIssues: subIssuesStore } = useIssueDetail(isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
const isSubIssue = nestingLevel !== 0;
useEffect(() => {
const blockElement = issueBlockRef.current;
if (!blockElement) return;
return combine(
dropTargetForElements({
element: blockElement,
canDrop: ({ source }) => source?.data?.id !== issueId && !isSubIssue && canDropOverIssue,
getData: ({ input, element }) => {
const data = { id: issueId, type: "ISSUE" };
// attach instruction for last in list
return attachInstruction(data, {
input,
element,
currentLevel: 0,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
});
},
onDrag: ({ self }) => {
const extractedInstruction = extractInstruction(self?.data)?.type;
// check if the highlight is to be shown above or below
setInstruction(
extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined
);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: () => {
setInstruction(undefined);
},
})
);
}, [issueId, isLastChild, issueBlockRef, isSubIssue, canDropOverIssue, setInstruction]);
useOutsideClickDetector(issueBlockRef, () => {
issueBlockRef?.current?.classList?.remove(HIGHLIGHT_CLASS);
});
if (!issueId || !issuesMap[issueId]?.created_at) return null;
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
return (
<div className="relative" ref={issueBlockRef} id={getIssueBlockId(issueId, groupId)}>
<DropIndicator classNames={"absolute top-0 z-[2]"} isVisible={instruction === "DRAG_OVER"} />
<RenderIfVisible
key={`${issueId}`}
root={containerRef}
classNames={`relative ${isLastChild && !isExpanded ? "" : "border-b border-b-custom-border-200"}`}
verticalOffset={100}
defaultValue={shouldRenderByDefault || isIssueNew(issuesMap[issueId])}
placeholderChildren={<ListLoaderItemRow shouldAnimate={false} renderForPlaceHolder defaultPropertyCount={4} />}
shouldRecordHeights={isMobile}
>
<IssueBlock
issueId={issueId}
issuesMap={issuesMap}
groupId={groupId}
updateIssue={updateIssue}
quickActions={quickActions}
canEditProperties={canEditProperties}
displayProperties={displayProperties}
isExpanded={isExpanded}
setExpanded={setExpanded}
nestingLevel={nestingLevel}
spacingLeft={spacingLeft}
selectionHelpers={selectionHelpers}
canDrag={!isSubIssue && isDragAllowed}
isCurrentBlockDragging={isParentIssueBeingDragged || isCurrentBlockDragging}
setIsCurrentBlockDragging={setIsCurrentBlockDragging}
isEpic={isEpic}
/>
</RenderIfVisible>
{isExpanded &&
!isEpic &&
subIssues?.map((subIssueId) => (
<IssueBlockRoot
key={`${subIssueId}`}
issueId={subIssueId}
issuesMap={issuesMap}
updateIssue={updateIssue}
quickActions={quickActions}
canEditProperties={canEditProperties}
displayProperties={displayProperties}
nestingLevel={nestingLevel + 1}
spacingLeft={spacingLeft + 12}
containerRef={containerRef}
selectionHelpers={selectionHelpers}
groupId={groupId}
isDragAllowed={isDragAllowed}
canDropOverIssue={canDropOverIssue}
isParentIssueBeingDragged={isParentIssueBeingDragged || isCurrentBlockDragging}
shouldRenderByDefault={isExpanded}
/>
))}
{isLastChild && <DropIndicator classNames={"absolute z-[2]"} isVisible={instruction === "DRAG_BELOW"} />}
</div>
);
});

View File

@@ -0,0 +1,338 @@
"use client";
import type { Dispatch, MouseEvent, SetStateAction } from "react";
import { useEffect, useRef } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ChevronRight } from "lucide-react";
// types
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import { Spinner, ControlLink, Row } from "@plane/ui";
import { cn, generateWorkItemLink } from "@plane/utils";
// components
import { MultipleSelectEntityAction } from "@/components/core/multiple-select";
import { IssueProperties } from "@/components/issues/issue-layouts/properties";
// helpers
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import type { TSelectionHelper } from "@/hooks/use-multiple-select";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
import { IssueStats } from "@/plane-web/components/issues/issue-layouts/issue-stats";
// types
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import type { TRenderQuickActions } from "./list-view-types";
interface IssueBlockProps {
issueId: string;
issuesMap: TIssueMap;
groupId: string;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
displayProperties: IIssueDisplayProperties | undefined;
canEditProperties: (projectId: string | undefined) => boolean;
nestingLevel: number;
spacingLeft?: number;
isExpanded: boolean;
setExpanded: Dispatch<SetStateAction<boolean>>;
selectionHelpers: TSelectionHelper;
isCurrentBlockDragging: boolean;
setIsCurrentBlockDragging: React.Dispatch<React.SetStateAction<boolean>>;
canDrag: boolean;
isEpic?: boolean;
}
export const IssueBlock = observer((props: IssueBlockProps) => {
const {
issuesMap,
issueId,
groupId,
updateIssue,
quickActions,
displayProperties,
canEditProperties,
nestingLevel,
spacingLeft = 14,
isExpanded,
setExpanded,
selectionHelpers,
isCurrentBlockDragging,
setIsCurrentBlockDragging,
canDrag,
isEpic = false,
} = props;
// ref
const issueRef = useRef<HTMLDivElement | null>(null);
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const projectId = routerProjectId?.toString();
// hooks
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { getProjectIdentifierById } = useProject();
const {
getIsIssuePeeked,
peekIssue,
setPeekIssue,
subIssues: subIssuesStore,
} = useIssueDetail(isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
const handleIssuePeekOverview = (issue: TIssue) =>
workspaceSlug &&
issue &&
issue.project_id &&
issue.id &&
!getIsIssuePeeked(issue.id) &&
setPeekIssue({
workspaceSlug,
projectId: issue.project_id,
issueId: issue.id,
nestingLevel: nestingLevel,
isArchived: !!issue.archived_at,
});
// derived values
const issue = issuesMap[issueId];
const subIssuesCount = issue?.sub_issues_count ?? 0;
const canEditIssueProperties = canEditProperties(issue?.project_id ?? undefined);
const isDraggingAllowed = canDrag && canEditIssueProperties;
const { isMobile } = usePlatformOS();
useEffect(() => {
const element = issueRef.current;
if (!element) return;
return combine(
draggable({
element,
canDrag: () => isDraggingAllowed,
getInitialData: () => ({ id: issueId, type: "ISSUE", groupId }),
onDragStart: () => {
setIsCurrentBlockDragging(true);
},
onDrop: () => {
setIsCurrentBlockDragging(false);
},
})
);
}, [isDraggingAllowed, issueId, groupId, setIsCurrentBlockDragging]);
if (!issue) return null;
const projectIdentifier = getProjectIdentifierById(issue.project_id);
const isIssueSelected = selectionHelpers.getIsEntitySelected(issue.id);
const isIssueActive = selectionHelpers.getIsEntityActive(issue.id);
const isSubIssue = nestingLevel !== 0;
const canSelectIssues = canEditIssueProperties && !selectionHelpers.isSelectionDisabled;
const marginLeft = `${spacingLeft}px`;
const handleToggleExpand = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (nestingLevel >= 3) {
handleIssuePeekOverview(issue);
} else {
setExpanded((prevState) => {
if (!prevState && workspaceSlug && issue && issue.project_id)
subIssuesStore.fetchSubIssues(workspaceSlug.toString(), issue.project_id, issue.id);
return !prevState;
});
}
};
//TODO: add better logic. This is to have a min width for ID/Key based on the length of project identifier
const keyMinWidth = displayProperties?.key ? (projectIdentifier?.length ?? 0) * 7 : 0;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!issue?.archived_at,
});
return (
<ControlLink
id={`issue-${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="w-full cursor-pointer"
disabled={!!issue?.tempId || issue?.is_draft}
>
<Row
ref={issueRef}
className={cn(
"group/list-block min-h-11 relative flex flex-col gap-3 bg-custom-background-100 hover:bg-custom-background-90 py-3 text-sm transition-colors border border-transparent",
{
"border-custom-primary-70": getIsIssuePeeked(issue.id) && peekIssue?.nestingLevel === nestingLevel,
"border-custom-border-400": isIssueActive,
"last:border-b-transparent": !getIsIssuePeeked(issue.id) && !isIssueActive,
"bg-custom-primary-100/5 hover:bg-custom-primary-100/10": isIssueSelected,
"bg-custom-background-80": isCurrentBlockDragging,
"md:flex-row md:items-center": isSidebarCollapsed,
"lg:flex-row lg:items-center": !isSidebarCollapsed,
}
)}
onDragStart={() => {
if (!isDraggingAllowed) {
setToast({
type: TOAST_TYPE.WARNING,
title: "Cannot move work item",
message: !canEditIssueProperties
? "You are not allowed to move this work item"
: "Drag and drop is disabled for the current grouping",
});
}
}}
>
<div className="flex gap-2 w-full truncate">
<div className="flex flex-grow items-center gap-0.5 truncate">
<div className="flex items-center gap-1" style={isSubIssue ? { marginLeft } : {}}>
{/* select checkbox */}
{projectId && canSelectIssues && !isEpic && (
<Tooltip
tooltipContent={
<>
Only work items within the current
<br />
project can be selected.
</>
}
disabled={issue.project_id === projectId}
>
<div className="flex-shrink-0 grid place-items-center w-3.5 absolute left-1">
<MultipleSelectEntityAction
className={cn(
"opacity-0 pointer-events-none group-hover/list-block:opacity-100 group-hover/list-block:pointer-events-auto transition-opacity",
{
"opacity-100 pointer-events-auto": isIssueSelected,
}
)}
groupId={groupId}
id={issue.id}
selectionHelpers={selectionHelpers}
disabled={issue.project_id !== projectId}
/>
</div>
</Tooltip>
)}
{displayProperties && (displayProperties.key || displayProperties.issue_type) && (
<div className="flex-shrink-0" style={{ minWidth: `${keyMinWidth}px` }}>
{issue.project_id && (
<IssueIdentifier
issueId={issueId}
projectId={issue.project_id}
textContainerClassName="text-xs font-medium text-custom-text-300"
displayProperties={displayProperties}
/>
)}
</div>
)}
{/* sub-issues chevron */}
<div className="size-4 grid place-items-center flex-shrink-0">
{subIssuesCount > 0 && !isEpic && (
<button
type="button"
className="size-4 grid place-items-center rounded-sm text-custom-text-400 hover:text-custom-text-300"
onClick={handleToggleExpand}
>
<ChevronRight
className={cn("size-4", {
"rotate-90": isExpanded,
})}
strokeWidth={2.5}
/>
</button>
)}
</div>
{issue?.tempId !== undefined && (
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
)}
</div>
<Tooltip
tooltipContent={issue.name}
isMobile={isMobile}
position="top-start"
disabled={isCurrentBlockDragging}
renderByDefault={false}
>
<p className="truncate cursor-pointer text-sm text-custom-text-100">{issue.name}</p>
</Tooltip>
{isEpic && displayProperties && (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="sub_issue_count"
shouldRenderProperty={(properties) => !!properties.sub_issue_count}
>
<IssueStats issueId={issue.id} className="ml-2 font-medium text-custom-text-350" />
</WithDisplayPropertiesHOC>
)}
</div>
{!issue?.tempId && (
<div
className={cn("block border border-custom-border-300 rounded", {
"md:hidden": isSidebarCollapsed,
"lg:hidden": !isSidebarCollapsed,
})}
>
{quickActions({
issue,
parentRef: issueRef,
})}
</div>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{!issue?.tempId ? (
<>
<IssueProperties
className={`relative flex flex-wrap ${isSidebarCollapsed ? "md:flex-grow md:flex-shrink-0" : "lg:flex-grow lg:flex-shrink-0"} items-center gap-2 whitespace-nowrap`}
issue={issue}
isReadOnly={!canEditIssueProperties}
updateIssue={updateIssue}
displayProperties={displayProperties}
activeLayout="List"
isEpic={isEpic}
/>
<div
className={cn("hidden", {
"md:flex": isSidebarCollapsed,
"lg:flex": !isSidebarCollapsed,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{quickActions({
issue,
parentRef: issueRef,
})}
</div>
</>
) : (
<div className="h-4 w-4">
<Spinner className="h-4 w-4" />
</div>
)}
</div>
</Row>
</ControlLink>
);
});

View File

@@ -0,0 +1,67 @@
import type { FC, MutableRefObject } from "react";
// components
import type { TIssue, IIssueDisplayProperties, TIssueMap, TGroupedIssues } from "@plane/types";
// hooks
import type { TSelectionHelper } from "@/hooks/use-multiple-select";
// types
import { IssueBlockRoot } from "./block-root";
import type { TRenderQuickActions } from "./list-view-types";
interface Props {
issueIds: TGroupedIssues | any;
issuesMap: TIssueMap;
groupId: string;
canEditProperties: (projectId: string | undefined) => boolean;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
displayProperties: IIssueDisplayProperties | undefined;
containerRef: MutableRefObject<HTMLDivElement | null>;
isDragAllowed: boolean;
canDropOverIssue: boolean;
selectionHelpers: TSelectionHelper;
isEpic?: boolean;
}
export const IssueBlocksList: FC<Props> = (props) => {
const {
issueIds,
issuesMap,
groupId,
updateIssue,
quickActions,
displayProperties,
canEditProperties,
containerRef,
selectionHelpers,
isDragAllowed,
canDropOverIssue,
isEpic = false,
} = props;
return (
<div className="relative h-full w-full">
{issueIds &&
issueIds.length > 0 &&
issueIds.map((issueId: string, index: number) => (
<IssueBlockRoot
key={issueId}
issueId={issueId}
issuesMap={issuesMap}
updateIssue={updateIssue}
quickActions={quickActions}
canEditProperties={canEditProperties}
displayProperties={displayProperties}
nestingLevel={0}
spacingLeft={0}
containerRef={containerRef}
selectionHelpers={selectionHelpers}
groupId={groupId}
isLastChild={index === issueIds.length - 1}
isDragAllowed={isDragAllowed}
canDropOverIssue={canDropOverIssue}
isEpic={isEpic}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,176 @@
import { useEffect, useRef } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
// plane constants
import { ALL_ISSUES } from "@plane/constants";
// types
import type {
GroupByColumnTypes,
TGroupedIssues,
TIssue,
IIssueDisplayProperties,
TIssueMap,
TIssueGroupByOptions,
TIssueOrderByOptions,
IGroupByColumn,
TIssueKanbanFilters,
} from "@plane/types";
// components
import { MultipleSelectGroup } from "@/components/core/multiple-select";
// hooks
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
// plane web components
import { IssueBulkOperationsRoot } from "@/plane-web/components/issues/bulk-operations";
// plane web hooks
import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status";
// utils
import type { GroupDropLocation } from "../utils";
import { getGroupByColumns, isWorkspaceLevel, isSubGrouped } from "../utils";
import { ListGroup } from "./list-group";
import type { TRenderQuickActions } from "./list-view-types";
export interface IList {
groupedIssueIds: TGroupedIssues;
issuesMap: TIssueMap;
group_by: TIssueGroupByOptions | null;
orderBy: TIssueOrderByOptions | undefined;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
displayProperties: IIssueDisplayProperties | undefined;
enableIssueQuickAdd: boolean;
showEmptyGroup?: boolean;
canEditProperties: (projectId: string | undefined) => boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
disableIssueCreation?: boolean;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
isCompletedCycle?: boolean;
loadMoreIssues: (groupId?: string) => void;
handleCollapsedGroups: (value: string) => void;
collapsedGroups: TIssueKanbanFilters;
isEpic?: boolean;
}
export const List: React.FC<IList> = observer((props) => {
const {
groupedIssueIds,
issuesMap,
group_by,
orderBy,
updateIssue,
quickActions,
displayProperties,
enableIssueQuickAdd,
showEmptyGroup,
canEditProperties,
quickAddCallback,
disableIssueCreation,
handleOnDrop,
addIssuesToView,
isCompletedCycle = false,
loadMoreIssues,
handleCollapsedGroups,
collapsedGroups,
isEpic = false,
} = props;
const storeType = useIssueStoreType();
// plane web hooks
const isBulkOperationsEnabled = useBulkOperationStatus();
const containerRef = useRef<HTMLDivElement | null>(null);
const groups = getGroupByColumns({
groupBy: group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
isEpic: isEpic,
});
// Enable Auto Scroll for Main Kanban
useEffect(() => {
const element = containerRef.current;
if (!element) return;
return combine(
autoScrollForElements({
element,
})
);
}, [containerRef]);
if (!groups) return null;
const getGroupIndex = (groupId: string | undefined) => groups.findIndex(({ id }) => id === groupId);
const is_list = group_by === null ? true : false;
// create groupIds array and entities object for bulk ops
const groupIds = groups.map((g) => g.id);
const orderedGroups: Record<string, string[]> = {};
groupIds.forEach((gID) => {
orderedGroups[gID] = [];
});
let entities: Record<string, string[]> = {};
if (is_list) {
entities = Object.assign(orderedGroups, { [groupIds[0]]: groupedIssueIds[ALL_ISSUES] ?? [] });
} else if (!isSubGrouped(groupedIssueIds)) {
entities = Object.assign(orderedGroups, { ...groupedIssueIds });
} else {
entities = orderedGroups;
}
return (
<div className="relative size-full flex flex-col">
{groups && (
<MultipleSelectGroup
containerRef={containerRef}
entities={entities}
disabled={!isBulkOperationsEnabled || isEpic}
>
{(helpers) => (
<>
<div
ref={containerRef}
className="size-full vertical-scrollbar scrollbar-lg relative overflow-auto vertical-scrollbar-margin-top-md"
>
{groups.map((group: IGroupByColumn) => (
<ListGroup
key={group.id}
groupIssueIds={groupedIssueIds?.[group.id]}
issuesMap={issuesMap}
group_by={group_by}
group={group}
updateIssue={updateIssue}
quickActions={quickActions}
orderBy={orderBy}
getGroupIndex={getGroupIndex}
handleOnDrop={handleOnDrop}
displayProperties={displayProperties}
enableIssueQuickAdd={enableIssueQuickAdd}
showEmptyGroup={showEmptyGroup}
canEditProperties={canEditProperties}
quickAddCallback={quickAddCallback}
disableIssueCreation={disableIssueCreation}
addIssuesToView={addIssuesToView}
isCompletedCycle={isCompletedCycle}
loadMoreIssues={loadMoreIssues}
containerRef={containerRef}
selectionHelpers={helpers}
handleCollapsedGroups={handleCollapsedGroups}
collapsedGroups={collapsedGroups}
isEpic={isEpic}
/>
))}
</div>
<IssueBulkOperationsRoot selectionHelpers={helpers} />
</>
)}
</MultipleSelectGroup>
)}
</div>
);
});

View File

@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { CircleDashed, Plus } from "lucide-react";
// types
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, ISearchIssueResponse, TIssueGroupByOptions } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
import { MultipleSelectGroupAction } from "@/components/core/multiple-select";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
// constants
import { captureClick } from "@/helpers/event-tracker.helper";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import type { TSelectionHelper } from "@/hooks/use-multiple-select";
// plane-web
import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal";
// Plane-web
import { WorkFlowGroupTree } from "@/plane-web/components/workflow";
interface IHeaderGroupByCard {
groupID: string;
groupBy: TIssueGroupByOptions;
icon?: React.ReactNode;
title: string;
count: number;
issuePayload: Partial<TIssue>;
canEditProperties: (projectId: string | undefined) => boolean;
disableIssueCreation?: boolean;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
selectionHelpers: TSelectionHelper;
handleCollapsedGroups: (value: string) => void;
isEpic?: boolean;
}
export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
const {
groupID,
groupBy,
icon,
title,
count,
issuePayload,
canEditProperties,
disableIssueCreation,
addIssuesToView,
selectionHelpers,
handleCollapsedGroups,
isEpic = false,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [openExistingIssueListModal, setOpenExistingIssueListModal] = useState(false);
// router
const { workspaceSlug, projectId, moduleId, cycleId } = useParams();
const storeType = useIssueStoreType();
// derived values
const renderExistingIssueModal = moduleId || cycleId;
const existingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true };
const isGroupSelectionEmpty = selectionHelpers.isGroupSelected(groupID) === "empty";
// auth
const canSelectIssues = canEditProperties(projectId?.toString()) && !selectionHelpers.isSelectionDisabled;
const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return;
const issues = data.map((i) => i.id);
try {
await addIssuesToView?.(issues);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Work items added to the cycle successfully.",
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Selected work items could not be added to the cycle. Please try again.",
});
}
};
return (
<>
<div className="group/list-header w-full flex-shrink-0 flex items-center gap-2 py-1.5">
{canSelectIssues && (
<div className="flex-shrink-0 flex items-center w-3.5 absolute left-1">
<MultipleSelectGroupAction
className={cn(
"size-3.5 opacity-0 pointer-events-none group-hover/list-header:opacity-100 group-hover/list-header:pointer-events-auto !outline-none ",
{
"opacity-100 pointer-events-auto": !isGroupSelectionEmpty,
}
)}
groupID={groupID}
selectionHelpers={selectionHelpers}
disabled={count === 0}
/>
</div>
)}
<div className="flex-shrink-0 grid place-items-center overflow-hidden">
{icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
</div>
<div
className="relative flex w-full flex-row items-center gap-1 overflow-hidden cursor-pointer"
onClick={() => handleCollapsedGroups(groupID)}
>
<div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
<div className="px-2.5">
<WorkFlowGroupTree groupBy={groupBy} groupId={groupID} />
</div>
</div>
{!disableIssueCreation &&
(renderExistingIssueModal ? (
<CustomMenu
customButton={
<span className="flex h-5 w-5 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</span>
}
>
<CustomMenu.MenuItem
onClick={() => {
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.create });
setIsOpen(true);
}}
>
<span className="flex items-center justify-start gap-2">Create work item</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.add_existing });
setOpenExistingIssueListModal(true);
}}
>
<span className="flex items-center justify-start gap-2">Add an existing work item</span>
</CustomMenu.MenuItem>
</CustomMenu>
) : (
<div
className="flex h-5 w-5 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80"
onClick={() => {
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.create });
setIsOpen(true);
}}
>
<Plus width={14} strokeWidth={2} />
</div>
))}
{isEpic ? (
<CreateUpdateEpicModal isOpen={isOpen} onClose={() => setIsOpen(false)} data={issuePayload} />
) : (
<CreateUpdateIssueModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
data={issuePayload}
storeType={storeType}
/>
)}
{renderExistingIssueModal && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
isOpen={openExistingIssueListModal}
handleClose={() => setOpenExistingIssueListModal(false)}
searchParams={existingIssuesListModalPayload}
handleOnSubmit={handleAddIssuesToView}
/>
)}
</div>
</>
);
});

View File

@@ -0,0 +1,333 @@
"use client";
import type { MutableRefObject } from "react";
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
// plane imports
import { DRAG_ALLOWED_GROUPS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type {
IGroupByColumn,
TIssueMap,
TIssueGroupByOptions,
TIssueOrderByOptions,
TIssue,
IIssueDisplayProperties,
TIssueKanbanFilters,
} from "@plane/types";
import { EIssueLayoutTypes } from "@plane/types";
import { Row } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { ListLoaderItemRow } from "@/components/ui/loader/layouts/list-layout-loader";
// hooks
import { useProjectState } from "@/hooks/store/use-project-state";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import type { TSelectionHelper } from "@/hooks/use-multiple-select";
// Plane-web
import { useWorkFlowFDragNDrop } from "@/plane-web/components/workflow";
//
import { GroupDragOverlay } from "../group-drag-overlay";
import { ListQuickAddIssueButton, QuickAddIssueRoot } from "../quick-add";
import type { GroupDropLocation } from "../utils";
import {
getDestinationFromDropPayload,
getIssueBlockId,
getSourceFromDropPayload,
highlightIssueOnDrop,
} from "../utils";
import { IssueBlocksList } from "./blocks-list";
import { HeaderGroupByCard } from "./headers/group-by-card";
import type { TRenderQuickActions } from "./list-view-types";
interface Props {
groupIssueIds: string[] | undefined;
group: IGroupByColumn;
issuesMap: TIssueMap;
group_by: TIssueGroupByOptions | null;
orderBy: TIssueOrderByOptions | undefined;
getGroupIndex: (groupId: string | undefined) => number;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
displayProperties: IIssueDisplayProperties | undefined;
enableIssueQuickAdd: boolean;
canEditProperties: (projectId: string | undefined) => boolean;
containerRef: MutableRefObject<HTMLDivElement | null>;
quickAddCallback?: ((projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>) | undefined;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
disableIssueCreation?: boolean;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
isCompletedCycle?: boolean;
showEmptyGroup?: boolean;
loadMoreIssues: (groupId?: string) => void;
selectionHelpers: TSelectionHelper;
handleCollapsedGroups: (value: string) => void;
collapsedGroups: TIssueKanbanFilters;
isEpic?: boolean;
}
export const ListGroup = observer((props: Props) => {
const {
groupIssueIds = [],
group,
issuesMap,
group_by,
orderBy,
getGroupIndex,
updateIssue,
quickActions,
displayProperties,
enableIssueQuickAdd,
canEditProperties,
containerRef,
quickAddCallback,
handleOnDrop,
disableIssueCreation,
addIssuesToView,
isCompletedCycle,
showEmptyGroup,
loadMoreIssues,
selectionHelpers,
handleCollapsedGroups,
collapsedGroups,
isEpic = false,
} = props;
const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false);
const [dragColumnOrientation, setDragColumnOrientation] = useState<"justify-start" | "justify-end">("justify-start");
const isExpanded = !collapsedGroups?.group_by.includes(group.id);
const groupRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation();
const projectState = useProjectState();
const {
issues: { getGroupIssueCount, getPaginationData, getIssueLoader },
} = useIssuesStore();
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
const { workflowDisabledSource, isWorkflowDropDisabled, handleWorkFlowState, getIsWorkflowWorkItemCreationDisabled } =
useWorkFlowFDragNDrop(group_by);
const isWorkflowIssueCreationDisabled = getIsWorkflowWorkItemCreationDisabled(group.id);
const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults;
const isPaginating = !!getIssueLoader(group.id);
useIntersectionObserver(containerRef, isPaginating ? null : intersectionElement, loadMoreIssues, `100% 0% 100% 0%`);
const shouldLoadMore =
nextPageResults === undefined && groupIssueCount !== undefined && groupIssueIds
? groupIssueIds.length < groupIssueCount
: !!nextPageResults;
const loadMore = isPaginating ? (
<ListLoaderItemRow />
) : (
<div
className={
"h-11 relative flex items-center gap-3 bg-custom-background-100 border border-transparent border-t-custom-border-200 pl-8 p-3 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 hover:underline cursor-pointer"
}
onClick={() => loadMoreIssues(group.id)}
>
{t("common.load_more")} &darr;
</div>
);
const validateEmptyIssueGroups = (issueCount: number = 0) => {
if (!showEmptyGroup && issueCount <= 0) return false;
return true;
};
const prePopulateQuickAddData = (groupByKey: string | null, value: any) => {
const defaultState = projectState.projectStates?.find((state) => state.default);
let preloadedData: object = { state_id: defaultState?.id };
if (groupByKey === null) {
preloadedData = { ...preloadedData };
} else {
if (groupByKey === "state") {
preloadedData = { ...preloadedData, state_id: value };
} else if (groupByKey === "priority") {
preloadedData = { ...preloadedData, priority: value };
} else if (groupByKey === "labels" && value != "None") {
preloadedData = { ...preloadedData, label_ids: [value] };
} else if (groupByKey === "assignees" && value != "None") {
preloadedData = { ...preloadedData, assignee_ids: [value] };
} else if (groupByKey === "cycle" && value != "None") {
preloadedData = { ...preloadedData, cycle_id: value };
} else if (groupByKey === "module" && value != "None") {
preloadedData = { ...preloadedData, module_ids: [value] };
} else if (groupByKey === "created_by") {
preloadedData = { ...preloadedData };
} else {
preloadedData = { ...preloadedData, [groupByKey]: value };
}
}
return preloadedData;
};
useEffect(() => {
const element = groupRef.current;
if (!element) return;
return combine(
dropTargetForElements({
element,
getData: () => ({ groupId: group.id, type: "COLUMN" }),
onDragEnter: () => {
setIsDraggingOverColumn(true);
},
onDragLeave: () => {
setIsDraggingOverColumn(false);
},
onDragStart: () => {
setIsDraggingOverColumn(true);
},
onDrag: ({ source }) => {
const sourceGroupId = source?.data?.groupId as string | undefined;
const currentGroupId = group.id;
sourceGroupId && handleWorkFlowState(sourceGroupId, currentGroupId);
const sourceIndex = getGroupIndex(sourceGroupId);
const currentIndex = getGroupIndex(currentGroupId);
if (sourceIndex > currentIndex) {
setDragColumnOrientation("justify-end");
} else {
setDragColumnOrientation("justify-start");
}
},
onDrop: (payload) => {
setIsDraggingOverColumn(false);
const source = getSourceFromDropPayload(payload);
const destination = getDestinationFromDropPayload(payload);
if (!source || !destination) return;
if (isWorkflowDropDisabled || group.isDropDisabled) {
if (group.dropErrorMessage)
setToast({
type: TOAST_TYPE.WARNING,
title: t("common.warning"),
message: group.dropErrorMessage,
});
return;
}
handleOnDrop(source, destination);
highlightIssueOnDrop(getIssueBlockId(source.id, destination?.groupId), orderBy !== "sort_order");
if (!isExpanded) {
handleCollapsedGroups(group.id);
}
},
})
);
}, [
groupRef?.current,
group,
orderBy,
getGroupIndex,
setDragColumnOrientation,
setIsDraggingOverColumn,
isWorkflowDropDisabled,
]);
const isDragAllowed = group_by ? DRAG_ALLOWED_GROUPS.includes(group_by) : true;
const canOverlayBeVisible = isWorkflowDropDisabled || orderBy !== "sort_order" || !!group.isDropDisabled;
const isDropDisabled = isWorkflowDropDisabled || !!group.isDropDisabled;
const isGroupByCreatedBy = group_by === "created_by";
const shouldExpand = (!!groupIssueCount && isExpanded) || !group_by;
return validateEmptyIssueGroups(groupIssueCount) ? (
<div
ref={groupRef}
className={cn(`relative flex flex-shrink-0 flex-col border-[1px] border-transparent`, {
"border-custom-primary-100": isDraggingOverColumn,
"border-custom-error-200": isDraggingOverColumn && isDropDisabled,
})}
>
<Row
className={cn("w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 pr-3 py-1", {
"sticky top-0 z-[2]": isExpanded && groupIssueCount > 0,
})}
>
<HeaderGroupByCard
groupID={group.id}
groupBy={group_by}
icon={group.icon}
title={group.name}
count={groupIssueCount}
issuePayload={group.payload}
canEditProperties={canEditProperties}
disableIssueCreation={
disableIssueCreation || isGroupByCreatedBy || isCompletedCycle || isWorkflowIssueCreationDisabled
}
addIssuesToView={addIssuesToView}
selectionHelpers={selectionHelpers}
handleCollapsedGroups={handleCollapsedGroups}
isEpic={isEpic}
/>
</Row>
{shouldExpand && (
<div className="relative">
<GroupDragOverlay
dragColumnOrientation={dragColumnOrientation}
canOverlayBeVisible={canOverlayBeVisible}
isDropDisabled={isDropDisabled}
workflowDisabledSource={workflowDisabledSource}
dropErrorMessage={group.dropErrorMessage}
orderBy={orderBy}
isDraggingOverColumn={isDraggingOverColumn}
isEpic={isEpic}
/>
{groupIssueIds && (
<IssueBlocksList
issueIds={groupIssueIds}
groupId={group.id}
issuesMap={issuesMap}
updateIssue={updateIssue}
quickActions={quickActions}
displayProperties={displayProperties}
canEditProperties={canEditProperties}
containerRef={containerRef}
isDragAllowed={isDragAllowed}
canDropOverIssue={!canOverlayBeVisible}
selectionHelpers={selectionHelpers}
isEpic={isEpic}
/>
)}
{shouldLoadMore && (group_by ? <>{loadMore}</> : <ListLoaderItemRow ref={setIntersectionElement} />)}
{enableIssueQuickAdd &&
!disableIssueCreation &&
!isGroupByCreatedBy &&
!isCompletedCycle &&
!isWorkflowIssueCreationDisabled && (
<div className="sticky bottom-0 z-[1] w-full flex-shrink-0">
<QuickAddIssueRoot
layout={EIssueLayoutTypes.LIST}
QuickAddButton={ListQuickAddIssueButton}
prePopulatedData={prePopulateQuickAddData(group_by, group.id)}
containerClassName="border-b border-t border-custom-border-200 bg-custom-background-100 "
quickAddCallback={quickAddCallback}
isEpic={isEpic}
/>
</div>
)}
</div>
)}
</div>
) : null;
});

View File

@@ -0,0 +1,31 @@
import type { TPlacement } from "@plane/propel/utils/placement";
import type { TIssue } from "@plane/types";
export interface IQuickActionProps {
parentRef: React.RefObject<HTMLElement>;
issue: TIssue;
handleDelete: () => Promise<void>;
handleUpdate?: (data: TIssue) => Promise<void>;
handleRemoveFromView?: () => Promise<void>;
handleArchive?: () => Promise<void>;
handleRestore?: () => Promise<void>;
handleMoveToIssues?: () => Promise<void>;
customActionButton?: React.ReactElement;
portalElement?: HTMLDivElement | null;
readOnly?: boolean;
placements?: TPlacement;
}
export type TRenderQuickActions = ({
issue,
parentRef,
customActionButton,
placement,
portalElement,
}: {
issue: TIssue;
parentRef: React.RefObject<HTMLElement>;
customActionButton?: React.ReactElement;
placement?: TPlacement;
portalElement?: HTMLDivElement | null;
}) => React.ReactNode;

View File

@@ -0,0 +1,16 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// local imports
import { ArchivedIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseListRoot } from "../base-list-root";
export const ArchivedIssueListLayout: FC = observer(() => {
const canEditPropertiesBasedOnProject = () => false;
return (
<BaseListRoot
QuickActions={ArchivedIssueQuickActions}
canEditPropertiesBasedOnProject={canEditPropertiesBasedOnProject}
/>
);
});

View File

@@ -0,0 +1,51 @@
import React, { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
// types
import { CycleIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseListRoot } from "../base-list-root";
export const CycleListLayout: React.FC = observer(() => {
const { workspaceSlug, projectId, cycleId } = useParams();
// store
const { issues } = useIssues(EIssuesStoreType.CYCLE);
const { currentProjectCompletedCycleIds } = useCycle(); // mobx store
const { allowPermissions } = useUserPermissions();
const isCompletedCycle =
cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false;
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const canEditIssueProperties = useCallback(
() => !isCompletedCycle && isEditingAllowed,
[isCompletedCycle, isEditingAllowed]
);
const addIssuesToView = useCallback(
(issueIds: string[]) => {
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
},
[issues?.addIssueToCycle, workspaceSlug, projectId, cycleId]
);
return (
<BaseListRoot
QuickActions={CycleIssueQuickActions}
addIssuesToView={addIssuesToView}
canEditPropertiesBasedOnProject={canEditIssueProperties}
isCompletedCycle={isCompletedCycle}
viewId={cycleId?.toString()}
/>
);
});

View File

@@ -0,0 +1,27 @@
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssuesStoreType } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
// local imports
import { ModuleIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseListRoot } from "../base-list-root";
export const ModuleListLayout: React.FC = observer(() => {
const { workspaceSlug, projectId, moduleId } = useParams();
const { issues } = useIssues(EIssuesStoreType.MODULE);
return (
<BaseListRoot
QuickActions={ModuleIssueQuickActions}
addIssuesToView={(issueIds: string[]) => {
if (!workspaceSlug || !projectId || !moduleId) throw new Error();
return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds);
}}
viewId={moduleId?.toString()}
/>
);
});

View File

@@ -0,0 +1,32 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { ProjectIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseListRoot } from "../base-list-root";
export const ProfileIssuesListLayout: FC = observer(() => {
// router
const { workspaceSlug, profileViewId } = useParams();
// store
const { allowPermissions } = useUserPermissions();
const canEditPropertiesBasedOnProject = (projectId: string) =>
allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId
);
return (
<BaseListRoot
QuickActions={ProjectIssueQuickActions}
canEditPropertiesBasedOnProject={canEditPropertiesBasedOnProject}
viewId={profileViewId?.toString()}
/>
);
});

View File

@@ -0,0 +1,34 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { ProjectIssueQuickActions } from "../../quick-action-dropdowns";
import { BaseListRoot } from "../base-list-root";
export const ListLayout: FC = observer(() => {
// router
const { workspaceSlug } = useParams();
// hooks
const { allowPermissions } = useUserPermissions();
if (!workspaceSlug) return null;
const canEditPropertiesBasedOnProject = (projectId: string) =>
allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId
);
return (
<BaseListRoot
QuickActions={ProjectIssueQuickActions}
canEditPropertiesBasedOnProject={canEditPropertiesBasedOnProject}
/>
);
});

View File

@@ -0,0 +1,15 @@
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// store
// constants
// types
import { ProjectIssueQuickActions } from "../../quick-action-dropdowns";
// components
import { BaseListRoot } from "../base-list-root";
export const ProjectViewListLayout: React.FC = observer(() => {
const { viewId } = useParams();
return <BaseListRoot QuickActions={ProjectIssueQuickActions} viewId={viewId.toString()} />;
});

View File

@@ -0,0 +1,541 @@
"use client";
import type { SyntheticEvent } from "react";
import { useCallback, useMemo } from "react";
import { xor } from "lodash-es";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { CalendarCheck2, CalendarClock, Link, Paperclip } from "lucide-react";
// types
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
import { ViewsIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
// ui
import {
cn,
getDate,
renderFormattedPayloadDate,
generateWorkItemLink,
shouldHighlightIssueDueDate,
} from "@plane/utils";
// components
import { CycleDropdown } from "@/components/dropdowns/cycle";
import { DateDropdown } from "@/components/dropdowns/date";
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
import { EstimateDropdown } from "@/components/dropdowns/estimate";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { ModuleDropdown } from "@/components/dropdowns/module/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
// helpers
import { captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { WorkItemLayoutAdditionalProperties } from "@/plane-web/components/issues/issue-layouts/additional-properties";
// local components
import { IssuePropertyLabels } from "./labels";
import { WithDisplayPropertiesHOC } from "./with-display-properties-HOC";
export interface IIssueProperties {
issue: TIssue;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
displayProperties: IIssueDisplayProperties | undefined;
isReadOnly: boolean;
className: string;
activeLayout: string;
isEpic?: boolean;
}
export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const { issue, updateIssue, displayProperties, isReadOnly, className, isEpic = false } = props;
// i18n
const { t } = useTranslation();
// store hooks
const { getProjectById } = useProject();
const { labelMap } = useLabel();
const storeType = useIssueStoreType();
const {
issues: { changeModulesInIssue },
} = useIssues(storeType);
const {
issues: { addCycleToIssue, removeCycleFromIssue },
} = useIssues(storeType);
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const { getStateById } = useProjectState();
const { isMobile } = usePlatformOS();
const projectDetails = getProjectById(issue.project_id);
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// derived values
const stateDetails = getStateById(issue.state_id);
const subIssueCount = issue?.sub_issues_count ?? 0;
const issueOperations = useMemo(
() => ({
addModulesToIssue: async (moduleIds: string[]) => {
if (!workspaceSlug || !issue.project_id || !issue.id) return;
await changeModulesInIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds, []);
},
removeModulesFromIssue: async (moduleIds: string[]) => {
if (!workspaceSlug || !issue.project_id || !issue.id) return;
await changeModulesInIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, [], moduleIds);
},
addIssueToCycle: async (cycleId: string) => {
if (!workspaceSlug || !issue.project_id || !issue.id) return;
await addCycleToIssue?.(workspaceSlug.toString(), issue.project_id, cycleId, issue.id);
},
removeIssueFromCycle: async () => {
if (!workspaceSlug || !issue.project_id || !issue.id) return;
await removeCycleFromIssue?.(workspaceSlug.toString(), issue.project_id, issue.id);
},
}),
[workspaceSlug, issue, changeModulesInIssue, addCycleToIssue, removeCycleFromIssue]
);
const handleState = (stateId: string) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { state_id: stateId }).then(() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issue.id },
});
});
};
const handlePriority = (value: TIssuePriorities) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { priority: value }).then(() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issue.id },
});
});
};
const handleLabel = (ids: string[]) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { label_ids: ids }).then(() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issue.id },
});
});
};
const handleAssignee = (ids: string[]) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { assignee_ids: ids }).then(() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issue.id },
});
});
};
const handleModule = useCallback(
(moduleIds: string[] | null) => {
if (!issue || !issue.module_ids || !moduleIds) return;
const updatedModuleIds = xor(issue.module_ids, moduleIds);
const modulesToAdd: string[] = [];
const modulesToRemove: string[] = [];
for (const moduleId of updatedModuleIds)
if (issue.module_ids.includes(moduleId)) modulesToRemove.push(moduleId);
else modulesToAdd.push(moduleId);
if (modulesToAdd.length > 0) issueOperations.addModulesToIssue(modulesToAdd);
if (modulesToRemove.length > 0) issueOperations.removeModulesFromIssue(modulesToRemove);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issue.id },
});
},
[issueOperations, issue]
);
const handleCycle = useCallback(
(cycleId: string | null) => {
if (!issue || issue.cycle_id === cycleId) return;
if (cycleId) issueOperations.addIssueToCycle?.(cycleId);
else issueOperations.removeIssueFromCycle?.();
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issue.id },
});
},
[issue, issueOperations]
);
const handleStartDate = (date: Date | null) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { start_date: date ? renderFormattedPayloadDate(date) : null }).then(
() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issue.id },
});
}
);
};
const handleTargetDate = (date: Date | null) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { target_date: date ? renderFormattedPayloadDate(date) : null }).then(
() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issue.id },
});
}
);
};
const handleEstimate = (value: string | undefined) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issue.id },
});
});
};
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issue?.sequence_id,
isArchived: !!issue?.archived_at,
isEpic,
});
const redirectToIssueDetail = () => router.push(`${workItemLink}#sub-issues`);
if (!displayProperties || !issue.project_id) return null;
// date range is enabled only when both dates are available and both dates are enabled
const isDateRangeEnabled: boolean = Boolean(
issue.start_date && issue.target_date && displayProperties.start_date && displayProperties.due_date
);
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
const minDate = getDate(issue.start_date);
const maxDate = getDate(issue.target_date);
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
};
return (
<div className={className}>
{/* basic properties */}
{/* state */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<StateDropdown
buttonContainerClassName="truncate max-w-40"
value={issue.state_id}
onChange={handleState}
projectId={issue.project_id}
disabled={isReadOnly}
buttonVariant="border-with-text"
renderByDefault={isMobile}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* priority */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<PriorityDropdown
value={issue?.priority}
onChange={handlePriority}
disabled={isReadOnly}
buttonVariant="border-without-text"
buttonClassName="border"
renderByDefault={isMobile}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* merged dates */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={["start_date", "due_date"]}
shouldRenderProperty={() => isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateRangeDropdown
value={{
from: getDate(issue.start_date) || undefined,
to: getDate(issue.target_date) || undefined,
}}
onSelect={(range) => {
handleStartDate(range?.from ?? null);
handleTargetDate(range?.to ?? null);
}}
hideIcon={{
from: false,
}}
isClearable
mergeDates
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""}
clearIconClassName="!text-custom-text-100"
disabled={isReadOnly}
renderByDefault={isMobile}
showTooltip
renderPlaceholder={false}
customTooltipHeading="Date Range"
/>
</div>
</WithDisplayPropertiesHOC>
{/* start date */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="start_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue.start_date ?? null}
onChange={handleStartDate}
maxDate={maxDate}
placeholder={t("common.order_by.start_date")}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-10"
disabled={isReadOnly}
renderByDefault={isMobile}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* target/due date */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="due_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue?.target_date ?? null}
onChange={handleTargetDate}
minDate={minDate}
placeholder={t("common.order_by.due_date")}
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""}
clearIconClassName="!text-custom-text-100"
optionsClassName="z-10"
disabled={isReadOnly}
renderByDefault={isMobile}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* assignee */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<MemberDropdown
projectId={issue?.project_id}
value={issue?.assignee_ids}
onChange={handleAssignee}
disabled={isReadOnly}
multiple
buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
showTooltip={issue?.assignee_ids?.length === 0}
placeholder={t("common.assignees")}
optionsClassName="z-10"
tooltipContent=""
renderByDefault={isMobile}
/>
</div>
</WithDisplayPropertiesHOC>
<>
{!isEpic && (
<>
{/* modules */}
{projectDetails?.module_view && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<ModuleDropdown
buttonContainerClassName="truncate max-w-40"
projectId={issue?.project_id}
value={issue?.module_ids ?? []}
onChange={handleModule}
disabled={isReadOnly}
renderByDefault={isMobile}
multiple
buttonVariant="border-with-text"
showCount
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
)}
{/* cycles */}
{projectDetails?.cycle_view && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<CycleDropdown
buttonContainerClassName="truncate max-w-40"
projectId={issue?.project_id}
value={issue?.cycle_id}
onChange={handleCycle}
disabled={isReadOnly}
buttonVariant="border-with-text"
renderByDefault={isMobile}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
)}
</>
)}
</>
{/* estimates */}
{projectId && areEstimateEnabledByProjectId(projectId?.toString()) && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<EstimateDropdown
value={issue.estimate_point ?? undefined}
onChange={handleEstimate}
projectId={issue.project_id}
disabled={isReadOnly}
buttonVariant="border-with-text"
renderByDefault={isMobile}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
)}
{/* extra render properties */}
{/* sub-issues */}
{!isEpic && (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="sub_issue_count"
shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!subIssueCount}
>
<Tooltip
tooltipHeading={t("common.sub_work_items")}
tooltipContent={`${subIssueCount}`}
isMobile={isMobile}
renderByDefault={false}
>
<div
onFocus={handleEventPropagation}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (subIssueCount) redirectToIssueDetail();
}}
className={cn(
"flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1",
{
"hover:bg-custom-background-80 cursor-pointer": subIssueCount,
}
)}
>
<ViewsIcon className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{subIssueCount}</div>
</div>
</Tooltip>
</WithDisplayPropertiesHOC>
)}
{/* attachments */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="attachment_count"
shouldRenderProperty={(properties) => !!properties.attachment_count && !!issue.attachment_count}
>
<Tooltip
tooltipHeading={t("common.attachments")}
tooltipContent={`${issue.attachment_count}`}
isMobile={isMobile}
renderByDefault={false}
>
<div
className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1"
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<Paperclip className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.attachment_count}</div>
</div>
</Tooltip>
</WithDisplayPropertiesHOC>
{/* link */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="link"
shouldRenderProperty={(properties) => !!properties.link && !!issue.link_count}
>
<Tooltip
tooltipHeading={t("common.links")}
tooltipContent={`${issue.link_count}`}
isMobile={isMobile}
renderByDefault={false}
>
<div
className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1"
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<Link className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.link_count}</div>
</div>
</Tooltip>
</WithDisplayPropertiesHOC>
{/* Additional Properties */}
<WorkItemLayoutAdditionalProperties displayProperties={displayProperties} issue={issue} />
{/* label */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="labels">
<IssuePropertyLabels
projectId={issue?.project_id || null}
value={issue?.label_ids || []}
defaultOptions={defaultLabelOptions}
onChange={handleLabel}
disabled={isReadOnly}
renderByDefault={isMobile}
hideDropdownArrow
maxRender={3}
/>
</WithDisplayPropertiesHOC>
</div>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./labels";
export * from "./all-properties";
export * from "./label-dropdown";

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