feat: my issues view layouts and filters, refactor: issue views (#1681)

* refactor: issue views and my issues

* chore: update view dropdown options

* refactor: render emoji function

* refactor: api calss

* fix: build errors

* fix: fetch states only when dropdown is opened

* chore: my issues dnd

* fix: build errors

* refactor: folder structure
This commit is contained in:
Aaryan Khandelwal 2023-07-26 17:51:26 +05:30 committed by GitHub
parent ec62308195
commit 3d7fe40035
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 2249 additions and 1430 deletions

View File

@ -227,12 +227,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</span>
) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
<span
style={{ color: project.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{project.icon_prop.name}
</span>
{renderEmoji(project.icon_prop)}
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
@ -342,12 +337,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</div>
) : projectDetails?.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
<span
style={{ color: projectDetails.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{projectDetails.icon_prop.name}
</span>
{renderEmoji(projectDetails.icon_prop)}
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">

View File

@ -1,6 +1,6 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
@ -8,42 +8,31 @@ import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// ui
import { Avatar } from "components/ui";
// helpers
import { getStatesList } from "helpers/state.helper";
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// services
import issuesService from "services/issues.service";
import projectService from "services/project.service";
import stateService from "services/state.service";
// types
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys";
import { IIssueFilterOptions } from "types";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IIssueFilterOptions, IIssueLabels, IState, IUserLite } from "types";
export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
type Props = {
filters: any;
setFilters: any;
clearAllFilters: (...args: any) => void;
labels: IIssueLabels[] | undefined;
members: IUserLite[] | undefined;
states: IState[] | undefined;
};
export const FiltersList: React.FC<Props> = ({
filters,
setFilters,
clearAllFilters,
labels,
members,
states,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const { data: issueLabels } = useSWR(
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId.toString())
: null
);
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
const { viewId } = router.query;
if (!filters) return <></>;
@ -166,7 +155,7 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
) : key === "assignees" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.member.id === memberId)?.member;
const member = members?.find((m) => m.id === memberId);
return (
<div
@ -207,7 +196,7 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
) : key === "created_by" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.created_by?.map((memberId: string) => {
const member = members?.find((m) => m.member.id === memberId)?.member;
const member = members?.find((m) => m.id === memberId);
return (
<div
@ -248,7 +237,7 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
) : key === "labels" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.labels?.map((labelId: string) => {
const label = issueLabels?.find((l) => l.id === labelId);
const label = labels?.find((l) => l.id === labelId);
if (!label) return null;
const color = label.color !== "" ? label.color : "#0f172a";
@ -370,17 +359,7 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<button
type="button"
onClick={() =>
setFilters({
type: null,
state: null,
priority: null,
assignees: null,
labels: null,
created_by: null,
target_date: null,
})
}
onClick={clearAllFilters}
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs"
>
<span>Clear all filters</span>

View File

@ -187,16 +187,19 @@ export const IssuesFilterView: React.FC = () => {
?.name ?? "Select"
}
>
{GROUP_BY_OPTIONS.map((option) =>
issueView === "kanban" && option.key === null ? null : (
{GROUP_BY_OPTIONS.map((option) => {
if (issueView === "kanban" && option.key === null) return null;
if (option.key === "project") return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
);
})}
</CustomMenu>
</div>
<div className="flex items-center justify-between">

View File

@ -1,12 +1,7 @@
export * from "./board-view";
export * from "./calendar-view";
export * from "./filters";
export * from "./gantt-chart-view";
export * from "./list-view";
export * from "./modals";
export * from "./spreadsheet-view";
export * from "./theme";
export * from "./sidebar";
export * from "./issues-view";
export * from "./image-picker-popover";
export * from "./theme";
export * from "./views";
export * from "./feeds";
export * from "./image-picker-popover";

View File

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

View File

@ -0,0 +1,203 @@
import React, { useCallback } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// services
import stateService from "services/state.service";
// hooks
import useUser from "hooks/use-user";
import { useProjectMyMembership } from "contexts/project-member.context";
// components
import {
AllLists,
AllBoards,
CalendarView,
SpreadsheetView,
GanttChartView,
} from "components/core";
// ui
import { EmptyState, SecondaryButton, Spinner } from "components/ui";
// icons
import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
// images
import emptyIssue from "public/empty-state/issue.svg";
import emptyIssueArchive from "public/empty-state/issue-archive.svg";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
import { IIssue, IIssueViewProps } from "types";
// fetch-keys
import { STATES_LIST } from "constants/fetch-keys";
type Props = {
addIssueToDate: (date: string) => void;
addIssueToGroup: (groupTitle: string) => void;
disableUserActions: boolean;
dragDisabled?: boolean;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleOnDragEnd: (result: DropResult) => Promise<void>;
openIssuesListModal: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
trashBox: boolean;
setTrashBox: React.Dispatch<React.SetStateAction<boolean>>;
viewProps: IIssueViewProps;
};
export const AllViews: React.FC<Props> = ({
addIssueToDate,
addIssueToGroup,
disableUserActions,
dragDisabled = false,
handleIssueAction,
handleOnDragEnd,
openIssuesListModal,
removeIssue,
trashBox,
setTrashBox,
viewProps,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { user } = useUser();
const { memberRole } = useProjectMyMembership();
const { groupedIssues, isEmpty, issueView } = viewProps;
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
const handleTrashBox = useCallback(
(isDragging: boolean) => {
if (isDragging && !trashBox) setTrashBox(true);
},
[trashBox, setTrashBox]
);
return (
<DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox">
{(provided, snapshot) => (
<div
className={`${
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
} fixed top-4 left-1/2 -translate-x-1/2 z-40 w-72 flex items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-custom-background-100 px-3 py-5 text-xs font-medium italic text-red-500 ${
snapshot.isDraggingOver ? "bg-red-500 blur-2xl opacity-70" : ""
} transition duration-300`}
ref={provided.innerRef}
{...provided.droppableProps}
>
<TrashIcon className="h-4 w-4" />
Drop here to delete the issue.
</div>
)}
</StrictModeDroppable>
{groupedIssues ? (
!isEmpty || issueView === "kanban" || issueView === "calendar" ? (
<>
{issueView === "list" ? (
<AllLists
states={states}
addIssueToGroup={addIssueToGroup}
handleIssueAction={handleIssueAction}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
removeIssue={removeIssue}
disableUserActions={disableUserActions}
user={user}
userAuth={memberRole}
viewProps={viewProps}
/>
) : issueView === "kanban" ? (
<AllBoards
addIssueToGroup={addIssueToGroup}
disableUserActions={disableUserActions}
dragDisabled={dragDisabled}
handleIssueAction={handleIssueAction}
handleTrashBox={handleTrashBox}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
removeIssue={removeIssue}
states={states}
user={user}
userAuth={memberRole}
viewProps={viewProps}
/>
) : issueView === "calendar" ? (
<CalendarView
handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate}
disableUserActions={disableUserActions}
user={user}
userAuth={memberRole}
/>
) : issueView === "spreadsheet" ? (
<SpreadsheetView
handleIssueAction={handleIssueAction}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
disableUserActions={disableUserActions}
user={user}
userAuth={memberRole}
/>
) : (
issueView === "gantt_chart" && <GanttChartView />
)}
</>
) : router.pathname.includes("archived-issues") ? (
<EmptyState
title="Archived Issues will be shown here"
description="All the issues that have been in the completed or canceled groups for the configured period of time can be viewed here."
image={emptyIssueArchive}
buttonText="Go to Automation Settings"
onClick={() => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`);
}}
/>
) : (
<EmptyState
title={
cycleId
? "Cycle issues will appear here"
: moduleId
? "Module issues will appear here"
: "Project issues will appear here"
}
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
buttonText="New Issue"
buttonIcon={<PlusIcon className="h-4 w-4" />}
secondaryButton={
cycleId || moduleId ? (
<SecondaryButton
className="flex items-center gap-1.5"
onClick={openIssuesListModal ?? (() => {})}
>
<PlusIcon className="h-4 w-4" />
Add an existing issue
</SecondaryButton>
) : null
}
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
/>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</DragDropContext>
);
};

View File

@ -1,75 +1,66 @@
// hooks
import useProjectIssuesView from "hooks/use-issues-view";
// components
import { SingleBoard } from "components/core/board-view/single-board";
import { SingleBoard } from "components/core/views/board-view/single-board";
// icons
import { getStateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
type Props = {
type: "issue" | "cycle" | "module";
states: IState[] | undefined;
addIssueToState: (groupTitle: string) => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
addIssueToGroup: (groupTitle: string) => void;
disableUserActions: boolean;
dragDisabled: boolean;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean;
states: IState[] | undefined;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
viewProps: IIssueViewProps;
};
export const AllBoards: React.FC<Props> = ({
type,
states,
addIssueToState,
makeIssueCopy,
handleEditIssue,
openIssuesListModal,
handleDeleteIssue,
addIssueToGroup,
disableUserActions,
dragDisabled,
handleIssueAction,
handleTrashBox,
openIssuesListModal,
removeIssue,
isCompleted = false,
states,
user,
userAuth,
viewProps,
}) => {
const {
groupedByIssues,
groupByProperty: selectedGroup,
showEmptyGroups,
} = useProjectIssuesView();
const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps;
return (
<>
{groupedByIssues ? (
{groupedIssues ? (
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
{Object.keys(groupedByIssues).map((singleGroup, index) => {
{Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null;
return (
<SingleBoard
key={index}
type={type}
addIssueToGroup={() => addIssueToGroup(singleGroup)}
currentState={currentState}
disableUserActions={disableUserActions}
dragDisabled={dragDisabled}
groupTitle={singleGroup}
handleEditIssue={handleEditIssue}
makeIssueCopy={makeIssueCopy}
addIssueToState={() => addIssueToState(singleGroup)}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={openIssuesListModal ?? null}
handleIssueAction={handleIssueAction}
handleTrashBox={handleTrashBox}
openIssuesListModal={openIssuesListModal ?? null}
removeIssue={removeIssue}
isCompleted={isCompleted}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
);
})}
@ -77,11 +68,11 @@ export const AllBoards: React.FC<Props> = ({
<div className="h-full w-96 flex-shrink-0 space-y-2 p-1">
<h2 className="text-lg font-semibold">Hidden groups</h2>
<div className="space-y-3">
{Object.keys(groupedByIssues).map((singleGroup, index) => {
{Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (groupedByIssues[singleGroup].length === 0)
if (groupedIssues[singleGroup].length === 0)
return (
<div
key={index}

View File

@ -8,7 +8,7 @@ import useSWR from "swr";
import issuesService from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useProjects from "hooks/use-projects";
// component
import { Avatar } from "components/ui";
// icons
@ -16,47 +16,56 @@ import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicon
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import { IIssueLabels, IState } from "types";
import { IIssueViewProps, IState } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
currentState?: IState | null;
groupTitle: string;
addIssueToState: () => void;
addIssueToGroup: () => void;
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
isCompleted?: boolean;
disableUserActions: boolean;
viewProps: IIssueViewProps;
};
export const BoardHeader: React.FC<Props> = ({
currentState,
groupTitle,
addIssueToState,
addIssueToGroup,
isCollapsed,
setIsCollapsed,
isCompleted = false,
disableUserActions,
viewProps,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView();
const { groupedIssues, groupByProperty: selectedGroup } = viewProps;
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
const { data: issueLabels } = useSWR(
workspaceSlug && projectId && selectedGroup === "labels"
? PROJECT_ISSUE_LABELS(projectId.toString())
: null,
workspaceSlug && projectId && selectedGroup === "labels"
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
: null
);
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
workspaceSlug && projectId && selectedGroup === "created_by"
? PROJECT_MEMBERS(projectId.toString())
: null,
workspaceSlug && projectId && selectedGroup === "created_by"
? () => projectService.projectMembers(workspaceSlug.toString(), projectId.toString())
: null
);
const { projects } = useProjects();
const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle);
@ -67,6 +76,9 @@ export const BoardHeader: React.FC<Props> = ({
case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
break;
case "project":
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
@ -87,9 +99,22 @@ export const BoardHeader: React.FC<Props> = ({
icon =
currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color);
break;
case "state_detail.group":
icon = getStateGroupIcon(groupTitle as any, "16", "16");
break;
case "priority":
icon = getPriorityIcon(groupTitle, "text-lg");
break;
case "project":
const project = projects?.find((p) => p.id === groupTitle);
icon =
project &&
(project.emoji !== null
? renderEmoji(project.emoji)
: project.icon_prop !== null
? renderEmoji(project.icon_prop)
: null);
break;
case "labels":
const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
@ -116,7 +141,7 @@ export const BoardHeader: React.FC<Props> = ({
!isCollapsed ? "flex-col rounded-md bg-custom-background-90" : ""
}`}
>
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
<div className={`flex items-center ${isCollapsed ? "gap-1" : "flex-col gap-2"}`}>
<div
className={`flex cursor-pointer items-center gap-x-3 max-w-[316px] ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
@ -126,7 +151,7 @@ export const BoardHeader: React.FC<Props> = ({
<h2
className="text-lg font-semibold capitalize truncate"
style={{
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl",
}}
>
{getGroupTitle()}
@ -136,7 +161,7 @@ export const BoardHeader: React.FC<Props> = ({
isCollapsed ? "ml-0.5" : ""
} min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs`}
>
{groupedByIssues?.[groupTitle].length ?? 0}
{groupedIssues?.[groupTitle].length ?? 0}
</span>
</div>
</div>
@ -155,11 +180,11 @@ export const BoardHeader: React.FC<Props> = ({
<ArrowsPointingOutIcon className="h-4 w-4" />
)}
</button>
{!isCompleted && selectedGroup !== "created_by" && (
{!disableUserActions && selectedGroup !== "created_by" && (
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 text-custom-text-200 outline-none duration-300 hover:bg-custom-background-80"
onClick={addIssueToState}
onClick={addIssueToGroup}
>
<PlusIcon className="h-4 w-4" />
</button>

View File

@ -5,9 +5,6 @@ import { useRouter } from "next/router";
// react-beautiful-dnd
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useIssuesProperties from "hooks/use-issue-properties";
// components
import { BoardHeader, SingleBoardIssue } from "components/core";
// ui
@ -17,64 +14,63 @@ import { PlusIcon } from "@heroicons/react/24/outline";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
type Props = {
type?: "issue" | "cycle" | "module";
addIssueToGroup: () => void;
currentState?: IState | null;
disableUserActions: boolean;
dragDisabled: boolean;
groupTitle: string;
handleEditIssue: (issue: IIssue) => void;
makeIssueCopy: (issue: IIssue) => void;
addIssueToState: () => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
viewProps: IIssueViewProps;
};
export const SingleBoard: React.FC<Props> = ({
type,
addIssueToGroup,
currentState,
groupTitle,
handleEditIssue,
makeIssueCopy,
addIssueToState,
handleDeleteIssue,
openIssuesListModal,
disableUserActions,
dragDisabled,
handleIssueAction,
handleTrashBox,
openIssuesListModal,
removeIssue,
isCompleted = false,
user,
userAuth,
viewProps,
}) => {
// collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true);
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssuesView();
const { groupedIssues, groupByProperty: selectedGroup, orderBy, properties } = viewProps;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { cycleId, moduleId } = router.query;
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
// Check if it has at least 4 tickets since it is enough to accommodate the Calendar height
const issuesLength = groupedByIssues?.[groupTitle].length;
const issuesLength = groupedIssues?.[groupTitle].length;
const hasMinimumNumberOfCards = issuesLength ? issuesLength >= 4 : false;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return (
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
<BoardHeader
addIssueToState={addIssueToState}
addIssueToGroup={addIssueToGroup}
currentState={currentState}
groupTitle={groupTitle}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
isCompleted={isCompleted}
disableUserActions={disableUserActions}
viewProps={viewProps}
/>
{isCollapsed && (
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
@ -112,14 +108,12 @@ export const SingleBoard: React.FC<Props> = ({
hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : ""
} `}
>
{groupedByIssues?.[groupTitle].map((issue, index) => (
{groupedIssues?.[groupTitle].map((issue, index) => (
<Draggable
key={issue.id}
draggableId={issue.id}
index={index}
isDragDisabled={
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "labels"
}
isDragDisabled={isNotAllowed || dragDisabled}
>
{(provided, snapshot) => (
<SingleBoardIssue
@ -128,21 +122,20 @@ export const SingleBoard: React.FC<Props> = ({
snapshot={snapshot}
type={type}
index={index}
selectedGroup={selectedGroup}
issue={issue}
groupTitle={groupTitle}
properties={properties}
editIssue={() => handleEditIssue(issue)}
makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue}
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleTrashBox={handleTrashBox}
removeIssue={() => {
if (removeIssue && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id);
}}
isCompleted={isCompleted}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
)}
</Draggable>
@ -161,18 +154,18 @@ export const SingleBoard: React.FC<Props> = ({
<button
type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
onClick={addIssueToState}
onClick={addIssueToGroup}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
!isCompleted && (
!disableUserActions && (
<CustomMenu
customButton={
<button
type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none"
className="flex items-center gap-2 font-medium text-custom-primary outline-none whitespace-nowrap"
>
<PlusIcon className="h-4 w-4" />
Add Issue
@ -181,7 +174,7 @@ export const SingleBoard: React.FC<Props> = ({
position="left"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>
<CustomMenu.MenuItem onClick={addIssueToGroup}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (

View File

@ -15,7 +15,6 @@ import {
// services
import issuesService from "services/issues.service";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useToast from "hooks/use-toast";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
@ -45,14 +44,7 @@ import { LayerDiagonalIcon } from "components/icons";
import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import {
ICurrentUserResponse,
IIssue,
ISubIssueResponse,
Properties,
TIssueGroupByOptions,
UserAuth,
} from "types";
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
// fetch-keys
import {
CYCLE_DETAILS,
@ -61,6 +53,7 @@ import {
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
USER_ISSUES,
VIEW_ISSUES,
} from "constants/fetch-keys";
@ -69,18 +62,17 @@ type Props = {
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue;
properties: Properties;
groupTitle?: string;
index: number;
selectedGroup: TIssueGroupByOptions;
editIssue: () => void;
makeIssueCopy: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
handleTrashBox: (isDragging: boolean) => void;
isCompleted?: boolean;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
viewProps: IIssueViewProps;
};
export const SingleBoardIssue: React.FC<Props> = ({
@ -88,18 +80,17 @@ export const SingleBoardIssue: React.FC<Props> = ({
provided,
snapshot,
issue,
properties,
index,
selectedGroup,
editIssue,
makeIssueCopy,
removeIssue,
groupTitle,
handleDeleteIssue,
handleTrashBox,
isCompleted = false,
disableUserActions,
user,
userAuth,
viewProps,
}) => {
// context menu
const [contextMenu, setContextMenu] = useState(false);
@ -108,7 +99,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const { orderBy, params } = useIssuesView();
const { groupByProperty: selectedGroup, orderBy, params, properties } = viewProps;
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
@ -117,7 +108,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !projectId) return;
if (!workspaceSlug || !issue) return;
const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
@ -125,7 +116,9 @@ export const SingleBoardIssue: React.FC<Props> = ({
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
: router.pathname.includes("my-issues")
? USER_ISSUES(workspaceSlug.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project.toString(), params);
if (issue.parent) {
mutate<ISubIssueResponse>(
@ -170,7 +163,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
}
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user)
.patchIssue(workspaceSlug as string, issue.project, issue.id, formData, user)
.then(() => {
mutate(fetchKey);
@ -180,7 +173,6 @@ export const SingleBoardIssue: React.FC<Props> = ({
},
[
workspaceSlug,
projectId,
cycleId,
moduleId,
viewId,
@ -189,6 +181,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
selectedGroup,
orderBy,
params,
router,
user,
]
);
@ -228,7 +221,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return (
<>

View File

@ -34,19 +34,17 @@ import {
} from "constants/fetch-keys";
type Props = {
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
addIssueToDate: (date: string) => void;
isCompleted: boolean;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
export const CalendarView: React.FC<Props> = ({
handleEditIssue,
handleDeleteIssue,
handleIssueAction,
addIssueToDate,
isCompleted = false,
disableUserActions,
user,
userAuth,
}) => {
@ -167,7 +165,7 @@ export const CalendarView: React.FC<Props> = ({
);
}, [currentDate]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return calendarIssues ? (
<div className="h-full overflow-y-auto">
@ -220,10 +218,10 @@ export const CalendarView: React.FC<Props> = ({
>
{currentViewDaysData.map((date, index) => (
<SingleCalendarDate
key={`${date}-${index}`}
index={index}
date={date}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView}
showWeekEnds={showWeekEnds}

View File

@ -13,8 +13,7 @@ import { formatDate } from "helpers/calendar.helper";
import { ICurrentUserResponse, IIssue } from "types";
type Props = {
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
index: number;
date: {
date: string;
@ -28,8 +27,7 @@ type Props = {
};
export const SingleCalendarDate: React.FC<Props> = ({
handleEditIssue,
handleDeleteIssue,
handleIssueAction,
date,
index,
addIssueToDate,
@ -72,8 +70,8 @@ export const SingleCalendarDate: React.FC<Props> = ({
provided={provided}
snapshot={snapshot}
issue={issue}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
handleEditIssue={() => handleIssueAction(issue, "edit")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
user={user}
isNotAllowed={isNotAllowed}
/>

View File

@ -0,0 +1,7 @@
export * from "./board-view";
export * from "./calendar-view";
export * from "./gantt-chart-view";
export * from "./list-view";
export * from "./spreadsheet-view";
export * from "./all-views";
export * from "./issues-view";

View File

@ -5,38 +5,26 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { DropResult } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service";
// contexts
import { useProjectMyMembership } from "contexts/project-member.context";
// hooks
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
import useUserAuth from "hooks/use-user-auth";
import useIssuesProperties from "hooks/use-issue-properties";
import useProjectMembers from "hooks/use-project-members";
// components
import {
AllLists,
AllBoards,
FilterList,
CalendarView,
GanttChartView,
SpreadsheetView,
} from "components/core";
import { FiltersList, AllViews } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateViewModal } from "components/views";
import { TransferIssues, TransferIssuesModal } from "components/cycles";
// ui
import { EmptyState, PrimaryButton, Spinner, SecondaryButton } from "components/ui";
import { PrimaryButton } from "components/ui";
// icons
import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
// images
import emptyIssue from "public/empty-state/issue.svg";
import emptyIssueArchive from "public/empty-state/issue-archive.svg";
import { PlusIcon } from "@heroicons/react/24/outline";
// helpers
import { getStatesList } from "helpers/state.helper";
import { orderArrayBy } from "helpers/array.helper";
@ -49,19 +37,18 @@ import {
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
PROJECT_ISSUE_LABELS,
STATES_LIST,
} from "constants/fetch-keys";
type Props = {
type?: "issue" | "cycle" | "module";
openIssuesListModal?: () => void;
isCompleted?: boolean;
disableUserActions?: boolean;
};
export const IssuesView: React.FC<Props> = ({
type = "issue",
openIssuesListModal,
isCompleted = false,
disableUserActions = false,
}) => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
@ -83,14 +70,9 @@ export const IssuesView: React.FC<Props> = ({
// trash box
const [trashBox, setTrashBox] = useState(false);
// transfer issue
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { memberRole } = useProjectMyMembership();
const { user } = useUserAuth();
const { setToastAlert } = useToast();
@ -104,7 +86,9 @@ export const IssuesView: React.FC<Props> = ({
isEmpty,
setFilters,
params,
showEmptyGroups,
} = useIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
@ -114,6 +98,15 @@ export const IssuesView: React.FC<Props> = ({
);
const states = getStatesList(stateGroups ?? {});
const { data: labels } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
: null
);
const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString());
const handleDeleteIssue = useCallback(
(issue: IIssue) => {
setDeleteIssueModal(true);
@ -123,7 +116,7 @@ export const IssuesView: React.FC<Props> = ({
);
const handleOnDragEnd = useCallback(
(result: DropResult) => {
async (result: DropResult) => {
setTrashBox(false);
if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return;
@ -281,7 +274,7 @@ export const IssuesView: React.FC<Props> = ({
]
);
const addIssueToState = useCallback(
const addIssueToGroup = useCallback(
(groupTitle: string) => {
setCreateIssueModal(true);
@ -335,6 +328,15 @@ export const IssuesView: React.FC<Props> = ({
[setEditIssueModal, setIssueToEdit]
);
const handleIssueAction = useCallback(
(issue: IIssue, action: "copy" | "edit" | "delete") => {
if (action === "copy") makeIssueCopy(issue);
else if (action === "edit") handleEditIssue(issue);
else if (action === "delete") handleDeleteIssue(issue);
},
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
);
const removeIssueFromCycle = useCallback(
(bridgeId: string, issueId: string) => {
if (!workspaceSlug || !projectId || !cycleId) return;
@ -421,13 +423,6 @@ export const IssuesView: React.FC<Props> = ({
[workspaceSlug, projectId, moduleId, params, selectedGroup, setToastAlert]
);
const handleTrashBox = useCallback(
(isDragging: boolean) => {
if (isDragging && !trashBox) setTrashBox(true);
},
[trashBox, setTrashBox]
);
const nullFilters = Object.keys(filters).filter(
(key) => filters[key as keyof IIssueFilterOptions] === null
);
@ -461,14 +456,27 @@ export const IssuesView: React.FC<Props> = ({
data={issueToDelete}
user={user}
/>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal}
/>
{areFiltersApplied && (
<>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
<FilterList filters={filters} setFilters={setFilters} />
<FiltersList
filters={filters}
setFilters={setFilters}
labels={labels}
members={members?.map((m) => m.member)}
states={states}
clearAllFilters={() =>
setFilters({
assignees: null,
created_by: null,
labels: null,
priority: null,
state: null,
target_date: null,
type: null,
})
}
/>
<PrimaryButton
onClick={() => {
if (viewId) {
@ -492,140 +500,32 @@ export const IssuesView: React.FC<Props> = ({
{<div className="mt-3 border-t border-custom-border-200" />}
</>
)}
<DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox">
{(provided, snapshot) => (
<div
className={`${
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
} fixed top-4 left-1/2 -translate-x-1/2 z-40 w-72 flex items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-custom-background-100 px-3 py-5 text-xs font-medium italic text-red-500 ${
snapshot.isDraggingOver ? "bg-red-500 blur-2xl opacity-70" : ""
} transition duration-300`}
ref={provided.innerRef}
{...provided.droppableProps}
>
<TrashIcon className="h-4 w-4" />
Drop here to delete the issue.
</div>
)}
</StrictModeDroppable>
{groupedByIssues ? (
!isEmpty || issueView === "kanban" || issueView === "calendar" ? (
<>
{isCompleted && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
{issueView === "list" ? (
<AllLists
type={type}
states={states}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : issueView === "kanban" ? (
<AllBoards
type={type}
states={states}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
handleDeleteIssue={handleDeleteIssue}
handleTrashBox={handleTrashBox}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : issueView === "calendar" ? (
<CalendarView
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
<AllViews
addIssueToDate={addIssueToDate}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : issueView === "spreadsheet" ? (
<SpreadsheetView
type={type}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : (
issueView === "gantt_chart" && <GanttChartView />
)}
</>
) : router.pathname.includes("archived-issues") ? (
<EmptyState
title="Archived Issues will be shown here"
description="All the issues that have been in the completed or canceled groups for the configured period of time can be viewed here."
image={emptyIssueArchive}
buttonText="Go to Automation Settings"
onClick={() => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`);
addIssueToGroup={addIssueToGroup}
disableUserActions={disableUserActions}
dragDisabled={
selectedGroup === "created_by" ||
selectedGroup === "labels" ||
selectedGroup === "state_detail.group"
}
handleOnDragEnd={handleOnDragEnd}
handleIssueAction={handleIssueAction}
openIssuesListModal={openIssuesListModal ? openIssuesListModal : null}
removeIssue={cycleId ? removeIssueFromCycle : moduleId ? removeIssueFromModule : null}
trashBox={trashBox}
setTrashBox={setTrashBox}
viewProps={{
groupByProperty: selectedGroup,
groupedIssues: groupedByIssues,
isEmpty,
issueView,
orderBy,
params,
properties,
showEmptyGroups,
}}
/>
) : (
<EmptyState
title={
cycleId
? "Cycle issues will appear here"
: moduleId
? "Module issues will appear here"
: "Project issues will appear here"
}
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
buttonText="New Issue"
buttonIcon={<PlusIcon className="h-4 w-4" />}
secondaryButton={
cycleId || moduleId ? (
<SecondaryButton
className="flex items-center gap-1.5"
onClick={openIssuesListModal}
>
<PlusIcon className="h-4 w-4" />
Add an existing issue
</SecondaryButton>
) : null
}
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
/>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</DragDropContext>
</>
);
};

View File

@ -0,0 +1,62 @@
// components
import { SingleList } from "components/core/views/list-view/single-list";
// types
import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
// types
type Props = {
states: IState[] | undefined;
addIssueToGroup: (groupTitle: string) => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
viewProps: IIssueViewProps;
};
export const AllLists: React.FC<Props> = ({
addIssueToGroup,
handleIssueAction,
disableUserActions,
openIssuesListModal,
removeIssue,
states,
user,
userAuth,
viewProps,
}) => {
const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps;
return (
<>
{groupedIssues && (
<div className="h-full overflow-y-auto">
{Object.keys(groupedIssues).map((singleGroup) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null;
return (
<SingleList
key={singleGroup}
groupTitle={singleGroup}
currentState={currentState}
addIssueToGroup={() => addIssueToGroup(singleGroup)}
handleIssueAction={handleIssueAction}
openIssuesListModal={openIssuesListModal}
removeIssue={removeIssue}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
);
})}
</div>
)}
</>
);
};

View File

@ -18,8 +18,6 @@ import {
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues";
// hooks
import useIssueView from "hooks/use-issues-view";
// ui
import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
// icons
@ -37,7 +35,14 @@ import { LayerDiagonalIcon } from "components/icons";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { handleIssuesMutation } from "constants/issue";
// types
import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types";
import {
ICurrentUserResponse,
IIssue,
IIssueViewProps,
ISubIssueResponse,
Properties,
UserAuth,
} from "types";
// fetch-keys
import {
CYCLE_DETAILS,
@ -46,37 +51,38 @@ import {
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
USER_ISSUES,
VIEW_ISSUES,
} from "constants/fetch-keys";
type Props = {
type?: string;
issue: IIssue;
properties: Properties;
groupTitle?: string;
editIssue: () => void;
index: number;
makeIssueCopy: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
isCompleted?: boolean;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
viewProps: IIssueViewProps;
};
export const SingleListIssue: React.FC<Props> = ({
type,
issue,
properties,
editIssue,
index,
makeIssueCopy,
removeIssue,
groupTitle,
handleDeleteIssue,
isCompleted = false,
disableUserActions,
user,
userAuth,
viewProps,
}) => {
// context menu
const [contextMenu, setContextMenu] = useState(false);
@ -88,11 +94,11 @@ export const SingleListIssue: React.FC<Props> = ({
const { setToastAlert } = useToast();
const { groupByProperty: selectedGroup, orderBy, params } = useIssueView();
const { groupByProperty: selectedGroup, orderBy, params, properties } = viewProps;
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !projectId) return;
if (!workspaceSlug || !issue) return;
const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
@ -100,7 +106,9 @@ export const SingleListIssue: React.FC<Props> = ({
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
: router.pathname.includes("my-issues")
? USER_ISSUES(workspaceSlug.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project.toString(), params);
if (issue.parent) {
mutate<ISubIssueResponse>(
@ -145,7 +153,7 @@ export const SingleListIssue: React.FC<Props> = ({
}
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user)
.patchIssue(workspaceSlug as string, issue.project, issue.id, formData, user)
.then(() => {
mutate(fetchKey);
@ -155,7 +163,6 @@ export const SingleListIssue: React.FC<Props> = ({
},
[
workspaceSlug,
projectId,
cycleId,
moduleId,
viewId,
@ -164,6 +171,7 @@ export const SingleListIssue: React.FC<Props> = ({
selectedGroup,
orderBy,
params,
router,
user,
]
);
@ -186,7 +194,8 @@ export const SingleListIssue: React.FC<Props> = ({
? `/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}`
: `/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted || isArchivedIssues;
const isNotAllowed =
userAuth.isGuest || userAuth.isViewer || disableUserActions || isArchivedIssues;
return (
<>

View File

@ -8,7 +8,7 @@ import { Disclosure, Transition } from "@headlessui/react";
import issuesService from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useProjects from "hooks/use-projects";
// components
import { SingleListIssue } from "components/core";
// ui
@ -18,58 +18,52 @@ import { PlusIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import {
ICurrentUserResponse,
IIssue,
IIssueLabels,
IIssueViewProps,
IState,
TIssueGroupByOptions,
UserAuth,
} from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
type?: "issue" | "cycle" | "module";
currentState?: IState | null;
groupTitle: string;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: TIssueGroupByOptions;
addIssueToState: () => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
addIssueToGroup: () => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
viewProps: IIssueViewProps;
};
export const SingleList: React.FC<Props> = ({
type,
currentState,
groupTitle,
groupedByIssues,
selectedGroup,
addIssueToState,
makeIssueCopy,
handleEditIssue,
handleDeleteIssue,
addIssueToGroup,
handleIssueAction,
openIssuesListModal,
removeIssue,
isCompleted = false,
disableUserActions,
user,
userAuth,
viewProps,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
const { groupByProperty: selectedGroup, groupedIssues } = viewProps;
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
@ -85,6 +79,8 @@ export const SingleList: React.FC<Props> = ({
: null
);
const { projects } = useProjects();
const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle);
@ -95,6 +91,9 @@ export const SingleList: React.FC<Props> = ({
case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
break;
case "project":
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
@ -115,9 +114,22 @@ export const SingleList: React.FC<Props> = ({
icon =
currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color);
break;
case "state_detail.group":
icon = getStateGroupIcon(groupTitle as any, "16", "16");
break;
case "priority":
icon = getPriorityIcon(groupTitle, "text-lg");
break;
case "project":
const project = projects?.find((p) => p.id === groupTitle);
icon =
project &&
(project.emoji !== null
? renderEmoji(project.emoji)
: project.icon_prop !== null
? renderEmoji(project.icon_prop)
: null);
break;
case "labels":
const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
@ -138,6 +150,8 @@ export const SingleList: React.FC<Props> = ({
return icon;
};
if (!groupedIssues) return null;
return (
<Disclosure as="div" defaultOpen>
{({ open }) => (
@ -156,7 +170,7 @@ export const SingleList: React.FC<Props> = ({
<h2 className="font-medium leading-5">All Issues</h2>
)}
<span className="text-custom-text-200 min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs">
{groupedByIssues[groupTitle as keyof IIssue].length}
{groupedIssues[groupTitle as keyof IIssue].length}
</span>
</div>
</Disclosure.Button>
@ -166,11 +180,11 @@ export const SingleList: React.FC<Props> = ({
<button
type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
onClick={addIssueToState}
onClick={addIssueToGroup}
>
<PlusIcon className="h-4 w-4" />
</button>
) : isCompleted ? (
) : disableUserActions ? (
""
) : (
<CustomMenu
@ -182,7 +196,7 @@ export const SingleList: React.FC<Props> = ({
position="right"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={addIssueToGroup}>Create new</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
@ -201,26 +215,26 @@ export const SingleList: React.FC<Props> = ({
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
{groupedByIssues[groupTitle] ? (
groupedByIssues[groupTitle].length > 0 ? (
groupedByIssues[groupTitle].map((issue, index) => (
{groupedIssues[groupTitle] ? (
groupedIssues[groupTitle].length > 0 ? (
groupedIssues[groupTitle].map((issue, index) => (
<SingleListIssue
key={issue.id}
type={type}
issue={issue}
properties={properties}
groupTitle={groupTitle}
index={index}
editIssue={() => handleEditIssue(issue)}
makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue}
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
removeIssue={() => {
if (removeIssue !== null && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id);
}}
isCompleted={isCompleted}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
))
) : (

View File

@ -53,7 +53,7 @@ type Props = {
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
gridTemplateColumns: string;
isCompleted?: boolean;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
nestingLevel: number;
@ -68,7 +68,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
handleEditIssue,
handleDeleteIssue,
gridTemplateColumns,
isCompleted = false,
disableUserActions,
user,
userAuth,
nestingLevel,
@ -190,7 +190,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
{!isNotAllowed && !isCompleted && (
{!isNotAllowed && !disableUserActions && (
<div className="absolute top-0 left-2.5 opacity-0 group-hover:opacity-100">
<Popover2
isOpen={isOpen}

View File

@ -8,32 +8,28 @@ import useSubIssue from "hooks/use-sub-issue";
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
type Props = {
key: string;
issue: IIssue;
index: number;
expandedIssues: string[];
setExpandedIssues: React.Dispatch<React.SetStateAction<string[]>>;
properties: Properties;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
gridTemplateColumns: string;
isCompleted?: boolean;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
nestingLevel?: number;
};
export const SpreadsheetIssues: React.FC<Props> = ({
key,
index,
issue,
expandedIssues,
setExpandedIssues,
gridTemplateColumns,
properties,
handleEditIssue,
handleDeleteIssue,
isCompleted = false,
handleIssueAction,
disableUserActions,
user,
userAuth,
nestingLevel = 0,
@ -64,9 +60,9 @@ export const SpreadsheetIssues: React.FC<Props> = ({
handleToggleExpand={handleToggleExpand}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
isCompleted={isCompleted}
handleEditIssue={() => handleIssueAction(issue, "edit")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
nestingLevel={nestingLevel}
@ -76,7 +72,7 @@ export const SpreadsheetIssues: React.FC<Props> = ({
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue, subIndex: number) => (
subIssues.map((subIssue: IIssue) => (
<SpreadsheetIssues
key={subIssue.id}
issue={subIssue}
@ -85,9 +81,8 @@ export const SpreadsheetIssues: React.FC<Props> = ({
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
isCompleted={isCompleted}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
nestingLevel={nestingLevel + 1}

View File

@ -17,28 +17,26 @@ import { SPREADSHEET_COLUMN } from "constants/spreadsheet";
import { PlusIcon } from "@heroicons/react/24/outline";
type Props = {
type: "issue" | "cycle" | "module";
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null;
isCompleted?: boolean;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
export const SpreadsheetView: React.FC<Props> = ({
type,
handleEditIssue,
handleDeleteIssue,
handleIssueAction,
openIssuesListModal,
isCompleted = false,
disableUserActions,
user,
userAuth,
}) => {
const [expandedIssues, setExpandedIssues] = useState<string[]>([]);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
const { spreadsheetIssues } = useSpreadsheetIssuesView();
@ -76,9 +74,8 @@ export const SpreadsheetView: React.FC<Props> = ({
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
isCompleted={isCompleted}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
/>
@ -99,7 +96,7 @@ export const SpreadsheetView: React.FC<Props> = ({
Add Issue
</button>
) : (
!isCompleted && (
!disableUserActions && (
<CustomMenu
className="sticky left-0 z-[1]"
customButton={

View File

@ -5,6 +5,8 @@ import {
StartedStateIcon,
UnstartedStateIcon,
} from "components/icons";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
export const getStateGroupIcon = (
stateGroup: "backlog" | "unstarted" | "started" | "completed" | "cancelled",
@ -14,15 +16,45 @@ export const getStateGroupIcon = (
) => {
switch (stateGroup) {
case "backlog":
return <BacklogStateIcon width={width} height={height} color={color} />;
return (
<BacklogStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["backlog"]}
/>
);
case "unstarted":
return <UnstartedStateIcon width={width} height={height} color={color} />;
return (
<UnstartedStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["unstarted"]}
/>
);
case "started":
return <StartedStateIcon width={width} height={height} color={color} />;
return (
<StartedStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["started"]}
/>
);
case "completed":
return <CompletedStateIcon width={width} height={height} color={color} />;
return (
<CompletedStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["completed"]}
/>
);
case "cancelled":
return <CancelledStateIcon width={width} height={height} color={color} />;
return (
<CancelledStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["cancelled"]}
/>
);
default:
return <></>;
}

View File

@ -37,8 +37,8 @@ export const FiltersDropdown: React.FC = () => {
id: "priority",
label: "Priority",
value: PRIORITIES,
children: [
...PRIORITIES.map((priority) => ({
hasChildren: true,
children: PRIORITIES.map((priority) => ({
id: priority === null ? "null" : priority,
label: (
<div className="flex items-center gap-2 capitalize">
@ -51,14 +51,13 @@ export const FiltersDropdown: React.FC = () => {
},
selected: filters?.priority?.includes(priority === null ? "null" : priority),
})),
],
},
{
id: "inbox_status",
label: "Status",
value: INBOX_STATUS.map((status) => status.value),
children: [
...INBOX_STATUS.map((status) => ({
hasChildren: true,
children: INBOX_STATUS.map((status) => ({
id: status.key,
label: status.label,
value: {
@ -67,7 +66,6 @@ export const FiltersDropdown: React.FC = () => {
},
selected: filters?.inbox_status?.includes(status.value),
})),
],
},
]}
/>

View File

@ -63,7 +63,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
if (!workspaceSlug || !projectId || !data) return;
await issueServices
.deleteIssue(workspaceSlug as string, projectId as string, data.id, user)
.deleteIssue(workspaceSlug as string, data.project, data.id, user)
.then(() => {
if (issueView === "calendar") {
const calendarFetchKey = cycleId
@ -72,7 +72,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams)
: viewId
? VIEW_ISSUES(viewId.toString(), calendarParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), calendarParams);
: PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, calendarParams);
mutate<IIssue[]>(
calendarFetchKey,
@ -86,7 +86,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams)
: viewId
? VIEW_ISSUES(viewId.toString(), spreadsheetParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", spreadsheetParams);
: PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, spreadsheetParams);
if (data.parent) {
mutate<ISubIssueResponse>(
SUB_ISSUES(data.parent.toString()),

View File

@ -1,5 +1,6 @@
export * from "./attachment";
export * from "./comment";
export * from "./my-issues";
export * from "./sidebar-select";
export * from "./view-select";
export * from "./activity";
@ -9,7 +10,6 @@ export * from "./form";
export * from "./gantt-chart";
export * from "./main-content";
export * from "./modal";
export * from "./my-issues-list-item";
export * from "./parent-issues-list-modal";
export * from "./sidebar";
export * from "./sub-issues-list";

View File

@ -1,218 +0,0 @@
import React, { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
// services
import issuesService from "services/issues.service";
// components
import {
ViewDueDateSelect,
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues/view-select";
// icon
import { LinkIcon, PaperClipIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// ui
import { AssigneesList } from "components/ui/avatar";
import { CustomMenu, Tooltip } from "components/ui";
// types
import { IIssue, Properties } from "types";
// helper
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// fetch-keys
import { USER_ISSUE } from "constants/fetch-keys";
type Props = {
issue: IIssue;
properties: Properties;
projectId: string;
};
export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUserAuth();
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug) return;
mutate<IIssue[]>(
USER_ISSUE(workspaceSlug as string),
(prevData) =>
prevData?.map((p) => {
if (p.id === issue.id) return { ...p, ...formData };
return p;
}),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user)
.then((res) => {
mutate(USER_ISSUE(workspaceSlug as string));
})
.catch((error) => {
console.log(error);
});
},
[workspaceSlug, projectId, user]
);
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const isNotAllowed = false;
return (
<div className="border-b border-custom-border-200 bg-custom-background-100 px-4 py-2.5 last:border-b-0">
<div key={issue.id} className="flex items-center justify-between gap-2">
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties?.key && (
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
)}
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100">
{truncateText(issue.name, 50)}
</span>
</Tooltip>
</a>
</Link>
<div className="flex flex-wrap items-center gap-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && issue.target_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.labels && issue.labels.length > 0 && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs text-custom-text-200"
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
)}
{properties.assignee && (
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm">
<Tooltip
position="top-right"
tooltipHeading="Assignees"
tooltipContent={
issue.assignee_details.length > 0
? issue.assignee_details
.map((assignee) =>
assignee?.first_name !== "" ? assignee?.first_name : assignee?.email
)
.join(", ")
: "No Assignee"
}
>
<div className="flex h-4 items-center gap-1">
<AssigneesList userIds={issue.assignees ?? []} />
</div>
</Tooltip>
</div>
)}
{properties.sub_issue_count && issue.sub_issues_count > 0 && (
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
<div className="flex items-center gap-1 text-custom-text-200">
<LayerDiagonalIcon className="h-3.5 w-3.5" />
{issue.sub_issues_count}
</div>
</Tooltip>
</div>
)}
{properties.link && issue.link_count > 0 && (
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
<div className="flex items-center gap-1 text-custom-text-200">
<LinkIcon className="h-3.5 w-3.5 text-custom-text-200" />
{issue.link_count}
</div>
</Tooltip>
</div>
)}
{properties.attachment_count && issue.attachment_count > 0 && (
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}>
<div className="flex items-center gap-1 text-custom-text-200">
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45 text-custom-text-200" />
{issue.attachment_count}
</div>
</Tooltip>
</div>
)}
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./my-issues-select-filters";
export * from "./my-issues-view-options";
export * from "./my-issues-view";

View File

@ -0,0 +1,168 @@
import { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import issuesService from "services/issues.service";
// components
import { DueDateFilterModal } from "components/core";
// ui
import { MultiLevelDropdown } from "components/ui";
// icons
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// helpers
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
// types
import { IIssueFilterOptions, IQuery } from "types";
// fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys";
// constants
import { GROUP_CHOICES, PRIORITIES } from "constants/project";
import { DUE_DATES } from "constants/due-dates";
type Props = {
filters: Partial<IIssueFilterOptions> | IQuery;
onSelect: (option: any) => void;
direction?: "left" | "right";
height?: "sm" | "md" | "rg" | "lg";
};
export const MyIssuesSelectFilters: React.FC<Props> = ({
filters,
onSelect,
direction = "right",
height = "md",
}) => {
const [isDueDateFilterModalOpen, setIsDueDateFilterModalOpen] = useState(false);
const [fetchLabels, setFetchLabels] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: labels } = useSWR(
workspaceSlug && fetchLabels ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
workspaceSlug && fetchLabels
? () => issuesService.getWorkspaceLabels(workspaceSlug.toString())
: null
);
return (
<>
{isDueDateFilterModalOpen && (
<DueDateFilterModal
isOpen={isDueDateFilterModalOpen}
handleClose={() => setIsDueDateFilterModalOpen(false)}
/>
)}
<MultiLevelDropdown
label="Filters"
onSelect={onSelect}
direction={direction}
height={height}
options={[
{
id: "priority",
label: "Priority",
value: PRIORITIES,
hasChildren: true,
children: [
...PRIORITIES.map((priority) => ({
id: priority === null ? "null" : priority,
label: (
<div className="flex items-center gap-2 capitalize">
{getPriorityIcon(priority)} {priority ?? "None"}
</div>
),
value: {
key: "priority",
value: priority === null ? "null" : priority,
},
selected: filters?.priority?.includes(priority === null ? "null" : priority),
})),
],
},
{
id: "state_group",
label: "State groups",
value: GROUP_CHOICES,
hasChildren: true,
children: [
...Object.keys(GROUP_CHOICES).map((key) => ({
id: key,
label: (
<div className="flex items-center gap-2">
{getStateGroupIcon(key as any, "16", "16")}{" "}
{GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]}
</div>
),
value: {
key: "state_group",
value: key,
},
selected: filters?.state?.includes(key),
})),
],
},
{
id: "labels",
label: "Labels",
onClick: () => setFetchLabels(true),
value: labels,
hasChildren: true,
children: labels?.map((label) => ({
id: label.id,
label: (
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
</div>
),
value: {
key: "labels",
value: label.id,
},
selected: filters?.labels?.includes(label.id),
})),
},
{
id: "target_date",
label: "Due date",
value: DUE_DATES,
hasChildren: true,
children: [
...(DUE_DATES?.map((option) => ({
id: option.name,
label: option.name,
value: {
key: "target_date",
value: option.value,
},
selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value),
})) ?? []),
{
id: "custom",
label: "Custom",
value: "custom",
element: (
<button
onClick={() => setIsDueDateFilterModalOpen(true)}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
]}
/>
</>
);
};

View File

@ -0,0 +1,290 @@
import React from "react";
import { useRouter } from "next/router";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// hooks
import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter";
import useEstimateOption from "hooks/use-estimate-option";
// components
import { MyIssuesSelectFilters } from "components/issues";
// ui
import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { FormatListBulletedOutlined, GridViewOutlined } from "@mui/icons-material";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
// types
import { Properties, TIssueViewOptions } from "types";
// constants
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
{
type: "list",
Icon: FormatListBulletedOutlined,
},
{
type: "kanban",
Icon: GridViewOutlined,
},
];
export const MyIssuesViewOptions: React.FC = () => {
const router = useRouter();
const { workspaceSlug } = router.query;
const {
issueView,
setIssueView,
groupBy,
setGroupBy,
orderBy,
setOrderBy,
showEmptyGroups,
setShowEmptyGroups,
properties,
setProperty,
filters,
setFilters,
} = useMyIssuesFilters(workspaceSlug?.toString());
const { isEstimateActive } = useEstimateOption();
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>
}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
issueView === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setIssueView(option.type)}
>
<option.Icon
sx={{
fontSize: 16,
}}
className={option.type === "gantt_chart" ? "rotate-90" : ""}
/>
</button>
</Tooltip>
))}
</div>
<MyIssuesSelectFilters
filters={filters}
onSelect={(option) => {
const key = option.key as keyof typeof filters;
if (key === "target_date") {
const valueExists = checkIfArraysHaveSameElements(
filters?.target_date ?? [],
option.value
);
setFilters({
target_date: valueExists ? null : option.value,
});
} else {
const valueExists = filters[key]?.includes(option.value);
if (valueExists)
setFilters({
[option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value
),
});
else
setFilters({
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
});
}
}}
direction="left"
height="rg"
/>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-custom-sidebar-border-200 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
open
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
: "text-custom-sidebar-text-200"
}`}
>
View
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
<div className="relative divide-y-2 divide-custom-border-200">
<div className="space-y-4 pb-3 text-xs">
{issueView !== "calendar" && issueView !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4>
<CustomMenu
label={
groupBy === "project"
? "Project"
: GROUP_BY_OPTIONS.find((option) => option.key === groupBy)?.name ??
"Select"
}
>
{GROUP_BY_OPTIONS.map((option) => {
if (issueView === "kanban" && option.key === null) return null;
if (option.key === "state" || option.key === "created_by")
return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupBy(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
>
{ORDER_BY_OPTIONS.map((option) => {
if (groupBy === "priority" && option.key === "priority") return null;
if (option.key === "sort_order") return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</>
)}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters?.type)
?.name ?? "Select"
}
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
{issueView !== "calendar" && issueView !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4>
<ToggleSwitch value={showEmptyGroups} onChange={setShowEmptyGroups} />
</div>
{/* <div className="relative flex justify-end gap-x-3">
<button type="button" onClick={() => resetFilterToDefault()}>
Reset to default
</button>
<button
type="button"
className="font-medium text-custom-primary"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div> */}
</>
)}
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
if (
issueView === "spreadsheet" &&
(key === "attachment_count" ||
key === "link" ||
key === "sub_issue_count")
)
return null;
if (
issueView !== "spreadsheet" &&
(key === "created_on" || key === "updated_on")
)
return null;
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200"
}`}
onClick={() => setProperty(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
};

View File

@ -0,0 +1,288 @@
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DropResult } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
// hooks
import useMyIssues from "hooks/my-issues/use-my-issues";
import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter";
import useUserAuth from "hooks/use-user-auth";
// components
import { AllViews, FiltersList } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateViewModal } from "components/views";
// types
import { IIssue, IIssueFilterOptions } from "types";
// fetch-keys
import { USER_ISSUES, WORKSPACE_LABELS } from "constants/fetch-keys";
import { orderArrayBy } from "helpers/array.helper";
type Props = {
openIssuesListModal?: () => void;
disableUserActions?: false;
};
export const MyIssuesView: React.FC<Props> = ({
openIssuesListModal,
disableUserActions = false,
}) => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [createViewModal, setCreateViewModal] = useState<any>(null);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
// update issue modal
const [editIssueModal, setEditIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<
(IIssue & { actionType: "edit" | "delete" }) | undefined
>(undefined);
// delete issue modal
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
// trash box
const [trashBox, setTrashBox] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUserAuth();
const { groupedIssues, isEmpty, params } = useMyIssues(workspaceSlug?.toString());
const { filters, setFilters, issueView, groupBy, orderBy, properties, showEmptyGroups } =
useMyIssuesFilters(workspaceSlug?.toString());
const { data: labels } = useSWR(
workspaceSlug && (filters.labels ?? []).length > 0
? WORKSPACE_LABELS(workspaceSlug.toString())
: null,
workspaceSlug && (filters.labels ?? []).length > 0
? () => issuesService.getWorkspaceLabels(workspaceSlug.toString())
: null
);
const handleDeleteIssue = useCallback(
(issue: IIssue) => {
setDeleteIssueModal(true);
setIssueToDelete(issue);
},
[setDeleteIssueModal, setIssueToDelete]
);
const handleOnDragEnd = useCallback(
async (result: DropResult) => {
setTrashBox(false);
console.log(result);
if (!result.destination || !workspaceSlug || !groupedIssues || groupBy !== "priority") return;
const { source, destination } = result;
if (source.droppableId === destination.droppableId) return;
const draggedItem = groupedIssues[source.droppableId][source.index];
if (!draggedItem) return;
if (destination.droppableId === "trashBox") handleDeleteIssue(draggedItem);
else {
const sourceGroup = source.droppableId;
const destinationGroup = destination.droppableId;
draggedItem[groupBy] = destinationGroup;
mutate<{
[key: string]: IIssue[];
}>(
USER_ISSUES(workspaceSlug.toString(), params),
(prevData) => {
if (!prevData) return prevData;
const sourceGroupArray = [...groupedIssues[sourceGroup]];
const destinationGroupArray = [...groupedIssues[destinationGroup]];
sourceGroupArray.splice(source.index, 1);
destinationGroupArray.splice(destination.index, 0, draggedItem);
return {
...prevData,
[sourceGroup]: orderArrayBy(sourceGroupArray, orderBy),
[destinationGroup]: orderArrayBy(destinationGroupArray, orderBy),
};
},
false
);
// patch request
issuesService
.patchIssue(
workspaceSlug as string,
draggedItem.project,
draggedItem.id,
{
priority: draggedItem.priority,
},
user
)
.catch(() => mutate(USER_ISSUES(workspaceSlug.toString(), params)));
}
},
[groupBy, groupedIssues, handleDeleteIssue, orderBy, params, user, workspaceSlug]
);
const addIssueToGroup = useCallback(
(groupTitle: string) => {
setCreateIssueModal(true);
let preloadedValue: string | string[] = groupTitle;
if (groupBy === "labels") {
if (groupTitle === "None") preloadedValue = [];
else preloadedValue = [groupTitle];
}
if (groupBy)
setPreloadedData({
[groupBy]: preloadedValue,
actionType: "createIssue",
});
else setPreloadedData({ actionType: "createIssue" });
},
[setCreateIssueModal, setPreloadedData, groupBy]
);
const addIssueToDate = useCallback(
(date: string) => {
setCreateIssueModal(true);
setPreloadedData({
target_date: date,
actionType: "createIssue",
});
},
[setCreateIssueModal, setPreloadedData]
);
const makeIssueCopy = useCallback(
(issue: IIssue) => {
setCreateIssueModal(true);
setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
},
[setCreateIssueModal, setPreloadedData]
);
const handleEditIssue = useCallback(
(issue: IIssue) => {
setEditIssueModal(true);
setIssueToEdit({
...issue,
actionType: "edit",
cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null,
module: issue.issue_module ? issue.issue_module.module : null,
});
},
[setEditIssueModal, setIssueToEdit]
);
const handleIssueAction = useCallback(
(issue: IIssue, action: "copy" | "edit" | "delete") => {
if (action === "copy") makeIssueCopy(issue);
else if (action === "edit") handleEditIssue(issue);
else if (action === "delete") handleDeleteIssue(issue);
},
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
);
const filtersToShow = { ...filters };
delete filtersToShow?.assignees;
delete filtersToShow?.created_by;
const nullFilters = Object.keys(filtersToShow).filter(
(key) => filtersToShow[key as keyof IIssueFilterOptions] === null
);
const areFiltersApplied =
Object.keys(filtersToShow).length > 0 &&
nullFilters.length !== Object.keys(filtersToShow).length;
return (
<>
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
preLoadedData={createViewModal}
user={user}
/>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
user={user}
/>
{areFiltersApplied && (
<>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
<FiltersList
filters={filtersToShow}
setFilters={setFilters}
labels={labels}
members={undefined}
states={undefined}
clearAllFilters={() =>
setFilters({
labels: null,
priority: null,
state_group: null,
target_date: null,
type: null,
})
}
/>
</div>
{<div className="mt-3 border-t border-custom-border-200" />}
</>
)}
<AllViews
addIssueToDate={addIssueToDate}
addIssueToGroup={addIssueToGroup}
disableUserActions={disableUserActions}
dragDisabled={groupBy !== "priority"}
handleOnDragEnd={handleOnDragEnd}
handleIssueAction={handleIssueAction}
openIssuesListModal={openIssuesListModal ? openIssuesListModal : null}
removeIssue={null}
trashBox={trashBox}
setTrashBox={setTrashBox}
viewProps={{
groupByProperty: groupBy,
groupedIssues,
isEmpty,
issueView,
orderBy,
params,
properties,
showEmptyGroups,
}}
/>
</>
);
};

View File

@ -1,3 +1,5 @@
import { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
@ -39,12 +41,14 @@ export const ViewStateSelect: React.FC<Props> = ({
user,
isNotAllowed,
}) => {
const [fetchStates, setFetchStates] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: stateGroups } = useSWR(
workspaceSlug && issue ? STATES_LIST(issue.project) : null,
workspaceSlug && issue
workspaceSlug && issue && fetchStates ? STATES_LIST(issue.project) : null,
workspaceSlug && issue && fetchStates
? () => stateService.getStates(workspaceSlug as string, issue.project)
: null
);
@ -61,7 +65,7 @@ export const ViewStateSelect: React.FC<Props> = ({
),
}));
const selectedOption = states?.find((s) => s.id === issue.state);
const selectedOption = issue.state_detail;
const stateLabel = (
<Tooltip
@ -126,6 +130,7 @@ export const ViewStateSelect: React.FC<Props> = ({
{...(customButton ? { customButton: stateLabel } : { label: stateLabel })}
position={position}
disabled={isNotAllowed}
onOpen={() => setFetchStates(true)}
noChevron
/>
);

View File

@ -244,20 +244,7 @@ export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user })
<EmojiIconPicker
label={
<div className="h-[44px] w-[44px] grid place-items-center rounded-md bg-custom-background-80 outline-none text-lg">
{value ? (
typeof value === "object" ? (
<span
style={{ color: value.color }}
className="material-symbols-rounded text-lg"
>
{value.name}
</span>
) : (
renderEmoji(value)
)
) : (
"Icon"
)}
{value ? renderEmoji(value) : "Icon"}
</div>
}
onChange={onChange}

View File

@ -176,12 +176,7 @@ export const SingleProjectCard: React.FC<ProjectCardProps> = ({
{renderEmoji(project.emoji)}
</span>
) : project.icon_prop ? (
<span
style={{ color: project.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{project.icon_prop.name}
</span>
renderEmoji(project.icon_prop)
) : null}
</div>
<p className="mt-3.5 mb-7 break-words">

View File

@ -150,12 +150,7 @@ export const SingleSidebarProject: React.FC<Props> = ({
</span>
) : project.icon_prop ? (
<div className="h-7 w-7 grid place-items-center">
<span
style={{ color: project.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{project.icon_prop.name}
</span>
{renderEmoji(project.icon_prop)}
</div>
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">

View File

@ -39,6 +39,7 @@ export const CustomSearchSelect = ({
noChevron = false,
onChange,
options,
onOpen,
optionsClassName = "",
position = "left",
selfPositioned = false,
@ -67,7 +68,10 @@ export const CustomSearchSelect = ({
className={`${selfPositioned ? "" : "relative"} flex-shrink-0 text-left ${className}`}
{...props}
>
{({ open }: any) => (
{({ open }: any) => {
if (open && onOpen) onOpen();
return (
<>
{customButton ? (
<Combobox.Button as="div">{customButton}</Combobox.Button>
@ -175,7 +179,8 @@ export const CustomSearchSelect = ({
</Combobox.Options>
</Transition>
</>
)}
);
}}
</Combobox>
);
};

View File

@ -7,6 +7,7 @@ export type DropdownProps = {
label?: string | JSX.Element;
maxHeight?: "sm" | "rg" | "md" | "lg";
noChevron?: boolean;
onOpen?: () => void;
optionsClassName?: string;
position?: "right" | "left";
selfPositioned?: boolean;

View File

@ -2,6 +2,8 @@ import { Fragment, useState } from "react";
// headless ui
import { Menu, Transition } from "@headlessui/react";
// ui
import { Loader } from "components/ui";
// icons
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import { ChevronRightIcon, ChevronLeftIcon } from "@heroicons/react/20/solid";
@ -10,9 +12,6 @@ type MultiLevelDropdownProps = {
label: string;
options: {
id: string;
label: string;
value: any;
selected?: boolean;
children?: {
id: string;
label: string | JSX.Element;
@ -20,6 +19,11 @@ type MultiLevelDropdownProps = {
selected?: boolean;
element?: JSX.Element;
}[];
hasChildren: boolean;
label: string;
onClick?: () => void;
selected?: boolean;
value: any;
}[];
onSelect: (value: any) => void;
direction?: "left" | "right";
@ -69,15 +73,15 @@ export const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
<Menu.Item
as="button"
onClick={(e: any) => {
if (option.children) {
if (option.hasChildren) {
e.stopPropagation();
e.preventDefault();
if (option.onClick) option.onClick();
if (openChildFor === option.id) setOpenChildFor(null);
else setOpenChildFor(option.id);
} else {
onSelect(option.value);
}
} else onSelect(option.value);
}}
className="w-full"
>
@ -90,18 +94,18 @@ export const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
direction === "right" ? "justify-between" : ""
}`}
>
{direction === "left" && option.children && (
{direction === "left" && option.hasChildren && (
<ChevronLeftIcon className="h-4 w-4" aria-hidden="true" />
)}
<span>{option.label}</span>
{direction === "right" && option.children && (
{direction === "right" && option.hasChildren && (
<ChevronRightIcon className="h-4 w-4" aria-hidden="true" />
)}
</div>
</>
)}
</Menu.Item>
{option.children && option.id === openChildFor && (
{option.hasChildren && option.id === openChildFor && (
<div
className={`absolute top-0 w-36 origin-top-right select-none overflow-y-scroll rounded-md bg-custom-background-90 shadow-lg focus:outline-none ${
direction === "left"
@ -119,6 +123,7 @@ export const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
: ""
}`}
>
{option.children ? (
<div className="space-y-1 p-1">
{option.children.map((child) => {
if (child.element) return child.element;
@ -142,6 +147,14 @@ export const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
);
})}
</div>
) : (
<Loader className="p-1 space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</div>

View File

@ -1,14 +1,28 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// services
import stateService from "services/state.service";
// hooks
import useProjectMembers from "hooks/use-project-members";
// components
import { FiltersList } from "components/core";
import { SelectFilters } from "components/views";
// ui
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// components
import { FilterList } from "components/core";
// helpers
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
import { getStatesList } from "helpers/state.helper";
// types
import { IView } from "types";
// components
import { SelectFilters } from "components/views";
import { IQuery, IView } from "types";
import issuesService from "services/issues.service";
// fetch-keys
import { PROJECT_ISSUE_LABELS, STATES_LIST } from "constants/fetch-keys";
type Props = {
handleFormSubmit: (values: IView) => Promise<void>;
@ -30,6 +44,9 @@ export const ViewForm: React.FC<Props> = ({
data,
preLoadedData,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
register,
formState: { errors, isSubmitting },
@ -42,6 +59,26 @@ export const ViewForm: React.FC<Props> = ({
});
const filters = watch("query");
const { data: stateGroups } = useSWR(
workspaceSlug && projectId && (filters.state ?? []).length > 0
? STATES_LIST(projectId as string)
: null,
workspaceSlug && (filters.state ?? []).length > 0
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
const { data: labels } = useSWR(
workspaceSlug && projectId && (filters.labels ?? []).length > 0
? PROJECT_ISSUE_LABELS(projectId.toString())
: null,
workspaceSlug && projectId && (filters.labels ?? []).length > 0
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
: null
);
const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString());
const handleCreateUpdateView = async (formData: IView) => {
await handleFormSubmit(formData);
@ -50,6 +87,18 @@ export const ViewForm: React.FC<Props> = ({
});
};
const clearAllFilters = () => {
setValue("query", {
assignees: null,
created_by: null,
labels: null,
priority: null,
state: null,
target_date: null,
type: null,
});
};
useEffect(() => {
reset({
...defaultValues,
@ -106,6 +155,16 @@ export const ViewForm: React.FC<Props> = ({
onSelect={(option) => {
const key = option.key as keyof typeof filters;
if (key === "target_date") {
const valueExists = checkIfArraysHaveSameElements(
filters?.target_date ?? [],
option.value
);
setValue("query", {
target_date: valueExists ? null : option.value,
} as IQuery);
} else {
if (!filters?.[key]?.includes(option.value))
setValue("query", {
...filters,
@ -117,12 +176,17 @@ export const ViewForm: React.FC<Props> = ({
[key]: (filters?.[key] as any[])?.filter((item) => item !== option.value),
});
}
}
}}
/>
</div>
<div>
<FilterList
<FiltersList
filters={filters}
labels={labels}
members={members?.map((m) => m.member)}
states={states}
clearAllFilters={clearAllFilters}
setFilters={(query: any) => {
setValue("query", {
...filters,

View File

@ -83,8 +83,8 @@ export const SelectFilters: React.FC<Props> = ({
id: "priority",
label: "Priority",
value: PRIORITIES,
children: [
...PRIORITIES.map((priority) => ({
hasChildren: true,
children: PRIORITIES.map((priority) => ({
id: priority === null ? "null" : priority,
label: (
<div className="flex items-center gap-2 capitalize">
@ -97,14 +97,13 @@ export const SelectFilters: React.FC<Props> = ({
},
selected: filters?.priority?.includes(priority === null ? "null" : priority),
})),
],
},
{
id: "state",
label: "State",
value: statesList,
children: [
...statesList.map((state) => ({
hasChildren: true,
children: statesList.map((state) => ({
id: state.id,
label: (
<div className="flex items-center gap-2">
@ -117,14 +116,13 @@ export const SelectFilters: React.FC<Props> = ({
},
selected: filters?.state?.includes(state.id),
})),
],
},
{
id: "assignees",
label: "Assignees",
value: members,
children: [
...(members?.map((member) => ({
hasChildren: true,
children: members?.map((member) => ({
id: member.member.id,
label: (
<div className="flex items-center gap-2">
@ -139,15 +137,14 @@ export const SelectFilters: React.FC<Props> = ({
value: member.member.id,
},
selected: filters?.assignees?.includes(member.member.id),
})) ?? []),
],
})),
},
{
id: "created_by",
label: "Created by",
value: members,
children: [
...(members?.map((member) => ({
hasChildren: true,
children: members?.map((member) => ({
id: member.member.id,
label: (
<div className="flex items-center gap-2">
@ -162,23 +159,21 @@ export const SelectFilters: React.FC<Props> = ({
value: member.member.id,
},
selected: filters?.created_by?.includes(member.member.id),
})) ?? []),
],
})),
},
{
id: "labels",
label: "Labels",
value: issueLabels,
children: [
...(issueLabels?.map((label) => ({
hasChildren: true,
children: issueLabels?.map((label) => ({
id: label.id,
label: (
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor:
label.color && label.color !== "" ? label.color : "#000000",
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
@ -189,15 +184,15 @@ export const SelectFilters: React.FC<Props> = ({
value: label.id,
},
selected: filters?.labels?.includes(label.id),
})) ?? []),
],
})),
},
{
id: "target_date",
label: "Due date",
value: DUE_DATES,
hasChildren: true,
children: [
...(DUE_DATES?.map((option) => ({
...DUE_DATES.map((option) => ({
id: option.name,
label: option.name,
value: {
@ -205,7 +200,7 @@ export const SelectFilters: React.FC<Props> = ({
value: option.value,
},
selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value),
})) ?? []),
})),
{
id: "custom",
label: "Custom",

View File

@ -37,6 +37,29 @@ const inboxParamsToKey = (params: any) => {
return `${priorityKey}_${inboxStatusKey}`;
};
const myIssuesParamsToKey = (params: any) => {
const { assignees, created_by, labels, priority, state_group, target_date } = params;
let assigneesKey = assignees ? assignees.split(",") : [];
let createdByKey = created_by ? created_by.split(",") : [];
let stateGroupKey = state_group ? state_group.split(",") : [];
let priorityKey = priority ? priority.split(",") : [];
let labelsKey = labels ? labels.split(",") : [];
const targetDateKey = target_date ?? "";
const type = params.type ? params.type.toUpperCase() : "NULL";
const groupBy = params.group_by ? params.group_by.toUpperCase() : "NULL";
const orderBy = params.order_by ? params.order_by.toUpperCase() : "NULL";
// sorting each keys in ascending order
assigneesKey = assigneesKey.sort().join("_");
createdByKey = createdByKey.sort().join("_");
stateGroupKey = stateGroupKey.sort().join("_");
priorityKey = priorityKey.sort().join("_");
labelsKey = labelsKey.sort().join("_");
return `${assigneesKey}_${createdByKey}_${stateGroupKey}_${priorityKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${targetDateKey}`;
};
export const CURRENT_USER = "CURRENT_USER";
export const USER_WORKSPACE_INVITATIONS = "USER_WORKSPACE_INVITATIONS";
export const USER_WORKSPACES = "USER_WORKSPACES";
@ -97,6 +120,8 @@ export const PROJECT_ISSUE_BY_STATE = (projectId: string) =>
`PROJECT_ISSUE_BY_STATE_${projectId.toUpperCase()}`;
export const PROJECT_ISSUE_LABELS = (projectId: string) =>
`PROJECT_ISSUE_LABELS_${projectId.toUpperCase()}`;
export const WORKSPACE_LABELS = (workspaceSlug: string) =>
`WORKSPACE_LABELS_${workspaceSlug.toUpperCase()}`;
export const PROJECT_GITHUB_REPOSITORY = (projectId: string) =>
`PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`;
@ -123,9 +148,15 @@ export const CYCLE_ISSUES_WITH_PARAMS = (cycleId: string, params?: any) => {
export const CYCLE_DETAILS = (cycleId: string) => `CYCLE_DETAILS_${cycleId.toUpperCase()}`;
export const STATES_LIST = (projectId: string) => `STATES_LIST_${projectId.toUpperCase()}`;
export const STATE_DETAILS = "STATE_DETAILS";
export const USER_ISSUE = (workspaceSlug: string) => `USER_ISSUE_${workspaceSlug.toUpperCase()}`;
export const USER_ISSUES = (workspaceSlug: string, params: any) => {
if (!params) return `USER_ISSUES_${workspaceSlug.toUpperCase()}`;
const paramsKey = myIssuesParamsToKey(params);
return `USER_ISSUES_${paramsKey}`;
};
export const USER_ACTIVITY = "USER_ACTIVITY";
export const USER_WORKSPACE_DASHBOARD = (workspaceSlug: string) =>
`USER_WORKSPACE_DASHBOARD_${workspaceSlug.toUpperCase()}`;

View File

@ -2,8 +2,10 @@ export const GROUP_BY_OPTIONS: Array<{
name: string;
key: TIssueGroupByOptions;
}> = [
{ name: "State", key: "state" },
{ name: "States", key: "state" },
{ name: "State Groups", key: "state_detail.group" },
{ name: "Priority", key: "priority" },
{ name: "Project", key: "project" },
{ name: "Labels", key: "labels" },
{ name: "Created by", key: "created_by" },
{ name: "None", key: null },

View File

@ -91,8 +91,6 @@ export const initialState: StateType = {
assignees: null,
labels: null,
state: null,
issue__assignees__id: null,
issue__labels__id: null,
created_by: null,
target_date: null,
},

View File

@ -1,25 +0,0 @@
export const getRandomEmoji = () => {
const emojis = [
"8986",
"9200",
"128204",
"127773",
"127891",
"127947",
"128076",
"128077",
"128187",
"128188",
"128512",
"128522",
"128578",
];
return emojis[Math.floor(Math.random() * emojis.length)];
};
export const renderEmoji = (emoji: string) => {
if (!emoji) return;
return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji));
};

View File

@ -0,0 +1,38 @@
export const getRandomEmoji = () => {
const emojis = [
"8986",
"9200",
"128204",
"127773",
"127891",
"127947",
"128076",
"128077",
"128187",
"128188",
"128512",
"128522",
"128578",
];
return emojis[Math.floor(Math.random() * emojis.length)];
};
export const renderEmoji = (
emoji:
| string
| {
name: string;
color: string;
}
) => {
if (!emoji) return;
if (typeof emoji === "object")
return (
<span style={{ color: emoji.color }} className="material-symbols-rounded text-lg">
{emoji.name}
</span>
);
else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji));
};

View File

@ -0,0 +1,201 @@
import { useEffect, useCallback } from "react";
import useSWR, { mutate } from "swr";
// services
import workspaceService from "services/workspace.service";
// types
import {
IIssueFilterOptions,
IWorkspaceMember,
IWorkspaceViewProps,
Properties,
TIssueGroupByOptions,
TIssueOrderByOptions,
TIssueViewOptions,
} from "types";
// fetch-keys
import { WORKSPACE_MEMBERS_ME } from "constants/fetch-keys";
const initialValues: IWorkspaceViewProps = {
issueView: "list",
filters: {
assignees: null,
created_by: null,
labels: null,
priority: null,
state_group: null,
target_date: null,
type: null,
},
groupByProperty: null,
orderBy: "-created_at",
properties: {
assignee: true,
due_date: true,
key: true,
labels: true,
priority: true,
state: true,
sub_issue_count: true,
attachment_count: true,
link: true,
estimate: true,
created_on: true,
updated_on: true,
},
showEmptyGroups: true,
};
const useMyIssuesFilters = (workspaceSlug: string | undefined) => {
const { data: myWorkspace } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug as string) : null,
workspaceSlug ? () => workspaceService.workspaceMemberMe(workspaceSlug as string) : null,
{
shouldRetryOnError: false,
}
);
const saveData = useCallback(
(data: Partial<IWorkspaceViewProps>) => {
if (!workspaceSlug || !myWorkspace) return;
const oldData = { ...myWorkspace };
mutate<IWorkspaceMember>(
WORKSPACE_MEMBERS_ME(workspaceSlug.toString()),
(prevData) => {
if (!prevData) return;
return {
...prevData,
view_props: {
...prevData?.view_props,
...data,
},
};
},
false
);
workspaceService.updateWorkspaceView(workspaceSlug, {
view_props: {
...oldData.view_props,
...data,
},
});
},
[myWorkspace, workspaceSlug]
);
const issueView = (myWorkspace?.view_props ?? initialValues).issueView;
const setIssueView = useCallback(
(newView: TIssueViewOptions) => {
console.log("newView", newView);
saveData({
issueView: newView,
});
},
[saveData]
);
const groupBy = (myWorkspace?.view_props ?? initialValues).groupByProperty;
const setGroupBy = useCallback(
(newGroup: TIssueGroupByOptions) => {
saveData({
groupByProperty: newGroup,
});
},
[saveData]
);
const orderBy = (myWorkspace?.view_props ?? initialValues).orderBy;
const setOrderBy = useCallback(
(newOrderBy: TIssueOrderByOptions) => {
saveData({
orderBy: newOrderBy,
});
},
[saveData]
);
const showEmptyGroups = (myWorkspace?.view_props ?? initialValues).showEmptyGroups;
const setShowEmptyGroups = useCallback(() => {
if (!myWorkspace) return;
saveData({
showEmptyGroups: !myWorkspace?.view_props?.showEmptyGroups,
});
}, [myWorkspace, saveData]);
const setProperty = useCallback(
(key: keyof Properties) => {
if (!myWorkspace) return;
saveData({
properties: {
...myWorkspace.view_props?.properties,
[key]: !myWorkspace.view_props?.properties[key],
},
});
},
[myWorkspace, saveData]
);
const filters = (myWorkspace?.view_props ?? initialValues).filters;
const setFilters = useCallback(
(updatedFilter: Partial<IIssueFilterOptions & { state_group: string[] | null }>) => {
if (!myWorkspace) return;
saveData({
filters: {
...myWorkspace.view_props?.filters,
...updatedFilter,
},
});
},
[myWorkspace, saveData]
);
useEffect(() => {
if (!myWorkspace || !workspaceSlug) return;
if (!myWorkspace.view_props) {
workspaceService.updateWorkspaceView(workspaceSlug, {
view_props: { ...initialValues },
});
}
}, [myWorkspace, workspaceSlug]);
const newProperties: Properties = {
assignee: myWorkspace?.view_props.properties.assignee ?? true,
due_date: myWorkspace?.view_props.properties.due_date ?? true,
key: myWorkspace?.view_props.properties.key ?? true,
labels: myWorkspace?.view_props.properties.labels ?? true,
priority: myWorkspace?.view_props.properties.priority ?? true,
state: myWorkspace?.view_props.properties.state ?? true,
sub_issue_count: myWorkspace?.view_props.properties.sub_issue_count ?? true,
attachment_count: myWorkspace?.view_props.properties.attachment_count ?? true,
link: myWorkspace?.view_props.properties.link ?? true,
estimate: myWorkspace?.view_props.properties.estimate ?? true,
created_on: myWorkspace?.view_props.properties.created_on ?? true,
updated_on: myWorkspace?.view_props.properties.updated_on ?? true,
};
return {
issueView,
setIssueView,
groupBy,
setGroupBy,
orderBy,
setOrderBy,
showEmptyGroups,
setShowEmptyGroups,
properties: newProperties,
setProperty,
filters,
setFilters,
};
};
export default useMyIssuesFilters;

View File

@ -0,0 +1,61 @@
import { useMemo } from "react";
import useSWR from "swr";
// services
import userService from "services/user.service";
// hooks
import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter";
// types
import { IIssue } from "types";
// fetch-keys
import { USER_ISSUES } from "constants/fetch-keys";
const useMyIssues = (workspaceSlug: string | undefined) => {
const { filters, groupBy, orderBy } = useMyIssuesFilters(workspaceSlug);
const params: any = {
assignees: filters?.assignees ? filters?.assignees.join(",") : undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
group_by: groupBy,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
order_by: orderBy,
priority: filters?.priority ? filters?.priority.join(",") : undefined,
state_group: filters?.state_group ? filters?.state_group.join(",") : undefined,
target_date: filters?.target_date ? filters?.target_date.join(",") : undefined,
type: filters?.type ? filters?.type : undefined,
};
const { data: myIssues, mutate: mutateMyIssues } = useSWR(
workspaceSlug ? USER_ISSUES(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => userService.userIssues(workspaceSlug.toString(), params) : null
);
const groupedIssues:
| {
[key: string]: IIssue[];
}
| undefined = useMemo(() => {
if (!myIssues) return undefined;
if (Array.isArray(myIssues))
return {
allIssues: myIssues,
};
return myIssues;
}, [myIssues]);
const isEmpty =
Object.values(groupedIssues ?? {}).every((group) => group.length === 0) ||
Object.keys(groupedIssues ?? {}).length === 0;
return {
groupedIssues,
isEmpty,
mutateMyIssues,
params,
};
};
export default useMyIssues;

View File

@ -41,12 +41,6 @@ const useCalendarIssuesView = () => {
priority: filters?.priority ? filters?.priority.join(",") : undefined,
type: filters?.type ? filters?.type : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
issue__assignees__id: filters?.issue__assignees__id
? filters?.issue__assignees__id.join(",")
: undefined,
issue__labels__id: filters?.issue__labels__id
? filters?.issue__labels__id.join(",")
: undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
target_date: calendarDateRange,
};

View File

@ -57,12 +57,6 @@ const useIssuesView = () => {
priority: filters?.priority ? filters?.priority.join(",") : undefined,
type: filters?.type ? filters?.type : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
issue__assignees__id: filters?.issue__assignees__id
? filters?.issue__assignees__id.join(",")
: undefined,
issue__labels__id: filters?.issue__labels__id
? filters?.issue__labels__id.join(",")
: undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
target_date: filters?.target_date ? filters?.target_date.join(",") : undefined,
sub_issue: showSubIssues,

View File

@ -1,22 +0,0 @@
import useSWR from "swr";
// services
import userService from "services/user.service";
// types
import type { IIssue } from "types";
// fetch-keys
import { USER_ISSUE } from "constants/fetch-keys";
const useIssues = (workspaceSlug: string | undefined) => {
// API Fetching
const { data: myIssues, mutate: mutateMyIssues } = useSWR<IIssue[]>(
workspaceSlug ? USER_ISSUE(workspaceSlug as string) : null,
workspaceSlug ? () => userService.userIssues(workspaceSlug as string) : null
);
return {
myIssues: myIssues,
mutateMyIssues,
};
};
export default useIssues;

View File

@ -1,104 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import useSWR, { mutate } from "swr";
// services
import workspaceService from "services/workspace.service";
// hooks
import useUser from "hooks/use-user";
// types
import { IWorkspaceMember, Properties } from "types";
import { WORKSPACE_MEMBERS_ME } from "constants/fetch-keys";
const initialValues: Properties = {
assignee: true,
due_date: false,
key: true,
labels: false,
priority: false,
state: true,
sub_issue_count: false,
attachment_count: false,
link: false,
estimate: false,
created_on: false,
updated_on: false,
};
const useMyIssuesProperties = (workspaceSlug?: string) => {
const [properties, setProperties] = useState<Properties>(initialValues);
const { user } = useUser();
const { data: myWorkspace } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug as string) : null,
workspaceSlug ? () => workspaceService.workspaceMemberMe(workspaceSlug as string) : null,
{
shouldRetryOnError: false,
}
);
useEffect(() => {
if (!myWorkspace || !workspaceSlug || !user) return;
setProperties({ ...initialValues, ...myWorkspace.view_props });
if (!myWorkspace.view_props) {
workspaceService.updateWorkspaceView(workspaceSlug, {
view_props: { ...initialValues },
});
}
}, [myWorkspace, workspaceSlug, user]);
const updateIssueProperties = useCallback(
(key: keyof Properties) => {
if (!workspaceSlug || !user) return;
setProperties((prev) => ({ ...prev, [key]: !prev[key] }));
if (myWorkspace) {
mutate<IWorkspaceMember>(
WORKSPACE_MEMBERS_ME(workspaceSlug.toString()),
(prevData) => {
if (!prevData) return;
return {
...prevData,
view_props: { ...prevData?.view_props, [key]: !prevData.view_props?.[key] },
};
},
false
);
if (myWorkspace.view_props) {
workspaceService.updateWorkspaceView(workspaceSlug, {
view_props: {
...myWorkspace.view_props,
[key]: !myWorkspace.view_props[key],
},
});
} else {
workspaceService.updateWorkspaceView(workspaceSlug, {
view_props: { ...initialValues },
});
}
}
},
[workspaceSlug, myWorkspace, user]
);
const newProperties: Properties = {
assignee: properties.assignee,
due_date: properties.due_date,
key: properties.key,
labels: properties.labels,
priority: properties.priority,
state: properties.state,
sub_issue_count: properties.sub_issue_count,
attachment_count: properties.attachment_count,
link: properties.link,
estimate: properties.estimate,
created_on: properties.created_on,
updated_on: properties.updated_on,
};
return [newProperties, updateIssueProperties] as const;
};
export default useMyIssuesProperties;

View File

@ -6,11 +6,14 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys";
// hooks
import useUser from "./use-user";
const useProjectMembers = (workspaceSlug: string, projectId: string) => {
const useProjectMembers = (workspaceSlug: string | undefined, projectId: string | undefined) => {
const { user } = useUser();
// fetching project members
const { data: members } = useSWR(PROJECT_MEMBERS(projectId), () =>
projectService.projectMembers(workspaceSlug, projectId)
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug, projectId)
: null
);
const hasJoined = members?.some((item: any) => item.member.id === (user as any)?.id);

View File

@ -42,12 +42,6 @@ const useSpreadsheetIssuesView = () => {
priority: filters?.priority ? filters?.priority.join(",") : undefined,
type: filters?.type ? filters?.type : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
issue__assignees__id: filters?.issue__assignees__id
? filters?.issue__assignees__id.join(",")
: undefined,
issue__labels__id: filters?.issue__labels__id
? filters?.issue__labels__id.join(",")
: undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
sub_issue: "false",
};

View File

@ -6,12 +6,14 @@ import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// hooks
import useUser from "./use-user";
const useWorkspaceMembers = (workspaceSlug: string) => {
const useWorkspaceMembers = (workspaceSlug: string | undefined, fetchCondition?: boolean) => {
fetchCondition = fetchCondition ?? true;
const { user } = useUser();
const { data: workspaceMembers, error: workspaceMemberErrors } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug) : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug) : null
workspaceSlug && fetchCondition ? WORKSPACE_MEMBERS(workspaceSlug) : null,
workspaceSlug && fetchCondition ? () => workspaceService.workspaceMembers(workspaceSlug) : null
);
const hasJoined = workspaceMembers?.some((item: any) => item.member.id === (user as any)?.id);

View File

@ -1,40 +1,67 @@
import React from "react";
import React, { useEffect } from "react";
import { useRouter } from "next/router";
// headless ui
import { Disclosure, Popover, Transition } from "@headlessui/react";
// icons
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/24/outline";
// images
import emptyMyIssues from "public/empty-state/my-issues.svg";
import { PlusIcon } from "@heroicons/react/24/outline";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
// hooks
import useIssues from "hooks/use-issues";
// ui
import { Spinner, PrimaryButton, EmptyState } from "components/ui";
import { Breadcrumbs, BreadcrumbItem } from "components/breadcrumbs";
// hooks
import useMyIssuesProperties from "hooks/use-my-issues-filter";
// types
import { IIssue, Properties } from "types";
import useProjects from "hooks/use-projects";
import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter";
// components
import { MyIssuesListItem } from "components/issues";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
import { MyIssuesView, MyIssuesViewOptions } from "components/issues";
// ui
import { PrimaryButton } from "components/ui";
import { Breadcrumbs, BreadcrumbItem } from "components/breadcrumbs";
// types
import type { NextPage } from "next";
import useProjects from "hooks/use-projects";
import useUser from "hooks/use-user";
const MyIssuesPage: NextPage = () => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { myIssues } = useIssues(workspaceSlug as string);
const { projects } = useProjects();
const { user } = useUser();
const [properties, setProperties] = useMyIssuesProperties(workspaceSlug as string);
const { filters, setFilters } = useMyIssuesFilters(workspaceSlug?.toString());
const tabsList = [
{
key: "assigned",
label: "Assigned to me",
selected: (filters?.assignees ?? []).length > 0,
onClick: () => {
setFilters({
assignees: [user?.id ?? ""],
created_by: null,
});
},
},
{
key: "created",
label: "Created by me",
selected: (filters?.created_by ?? []).length > 0,
onClick: () => {
setFilters({
created_by: [user?.id ?? ""],
assignees: null,
});
},
},
];
useEffect(() => {
if (!filters || !user) return;
if (!filters.assignees && !filters.created_by) {
setFilters({
assignees: [user.id],
});
return;
}
}, [filters, setFilters, user]);
return (
<WorkspaceAuthorizationLayout
@ -45,59 +72,7 @@ const MyIssuesPage: NextPage = () => {
}
right={
<div className="flex items-center gap-2">
{myIssues && myIssues.length > 0 && (
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-custom-border-200 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-background-90 hover:text-custom-text-100 focus:outline-none ${
open ? "bg-custom-background-90 text-custom-text-100" : "text-custom-text-200"
}`}
>
<span>View</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-1/2 z-10 mr-5 mt-1 w-screen max-w-xs translate-x-1/2 transform overflow-hidden rounded-lg bg-custom-background-100 p-3 shadow-lg">
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => {
if (key === "estimate" || key === "created_on" || key === "updated_on")
return null;
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
)}
<MyIssuesViewOptions />
<PrimaryButton
className="flex items-center gap-2"
onClick={() => {
@ -111,87 +86,26 @@ const MyIssuesPage: NextPage = () => {
</div>
}
>
<div className="flex h-full w-full flex-col space-y-5">
{myIssues ? (
<>
{myIssues.length > 0 ? (
<Disclosure as="div" defaultOpen>
{({ open }) => (
<div>
<div className="flex items-center px-4 py-2.5 bg-custom-background-90">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<h2 className="font-medium leading-5">My Issues</h2>
<span className="rounded-full bg-custom-background-80 py-0.5 px-3 text-sm text-custom-text-200">
{myIssues.length}
</span>
</div>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
<div className="h-full w-full flex flex-col overflow-hidden">
<div className="border-b border-custom-border-300">
<div className="flex items-center overflow-x-scroll">
{tabsList.map((tab) => (
<button
key={tab.key}
type="button"
onClick={tab.onClick}
className={`border-b-2 p-4 text-sm font-medium outline-none whitespace-nowrap ${
tab.selected
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent"
}`}
>
<Disclosure.Panel>
{myIssues.map((issue: IIssue) => (
<MyIssuesListItem
key={issue.id}
issue={issue}
properties={properties}
projectId={issue.project}
/>
{tab.label}
</button>
))}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
) : (
<EmptyState
title={
projects
? projects.length > 0
? "You don't have any issue assigned to you yet"
: "Issues assigned to you will appear here"
: ""
}
description={
projects
? projects.length > 0
? "Keep track of your work in a single place."
: "Let's create your first project and add issues that you want to accomplish."
: ""
}
image={emptyMyIssues}
buttonText={projects ? (projects.length > 0 ? "New Issue" : "New Project") : ""}
buttonIcon={<PlusIcon className="h-4 w-4" />}
onClick={() => {
let e: KeyboardEvent;
if (projects && projects.length > 0)
e = new KeyboardEvent("keydown", {
key: "c",
});
else
e = new KeyboardEvent("keydown", {
key: "p",
});
document.dispatchEvent(e);
}}
/>
)}
</>
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
<MyIssuesView />
</div>
</WorkspaceAuthorizationLayout>
);

View File

@ -12,7 +12,7 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
import { IssueViewContextProvider } from "contexts/issue-view.context";
// components
import { ExistingIssuesListModal, IssuesFilterView, IssuesView } from "components/core";
import { CycleDetailsSidebar } from "components/cycles";
import { CycleDetailsSidebar, TransferIssues, TransferIssuesModal } from "components/cycles";
// services
import issuesService from "services/issues.service";
import cycleServices from "services/cycles.service";
@ -36,6 +36,7 @@ const SingleCycle: React.FC = () => {
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const [cycleSidebar, setCycleSidebar] = useState(true);
const [analyticsModal, setAnalyticsModal] = useState(false);
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
@ -157,16 +158,22 @@ const SingleCycle: React.FC = () => {
</div>
}
>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal}
/>
<AnalyticsProjectModal isOpen={analyticsModal} onClose={() => setAnalyticsModal(false)} />
<div
className={`h-full flex flex-col ${cycleSidebar ? "mr-[24rem]" : ""} ${
analyticsModal ? "mr-[50%]" : ""
} duration-300`}
>
{cycleStatus === "completed" && (
<TransferIssues handleClick={() => setTransferIssuesModal(true)} />
)}
<IssuesView
type="cycle"
openIssuesListModal={openIssuesListModal}
isCompleted={cycleStatus === "completed" ?? false}
disableUserActions={cycleStatus === "completed" ?? false}
/>
</div>
<CycleDetailsSidebar

View File

@ -168,7 +168,7 @@ const SingleModule: React.FC = () => {
analyticsModal ? "mr-[50%]" : ""
} duration-300`}
>
<IssuesView type="module" openIssuesListModal={openIssuesListModal} />
<IssuesView openIssuesListModal={openIssuesListModal} />
</div>
<ModuleDetailsSidebar

View File

@ -195,22 +195,7 @@ const GeneralSettings: NextPage = () => {
name="emoji_and_icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={
value ? (
typeof value === "object" ? (
<span
style={{ color: value.color }}
className="material-symbols-rounded text-lg"
>
{value.name}
</span>
) : (
renderEmoji(value)
)
) : (
"Icon"
)
}
label={value ? renderEmoji(value) : "Icon"}
value={value}
onChange={onChange}
/>

View File

@ -248,6 +248,14 @@ class ProjectIssuesServices extends APIService {
});
}
async getWorkspaceLabels(workspaceSlug: string): Promise<IIssueLabels[]> {
return this.get(`/api/workspaces/${workspaceSlug}/labels/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueLabels(workspaceSlug: string, projectId: string): Promise<IIssueLabels[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-labels/`)
.then((response) => response?.data)

View File

@ -4,6 +4,7 @@ import trackEventServices from "services/track-event.service";
import type {
ICurrentUserResponse,
IIssue,
IUser,
IUserActivityResponse,
IUserWorkspaceDashboard,
@ -26,8 +27,18 @@ class UserService extends APIService {
};
}
async userIssues(workspaceSlug: string): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/my-issues/`)
async userIssues(
workspaceSlug: string,
params: any
): Promise<
| {
[key: string]: IIssue[];
}
| IIssue[]
> {
return this.get(`/api/workspaces/${workspaceSlug}/my-issues/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;

View File

@ -14,6 +14,7 @@ import {
IProductUpdateResponse,
ICurrentUserResponse,
IWorkspaceBulkInviteFormData,
IWorkspaceViewProps,
} from "types";
const trackEvent =
@ -169,7 +170,10 @@ class WorkspaceService extends APIService {
});
}
async updateWorkspaceView(workspaceSlug: string, data: any): Promise<any> {
async updateWorkspaceView(
workspaceSlug: string,
data: { view_props: IWorkspaceViewProps }
): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/workspace-views/`, data)
.then((response) => response?.data)
.catch((error) => {

View File

@ -232,15 +232,20 @@ export interface IIssueFilterOptions {
target_date: string[] | null;
state: string[] | null;
labels: string[] | null;
issue__assignees__id: string[] | null;
issue__labels__id: string[] | null;
priority: string[] | null;
created_by: string[] | null;
}
export type TIssueViewOptions = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt_chart";
export type TIssueGroupByOptions = "state" | "priority" | "labels" | "created_by" | null;
export type TIssueGroupByOptions =
| "state"
| "priority"
| "labels"
| "created_by"
| "state_detail.group"
| "project"
| null;
export type TIssueOrderByOptions =
| "-created_at"
@ -279,3 +284,14 @@ export interface IIssueAttachment {
updated_by: string;
workspace: string;
}
export interface IIssueViewProps {
groupedIssues: { [key: string]: IIssue[] } | undefined;
groupByProperty: TIssueGroupByOptions;
isEmpty: boolean;
issueView: TIssueViewOptions;
orderBy: TIssueOrderByOptions;
params: any;
properties: Properties;
showEmptyGroups: boolean;
}

View File

@ -15,26 +15,11 @@ export interface IView {
}
export interface IQuery {
state: string[] | null;
parent: string[] | null;
priority: string[] | null;
labels: string[] | null;
assignees: string[] | null;
created_by: string[] | null;
name: string | null;
created_at: [
{
datetime: string;
timeline: "before";
},
{
datetime: string;
timeline: "after";
}
];
updated_at: string[] | null;
start_date: string[] | null;
labels: string[] | null;
priority: string[] | null;
state: string[] | null;
target_date: string[] | null;
completed_at: string[] | null;
type: string;
type: "active" | "backlog" | null;
}

View File

@ -1,4 +1,12 @@
import type { IProjectMember, IUser, IUserLite } from "types";
import type {
IIssueFilterOptions,
IProjectMember,
IUser,
IUserLite,
TIssueGroupByOptions,
TIssueOrderByOptions,
TIssueViewOptions,
} from "types";
export interface IWorkspace {
readonly id: string;
@ -54,6 +62,19 @@ export type Properties = {
updated_on: boolean;
};
export interface IWorkspaceViewProps {
properties: Properties;
issueView: TIssueViewOptions;
groupByProperty: TIssueGroupByOptions;
orderBy: TIssueOrderByOptions;
filters: Partial<
IIssueFilterOptions & {
state_group: string[] | null;
}
>;
showEmptyGroups: boolean;
}
export interface IWorkspaceMember {
readonly id: string;
user: IUserLite;
@ -61,7 +82,7 @@ export interface IWorkspaceMember {
member: IUserLite;
role: 5 | 10 | 15 | 20;
company_role: string | null;
view_props: Properties;
view_props: IWorkspaceViewProps;
created_at: Date;
updated_at: Date;
created_by: string;