feat: group by created by option (#516)

This commit is contained in:
Aaryan Khandelwal 2023-03-24 01:11:42 +05:30 committed by GitHub
parent 6c6f9a5bfd
commit 472767ab67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 267 additions and 76 deletions

View File

@ -1,5 +1,12 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import issuesService from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useIssuesView from "hooks/use-issues-view";
// icons
@ -8,7 +15,10 @@ import { getStateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IState } from "types";
import { IIssueLabels, IState } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
currentState?: IState | null;
groupTitle: string;
@ -26,8 +36,25 @@ export const BoardHeader: React.FC<Props> = ({
setIsCollapsed,
isCompleted = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView();
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
let bgColor = "#000000";
if (selectedGroup === "state") bgColor = currentState?.color ?? "#000000";
@ -40,6 +67,28 @@ export const BoardHeader: React.FC<Props> = ({
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle);
switch (selectedGroup) {
case "state":
title = addSpaceIfCamelCase(currentState?.name ?? "");
break;
case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
break;
}
return title;
};
return (
<div
className={`flex justify-between px-1 ${
@ -59,9 +108,7 @@ export const BoardHeader: React.FC<Props> = ({
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
}}
>
{selectedGroup === "state"
? addSpaceIfCamelCase(currentState?.name ?? "")
: addSpaceIfCamelCase(groupTitle)}
{getGroupTitle()}
</h2>
<span className="ml-0.5 rounded-full bg-gray-100 py-1 px-3 text-sm">
{groupedByIssues?.[groupTitle].length ?? 0}

View File

@ -108,7 +108,7 @@ export const SingleBoard: React.FC<Props> = ({
key={issue.id}
draggableId={issue.id}
index={index}
isDragDisabled={isNotAllowed}
isDragDisabled={isNotAllowed || selectedGroup === "created_by"}
>
{(provided, snapshot) => (
<SingleBoardIssue

View File

@ -37,7 +37,7 @@ import {
import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { IIssue, Properties, UserAuth } from "types";
import { IIssue, Properties, TIssueGroupByOptions, UserAuth } from "types";
// fetch-keys
import {
CYCLE_ISSUES_WITH_PARAMS,
@ -53,7 +53,7 @@ type Props = {
properties: Properties;
groupTitle?: string;
index: number;
selectedGroup: "priority" | "state" | "labels" | null;
selectedGroup: TIssueGroupByOptions;
editIssue: () => void;
makeIssueCopy: () => void;
removeIssue?: (() => void) | null;

View File

@ -11,6 +11,7 @@ import issuesService from "services/issues.service";
import stateService from "services/state.service";
import projectService from "services/project.service";
import modulesService from "services/modules.service";
import viewsService from "services/views.service";
// hooks
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
@ -29,6 +30,7 @@ import {
TrashIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { getStateGroupIcon } from "components/icons";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
@ -48,9 +50,9 @@ import {
PROJECT_ISSUES_LIST_WITH_PARAMS,
PROJECT_MEMBERS,
STATE_LIST,
VIEW_DETAILS,
} from "constants/fetch-keys";
import { getPriorityIcon } from "components/icons/priority-icon";
import { getStateGroupIcon } from "components/icons";
type Props = {
type?: "issue" | "cycle" | "module";
@ -116,6 +118,18 @@ export const IssuesView: React.FC<Props> = ({
: null
);
const { data: viewDetails } = useSWR(
workspaceSlug && projectId && viewId ? VIEW_DETAILS(viewId as string) : null,
workspaceSlug && projectId && viewId
? () =>
viewsService.getViewDetails(
workspaceSlug as string,
projectId as string,
viewId as string
)
: null
);
const handleDeleteIssue = useCallback(
(issue: IIssue) => {
setDeleteIssueModal(true);
@ -391,6 +405,8 @@ export const IssuesView: React.FC<Props> = ({
(key) => filters[key as keyof IIssueFilterOptions] === null
);
const isUpdatingView = JSON.stringify(filters) === JSON.stringify(viewDetails?.query_data);
return (
<>
<CreateUpdateViewModal
@ -540,27 +556,49 @@ export const IssuesView: React.FC<Props> = ({
})}
</div>
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<PrimaryButton
onClick={() => {
if (viewId) {
setFilters({}, true);
setToastAlert({
title: "View updated",
message: "Your view has been updated",
type: "success",
});
} else
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
{!viewId && <PlusIcon className="h-4 w-4" />}
Save view
</PrimaryButton>
)}
{viewId
? isUpdatingView && (
<PrimaryButton
onClick={() => {
if (viewId) {
setFilters({}, true);
setToastAlert({
title: "View updated",
message: "Your view has been updated",
type: "success",
});
} else
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
Update view
</PrimaryButton>
)
: Object.keys(filters).length > 0 &&
nullFilters.length !== Object.keys(filters).length && (
<PrimaryButton
onClick={() => {
if (viewId) {
setFilters({}, true);
setToastAlert({
title: "View updated",
message: "Your view has been updated",
type: "success",
});
} else
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
<PlusIcon className="h-4 w-4" />
Save view
</PrimaryButton>
)}
</div>
<DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox">

View File

@ -1,19 +1,27 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// services
import issuesService from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
// components
import { SingleListIssue } from "components/core";
// ui
import { CustomMenu } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
import { getStateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IProjectMember, IState, UserAuth } from "types";
import { CustomMenu } from "components/ui";
import { IIssue, IIssueLabels, IState, TIssueGroupByOptions, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
type?: "issue" | "cycle" | "module";
@ -23,7 +31,7 @@ type Props = {
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: "priority" | "state" | "labels" | null;
selectedGroup: TIssueGroupByOptions;
addIssueToState: () => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
@ -55,6 +63,42 @@ export const SingleList: React.FC<Props> = ({
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle);
switch (selectedGroup) {
case "state":
title = addSpaceIfCamelCase(currentState?.name ?? "");
break;
case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
break;
}
return title;
};
return (
<Disclosure key={groupTitle} as="div" defaultOpen>
{({ open }) => (
@ -75,9 +119,7 @@ export const SingleList: React.FC<Props> = ({
)}
{selectedGroup !== null ? (
<h2 className="text-xl font-semibold capitalize leading-6 text-gray-800">
{selectedGroup === "state"
? addSpaceIfCamelCase(currentState?.name ?? "")
: addSpaceIfCamelCase(groupTitle)}
{getGroupTitle()}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>

View File

@ -28,6 +28,7 @@ export * from "./priority-icon";
export * from "./question-mark-circle-icon";
export * from "./setting-icon";
export * from "./signal-cellular-icon";
export * from "./stacked-layers-icon";
export * from "./started-state-icon";
export * from "./state-group-icon";
export * from "./tag-icon";

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const StackedLayersIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "#858e96",
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.0398 13.4374C18.1224 13.5807 18.1448 13.7508 18.1022 13.9106C18.0596 14.0703 17.9554 14.2067 17.8125 14.2898L10.3125 18.6648C10.2169 18.7205 10.1083 18.7499 9.99766 18.7499C9.88703 18.7499 9.77838 18.7205 9.68281 18.6648L2.18281 14.2898C2.04195 14.2051 1.94009 14.0684 1.8993 13.9092C1.8585 13.75 1.88205 13.5812 1.96484 13.4392C2.04764 13.2972 2.18301 13.1936 2.34166 13.1507C2.50031 13.1078 2.66946 13.1292 2.8125 13.2101L10 17.4015L17.1875 13.2101C17.3308 13.1275 17.5009 13.1051 17.6606 13.1477C17.8204 13.1903 17.9567 13.2945 18.0398 13.4374ZM17.1875 9.46009L10 13.6515L2.8125 9.46009C2.67019 9.38924 2.50623 9.37528 2.35399 9.42105C2.20175 9.46682 2.07267 9.56888 1.99303 9.70646C1.91338 9.84405 1.88916 10.0068 1.92529 10.1616C1.96142 10.3164 2.05518 10.4517 2.1875 10.5398L9.6875 14.9148C9.78307 14.9705 9.89171 14.9999 10.0023 14.9999C10.113 14.9999 10.2216 14.9705 10.3172 14.9148L17.8172 10.5398C17.8892 10.499 17.9524 10.4444 18.0032 10.379C18.0539 10.3136 18.0912 10.2388 18.1128 10.1589C18.1344 10.079 18.1399 9.99559 18.129 9.91354C18.1181 9.8315 18.091 9.75243 18.0493 9.68094C18.0076 9.60944 17.9521 9.54694 17.8861 9.49706C17.82 9.44718 17.7447 9.41092 17.6646 9.39037C17.5844 9.36983 17.5009 9.36541 17.419 9.37738C17.3371 9.38935 17.2584 9.41746 17.1875 9.46009ZM1.875 6.24994C1.87525 6.14047 1.90425 6.03299 1.95909 5.93824C2.01393 5.8435 2.0927 5.76483 2.1875 5.71009L9.6875 1.33509C9.78307 1.27936 9.89171 1.25 10.0023 1.25C10.113 1.25 10.2216 1.27936 10.3172 1.33509L17.8172 5.71009C17.9115 5.76514 17.9898 5.84395 18.0442 5.93867C18.0986 6.03339 18.1272 6.14071 18.1272 6.24994C18.1272 6.35917 18.0986 6.46649 18.0442 6.56121C17.9898 6.65593 17.9115 6.73474 17.8172 6.78978L10.3172 11.1648C10.2216 11.2205 10.113 11.2499 10.0023 11.2499C9.89171 11.2499 9.78307 11.2205 9.6875 11.1648L2.1875 6.78978C2.0927 6.73505 2.01393 6.65637 1.95909 6.56163C1.90425 6.46689 1.87525 6.35941 1.875 6.24994ZM3.74063 6.24994L10 9.9015L16.2594 6.24994L10 2.59838L3.74063 6.24994Z"
fill={color}
/>
</svg>
);

View File

@ -1,10 +1,11 @@
export const GROUP_BY_OPTIONS: Array<{
name: string;
key: "state" | "priority" | "labels" | null;
key: TIssueGroupByOptions;
}> = [
{ name: "State", key: "state" },
{ name: "Priority", key: "priority" },
{ name: "Labels", key: "labels" },
{ name: "Created by", key: "created_by" },
{ name: "None", key: null },
];
@ -36,12 +37,12 @@ export const FILTER_ISSUE_OPTIONS: Array<{
},
];
import { IIssue } from "types";
import { IIssue, TIssueGroupByOptions } from "types";
type THandleIssuesMutation = (
formData: Partial<IIssue>,
oldGroupTitle: string,
selectedGroupBy: "state" | "priority" | "labels" | null,
selectedGroupBy: TIssueGroupByOptions,
issueIndex: number,
prevData?:
| {

View File

@ -10,7 +10,7 @@ import ToastAlert from "components/toast-alert";
import projectService from "services/project.service";
import viewsService from "services/views.service";
// types
import { IIssueFilterOptions, IProjectMember } from "types";
import { IIssueFilterOptions, IProjectMember, TIssueGroupByOptions } from "types";
// fetch-keys
import { USER_PROJECT_VIEW, VIEW_DETAILS } from "constants/fetch-keys";
@ -18,7 +18,7 @@ export const issueViewContext = createContext<ContextType>({} as ContextType);
type IssueViewProps = {
issueView: "list" | "kanban";
groupByProperty: "state" | "priority" | "labels" | null;
groupByProperty: TIssueGroupByOptions;
orderBy: "created_at" | "updated_at" | "priority" | "sort_order";
showEmptyGroups: boolean;
filters: IIssueFilterOptions;
@ -37,7 +37,7 @@ type ReducerActionType = {
};
type ContextType = IssueViewProps & {
setGroupByProperty: (property: "state" | "priority" | "labels" | null) => void;
setGroupByProperty: (property: TIssueGroupByOptions) => void;
setOrderBy: (property: "created_at" | "updated_at" | "priority" | "sort_order") => void;
setShowEmptyGroups: (property: boolean) => void;
setFilters: (filters: Partial<IIssueFilterOptions>, saveToServer?: boolean) => void;
@ -49,7 +49,7 @@ type ContextType = IssueViewProps & {
type StateType = {
issueView: "list" | "kanban";
groupByProperty: "state" | "priority" | "labels" | null;
groupByProperty: TIssueGroupByOptions;
orderBy: "created_at" | "updated_at" | "priority" | "sort_order";
showEmptyGroups: boolean;
filters: IIssueFilterOptions;
@ -167,11 +167,11 @@ const saveDataToServer = async (workspaceSlug: string, projectID: string, state:
const sendFilterDataToServer = async (
workspaceSlug: string,
projectID: string,
projectId: string,
viewId: string,
state: any
) => {
await viewsService.patchView(workspaceSlug, projectID, viewId, {
await viewsService.patchView(workspaceSlug, projectId, viewId, {
...state,
});
};
@ -283,7 +283,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
}, [workspaceSlug, projectId, state, mutateMyViewProps]);
const setGroupByProperty = useCallback(
(property: "state" | "priority" | "labels" | null) => {
(property: TIssueGroupByOptions) => {
dispatch({
type: "SET_GROUP_BY_PROPERTY",
payload: {

View File

@ -12,17 +12,17 @@ import viewsService from "services/views.service";
import AppLayout from "layouts/app-layout";
// contexts
import { IssueViewContextProvider } from "contexts/issue-view.context";
// components
import { CreateUpdateViewModal, DeleteViewModal } from "components/views";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types
import { UserAuth } from "types";
// fetch-keys
import { PROJECT_DETAILS, VIEW_DETAILS, VIEW_ISSUES } from "constants/fetch-keys";
import { PROJECT_DETAILS, VIEWS_LIST, VIEW_DETAILS } from "constants/fetch-keys";
import { IssuesFilterView, IssuesView } from "components/core";
import { HeaderButton } from "components/ui";
import { CustomMenu, HeaderButton } from "components/ui";
import { PlusIcon } from "@heroicons/react/24/outline";
import { truncateText } from "helpers/string.helper";
import { StackedLayersIcon } from "components/icons";
const SingleView: React.FC<UserAuth> = (props) => {
const router = useRouter();
@ -35,6 +35,13 @@ const SingleView: React.FC<UserAuth> = (props) => {
: null
);
const { data: views } = useSWR(
workspaceSlug && projectId ? VIEWS_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => viewsService.getViews(workspaceSlug as string, projectId as string)
: null
);
const { data: viewDetails } = useSWR(
workspaceSlug && projectId && viewId ? VIEW_DETAILS(viewId as string) : null,
workspaceSlug && projectId && viewId
@ -58,6 +65,28 @@ const SingleView: React.FC<UserAuth> = (props) => {
/>
</Breadcrumbs>
}
left={
<CustomMenu
label={
<>
<StackedLayersIcon height={12} width={12} />
{viewDetails?.name && truncateText(viewDetails.name, 40)}
</>
}
className="ml-1.5"
width="auto"
>
{views?.map((view) => (
<CustomMenu.MenuItem
key={view.id}
renderAs="a"
href={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}
>
{truncateText(view.name, 40)}
</CustomMenu.MenuItem>
))}
</CustomMenu>
}
right={
<div className="flex items-center gap-2">
<IssuesFilterView />

View File

@ -28,6 +28,7 @@ import { DeleteViewModal, CreateUpdateViewModal } from "components/views";
// types
import { IView } from "types";
import type { NextPage, GetServerSidePropsContext } from "next";
import { StackedLayersIcon } from "components/icons";
const ProjectViews: NextPage = () => {
const [isCreateViewModalOpen, setIsCreateViewModalOpen] = useState(false);
@ -59,7 +60,7 @@ const ProjectViews: NextPage = () => {
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Cycles`} />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Views`} />
</Breadcrumbs>
}
right={
@ -82,29 +83,35 @@ const ProjectViews: NextPage = () => {
/>
{views ? (
views.length > 0 ? (
<div className="rounded-[10px] border">
{views.map((view) => (
<div
className="flex items-center justify-between border-b bg-white p-4 first:rounded-t-[10px] last:rounded-b-[10px]"
key={view.id}
>
<Link href={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}>
<a>{view.name}</a>
</Link>
<CustomMenu width="auto" verticalEllipsis>
<CustomMenu.MenuItem
onClick={() => {
setSelectedView(view);
}}
>
<span className="flex items-center justify-start gap-2 text-gray-800">
<TrashIcon className="h-4 w-4" />
<span>Delete</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
))}
<div className="space-y-5">
<h3 className="text-3xl font-semibold text-black">Views</h3>
<div className="rounded-[10px] border">
{views.map((view) => (
<div
className="flex items-center justify-between border-b bg-white p-4 first:rounded-t-[10px] last:rounded-b-[10px]"
key={view.id}
>
<div className="flex items-center gap-2">
<StackedLayersIcon height={18} width={18} />
<Link href={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}>
<a>{view.name}</a>
</Link>
</div>
<CustomMenu width="auto" verticalEllipsis>
<CustomMenu.MenuItem
onClick={() => {
setSelectedView(view);
}}
>
<span className="flex items-center justify-start gap-2 text-gray-800">
<TrashIcon className="h-4 w-4" />
<span>Delete</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
))}
</div>
</div>
) : (
<EmptyState

View File

@ -226,8 +226,10 @@ export interface IIssueFilterOptions {
priority: string[] | null;
}
export type TIssueGroupByOptions = "state" | "priority" | "labels" | "created_by" | null;
export interface IIssueViewOptions {
group_by: "state" | "priority" | "labels" | null;
group_by: TIssueGroupByOptions;
order_by: "created_at" | "updated_at" | "priority" | "sort_order";
filters: IIssueFilterOptions;
}

View File

@ -1,4 +1,4 @@
import type { IIssueFilterOptions, IUserLite, IWorkspace } from "./";
import type { IIssueFilterOptions, IUserLite, IWorkspace, TIssueGroupByOptions } from "./";
export interface IProject {
cover_image: string | null;
@ -36,7 +36,7 @@ export interface IFavoriteProject {
type ProjectViewTheme = {
issueView: "list" | "kanban";
groupByProperty: "state" | "priority" | "labels" | null;
groupByProperty: TIssueGroupByOptions;
orderBy: "created_at" | "updated_at" | "priority" | "sort_order";
filters: IIssueFilterOptions;
};