feat: init
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./months-dropdown";
|
||||
export * from "./options-dropdown";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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()} />;
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const ProjectEpicsEmptyState: React.FC = () => <></>;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./dropdown";
|
||||
export * from "./filter-header";
|
||||
export * from "./filter-option";
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./display-filters";
|
||||
export * from "./filters";
|
||||
export * from "./helpers";
|
||||
export * from "./layout-selection";
|
||||
export * from "./mobile-layout-selection";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./header";
|
||||
export * from "./applied-filters";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
154
apps/web/core/components/issues/issue-layouts/gantt/blocks.tsx
Normal file
154
apps/web/core/components/issues/issue-layouts/gantt/blocks.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./base-gantt-root";
|
||||
@@ -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} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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}</>;
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
305
apps/web/core/components/issues/issue-layouts/kanban/block.tsx
Normal file
305
apps/web/core/components/issues/issue-layouts/kanban/block.tsx
Normal 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";
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
});
|
||||
236
apps/web/core/components/issues/issue-layouts/kanban/default.tsx
Normal file
236
apps/web/core/components/issues/issue-layouts/kanban/default.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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")} ↓
|
||||
</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>
|
||||
);
|
||||
});
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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()} />;
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
338
apps/web/core/components/issues/issue-layouts/list/block.tsx
Normal file
338
apps/web/core/components/issues/issue-layouts/list/block.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
176
apps/web/core/components/issues/issue-layouts/list/default.tsx
Normal file
176
apps/web/core/components/issues/issue-layouts/list/default.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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")} ↓
|
||||
</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;
|
||||
});
|
||||
31
apps/web/core/components/issues/issue-layouts/list/list-view-types.d.ts
vendored
Normal file
31
apps/web/core/components/issues/issue-layouts/list/list-view-types.d.ts
vendored
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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()} />;
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user