feat: issues filter using views (#448)

* fix: made basic UI for views, binded services and logic for views

* feat: views list, delete view, and conditionally updating filters or my view props
This commit is contained in:
Dakshesh Jain 2023-03-16 14:07:19 +05:30 committed by GitHub
parent 96ad751e11
commit ef0e326ca0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 509 additions and 127 deletions

View File

@ -97,26 +97,57 @@ export const IssuesFilterView: React.FC = () => {
</button> </button>
</div> </div>
<CustomMenu <CustomMenu
customButton={ label={
<button <span className="flex items-center gap-2 rounded-md py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus:outline-none">
type="button"
className="group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus:outline-none"
>
Filters Filters
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" /> </span>
</button>
} }
optionsPosition="right"
> >
<h4 className="px-1 py-2 font-medium">Status</h4>
{statesList?.map((state) => (
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => onClick={() => {
setFilters({ const filterStates = filters?.state ?? [];
assignees: ["72d6ad43-41ff-4907-9980-2f5ee8745ad3"], const newFilterState = filterStates.includes(state.id)
}) ? filterStates.filter((id) => id !== state.id)
} : [...filterStates, state.id];
setFilters({ ...filters, state: newFilterState });
}}
> >
Member- Aaryan <>{state.name}</>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
))}
<h4 className="px-1 py-2 font-medium">Members</h4>
{members?.map((member) => (
<CustomMenu.MenuItem onClick={() => {}}>
<>
{member.member.first_name && member.member.first_name !== ""
? member.member.first_name + " " + member.member.last_name
: member.member.email}
</>
</CustomMenu.MenuItem>
))}
<h4 className="px-1 py-2 font-medium">Labels</h4>
{issueLabels?.map((label) => (
<CustomMenu.MenuItem onClick={() => {}}>
<>{label.name}</>
</CustomMenu.MenuItem>
))}
<h4 className="px-1 py-2 font-medium">Priority</h4>
{PRIORITIES?.map((priority) => (
<CustomMenu.MenuItem
onClick={() => {
if (priority === null) return;
const filterPriorities = filters?.priority ?? [];
const newFilterPriority = filterPriorities.includes(priority)
? filterPriorities.filter((id) => id !== priority)
: [...filterPriorities, priority];
setFilters({ ...filters, priority: newFilterPriority });
}}
>
<span className="capitalize">{priority ?? "None"}</span>
</CustomMenu.MenuItem>
))}
</CustomMenu> </CustomMenu>
<Popover className="relative"> <Popover className="relative">
{({ open }) => ( {({ open }) => (

View File

@ -17,12 +17,20 @@ import useIssuesView from "hooks/use-issues-view";
import { AllLists, AllBoards } from "components/core"; import { AllLists, AllBoards } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CreateUpdateViewModal } from "components/views";
// icons // icons
import { PlusIcon, RectangleStackIcon, TrashIcon } from "@heroicons/react/24/outline"; import { PlusIcon, RectangleStackIcon, TrashIcon, XMarkIcon } from "@heroicons/react/24/outline";
// helpers // helpers
import { getStatesList } from "helpers/state.helper"; import { getStatesList } from "helpers/state.helper";
// types // types
import { CycleIssueResponse, IIssue, ModuleIssueResponse, UserAuth } from "types"; import {
CycleIssueResponse,
IIssue,
IIssueFilterOptions,
IView,
ModuleIssueResponse,
UserAuth,
} from "types";
// fetch-keys // fetch-keys
import { import {
CYCLE_ISSUES, CYCLE_ISSUES,
@ -44,6 +52,7 @@ type Props = {
export const IssuesView: React.FC<Props> = ({ type = "issue", openIssuesListModal, userAuth }) => { export const IssuesView: React.FC<Props> = ({ type = "issue", openIssuesListModal, userAuth }) => {
// create issue modal // create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false); const [createIssueModal, setCreateIssueModal] = useState(false);
const [createViewModal, setCreateViewModal] = useState<any>(null);
const [preloadedData, setPreloadedData] = useState< const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined (Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined); >(undefined);
@ -360,6 +369,11 @@ export const IssuesView: React.FC<Props> = ({ type = "issue", openIssuesListModa
return ( return (
<> <>
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
preLoadedData={createViewModal}
/>
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"} isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)} handleClose={() => setCreateIssueModal(false)}
@ -378,25 +392,96 @@ export const IssuesView: React.FC<Props> = ({ type = "issue", openIssuesListModa
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
data={issueToDelete} data={issueToDelete}
/> />
<div className="flex items-center gap-2"> <div className="mb-3 flex items-center justify-between gap-2">
<div className="flex gap-x-3">
{Object.keys(filters).map((key) => { {Object.keys(filters).map((key) => {
if (filters[key as keyof typeof filters] !== null) if (filters[key as keyof typeof filters] !== null)
return ( return (
<button <div key={key} className="flex gap-x-2 text-sm">
key={key} <p>
type="button" Filter for <span className="font-medium">{key}</span>:{" "}
className="rounded bg-black p-2 text-xs text-white" </p>
onClick={() => {filters[key as keyof IIssueFilterOptions] === null ||
setFilters({ (filters[key as keyof IIssueFilterOptions]?.length ?? 0) <= 0 ? (
[key]: null, <p className="font-medium">None</p>
}) ) : (
} Array.isArray(filters[key as keyof IIssueFilterOptions]) && (
<p className="space-x-2 font-medium">
{key === "state"
? (filters[key as keyof IIssueFilterOptions] as any)?.map(
(stateId: any) => {
const state = states?.find((s) => s.id === stateId);
return (
<p
key={state?.id}
className="inline-flex items-center gap-x-1 rounded-full bg-gray-500 px-2 py-0.5 text-xs font-medium text-white"
> >
Remove {key} filter <span>{state?.name ?? "Loading..."}</span>
</button> <span
className="cursor-pointer"
onClick={() => {
setFilters({
...filters,
[key]: (
filters[key as keyof IIssueFilterOptions] as any
)?.filter((s: any) => s !== stateId),
});
}}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
);
}
)
: key === "priority"
? (filters[key as keyof IIssueFilterOptions] as any)?.map(
(priority: any) => (
<p
key={priority}
className="inline-flex items-center gap-x-1 rounded-full bg-gray-500 px-2 py-0.5 text-xs font-medium capitalize text-white"
>
<span>{priority}</span>
<span
className="cursor-pointer"
onClick={() => {
setFilters({
...filters,
[key]: (
filters[key as keyof IIssueFilterOptions] as any
)?.filter((p: any) => p !== priority),
});
}}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
)
)
: (filters[key as keyof IIssueFilterOptions] as any)?.join(", ")}
</p>
)
)}
</div>
); );
})} })}
</div> </div>
<div>
<button
type="button"
onClick={() =>
setCreateViewModal({
query: filters,
})
}
className="flex items-center gap-x-0.5 text-sm"
>
<PlusIcon className="h-3 w-3" />
<span>Save view</span>
</button>
</div>
</div>
<DragDropContext onDragEnd={handleOnDragEnd}> <DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox"> <StrictModeDroppable droppableId="trashBox">
{(provided, snapshot) => ( {(provided, snapshot) => (

View File

@ -42,3 +42,4 @@ export * from "./tick-mark-icon";
export * from "./contrast-icon"; export * from "./contrast-icon";
export * from "./people-group-icon"; export * from "./people-group-icon";
export * from "./cmd-icon"; export * from "./cmd-icon";
export * from "./view-list-icon";

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const ViewListIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "#858E96",
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.25 13.125V4.875C2.25 4.5625 2.35938 4.29688 2.57812 4.07812C2.79688 3.85938 3.0625 3.75 3.375 3.75H14.625C14.9375 3.75 15.2031 3.85938 15.4219 4.07812C15.6406 4.29688 15.75 4.5625 15.75 4.875V13.125C15.75 13.4375 15.6406 13.7031 15.4219 13.9219C15.2031 14.1406 14.9375 14.25 14.625 14.25H3.375C3.0625 14.25 2.79688 14.1406 2.57812 13.9219C2.35938 13.7031 2.25 13.4375 2.25 13.125ZM3.375 6.88125H5.3625V4.875H3.375V6.88125ZM6.4875 6.88125H14.625V4.875H6.4875V6.88125ZM6.4875 9.99375H14.625V8.00625H6.4875V9.99375ZM6.4875 13.125H14.625V11.1187H6.4875V13.125ZM3.375 13.125H5.3625V11.1187H3.375V13.125ZM3.375 9.99375H5.3625V8.00625H3.375V9.99375Z"
fill={color}
/>
</svg>
);

View File

@ -7,7 +7,13 @@ import { Disclosure, Transition } from "@headlessui/react";
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
// icons // icons
import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { ContrastIcon, LayerDiagonalIcon, PeopleGroupIcon, SettingIcon } from "components/icons"; import {
ContrastIcon,
LayerDiagonalIcon,
PeopleGroupIcon,
SettingIcon,
ViewListIcon,
} from "components/icons";
// helpers // helpers
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
// types // types
@ -38,6 +44,11 @@ const navigation = (workspaceSlug: string, projectId: string) => [
href: `/${workspaceSlug}/projects/${projectId}/modules`, href: `/${workspaceSlug}/projects/${projectId}/modules`,
icon: PeopleGroupIcon, icon: PeopleGroupIcon,
}, },
{
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
icon: ViewListIcon,
},
{ {
name: "Settings", name: "Settings",
href: `/${workspaceSlug}/projects/${projectId}/settings`, href: `/${workspaceSlug}/projects/${projectId}/settings`,

View File

@ -21,11 +21,12 @@ import { VIEWS_LIST } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; data: IView | null;
data?: IView; onClose: () => void;
onSuccess?: () => void;
}; };
export const DeleteViewModal: React.FC<Props> = ({ isOpen, setIsOpen, data }) => { export const DeleteViewModal: React.FC<Props> = ({ isOpen, data, onClose, onSuccess }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@ -36,19 +37,23 @@ export const DeleteViewModal: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
const cancelButtonRef = useRef(null); const cancelButtonRef = useRef(null);
const handleClose = () => { const handleClose = () => {
setIsOpen(false);
setIsDeleteLoading(false); setIsDeleteLoading(false);
onClose();
}; };
const handleDeletion = async () => { const handleDeletion = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
if (!workspaceSlug || !data) return; if (!workspaceSlug || !data || !projectId) return;
await viewsService await viewsService
.deleteView(projectId as string, data.id) .deleteView(workspaceSlug as string, projectId as string, data.id)
.then(() => { .then(() => {
mutate(VIEWS_LIST(projectId as string)); mutate<IView[]>(VIEWS_LIST(projectId as string), (views) =>
router.push(`/${workspaceSlug}/projects/${projectId}/issues`); views?.filter((view) => view.id !== data.id)
);
if (onSuccess) onSuccess();
handleClose(); handleClose();
setToastAlert({ setToastAlert({

View File

@ -12,6 +12,7 @@ type Props = {
handleClose: () => void; handleClose: () => void;
status: boolean; status: boolean;
data?: IView; data?: IView;
preLoadedData?: Partial<IView> | null;
}; };
const defaultValues: Partial<IView> = { const defaultValues: Partial<IView> = {
@ -19,7 +20,13 @@ const defaultValues: Partial<IView> = {
description: "", description: "",
}; };
export const ViewForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => { export const ViewForm: React.FC<Props> = ({
handleFormSubmit,
handleClose,
status,
data,
preLoadedData,
}) => {
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@ -44,6 +51,13 @@ export const ViewForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
}); });
}, [data, reset]); }, [data, reset]);
useEffect(() => {
reset({
...defaultValues,
...preLoadedData,
});
}, [preLoadedData, reset]);
return ( return (
<form onSubmit={handleSubmit(handleCreateUpdateView)}> <form onSubmit={handleSubmit(handleCreateUpdateView)}>
<div className="space-y-5"> <div className="space-y-5">

View File

@ -4,8 +4,6 @@ import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
@ -23,14 +21,15 @@ type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
data?: IView; data?: IView;
preLoadedData?: Partial<IView> | null;
}; };
const defaultValues: Partial<IView> = { export const CreateUpdateViewModal: React.FC<Props> = ({
name: "", isOpen,
description: "", handleClose,
}; data,
preLoadedData,
export const CreateUpdateViewModal: React.FC<Props> = ({ isOpen, handleClose, data }) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -38,14 +37,13 @@ export const CreateUpdateViewModal: React.FC<Props> = ({ isOpen, handleClose, da
const onClose = () => { const onClose = () => {
handleClose(); handleClose();
reset(defaultValues);
}; };
const { reset } = useForm<IView>({
defaultValues,
});
const createView = async (payload: IView) => { const createView = async (payload: IView) => {
payload = {
...payload,
query_data: payload.query,
};
await viewsService await viewsService
.createView(workspaceSlug as string, projectId as string, payload) .createView(workspaceSlug as string, projectId as string, payload)
.then(() => { .then(() => {
@ -137,6 +135,7 @@ export const CreateUpdateViewModal: React.FC<Props> = ({ isOpen, handleClose, da
handleClose={handleClose} handleClose={handleClose}
status={data ? true : false} status={data ? true : false}
data={data} data={data}
preLoadedData={preLoadedData}
/> />
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>

View File

@ -8,6 +8,7 @@ import useSWR, { mutate } from "swr";
import ToastAlert from "components/toast-alert"; import ToastAlert from "components/toast-alert";
// services // services
import projectService from "services/project.service"; import projectService from "services/project.service";
import viewsService from "services/views.service";
// types // types
import { IIssueFilterOptions, IProjectMember, NestedKeyOf } from "types"; import { IIssueFilterOptions, IProjectMember, NestedKeyOf } from "types";
// fetch-keys // fetch-keys
@ -16,6 +17,8 @@ import {
MODULE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS,
USER_PROJECT_VIEW, USER_PROJECT_VIEW,
VIEW_DETAILS,
VIEW_ISSUES,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
export const issueViewContext = createContext<ContextType>({} as ContextType); export const issueViewContext = createContext<ContextType>({} as ContextType);
@ -62,8 +65,10 @@ export const initialState: StateType = {
orderBy: "created_at", orderBy: "created_at",
filters: { filters: {
type: null, type: null,
priority: null,
assignees: null, assignees: null,
labels: null, labels: null,
state: null,
issue__assignees__id: null, issue__assignees__id: null,
issue__labels__id: null, issue__labels__id: null,
}, },
@ -150,6 +155,17 @@ const saveDataToServer = async (workspaceSlug: string, projectID: string, state:
}); });
}; };
const sendFilterDataToServer = async (
workspaceSlug: string,
projectID: string,
viewId: string,
state: any
) => {
await viewsService.patchView(workspaceSlug, projectID, viewId, {
...state,
});
};
const setNewDefault = async (workspaceSlug: string, projectId: string, state: any) => { const setNewDefault = async (workspaceSlug: string, projectId: string, state: any) => {
mutate<IProjectMember>( mutate<IProjectMember>(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId as string) : null, workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId as string) : null,
@ -174,7 +190,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { data: myViewProps, mutate: mutateMyViewProps } = useSWR( const { data: myViewProps, mutate: mutateMyViewProps } = useSWR(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId as string) : null, workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId as string) : null,
@ -183,6 +199,18 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
: null : null
); );
const { data: viewDetails, mutate: mutateViewDetails } = 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 setIssueViewToKanban = useCallback(() => { const setIssueViewToKanban = useCallback(() => {
dispatch({ dispatch({
type: "SET_ISSUE_VIEW", type: "SET_ISSUE_VIEW",
@ -335,6 +363,24 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
}; };
}, false); }, false);
if (viewId) {
mutateViewDetails((prevData: any) => {
if (!prevData) return prevData;
return {
...prevData,
query_data: {
...state.filters,
...property,
},
};
}, false);
sendFilterDataToServer(workspaceSlug as string, projectId as string, viewId as string, {
query_data: {
...state.filters,
...property,
},
});
} else
saveDataToServer(workspaceSlug as string, projectId as string, { saveDataToServer(workspaceSlug as string, projectId as string, {
...state, ...state,
filters: { filters: {
@ -343,7 +389,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
}, },
}); });
}, },
[projectId, workspaceSlug, state, mutateMyViewProps] [projectId, workspaceSlug, state, mutateMyViewProps, viewId, mutateViewDetails]
); );
const setNewDefaultView = useCallback(() => { const setNewDefaultView = useCallback(() => {
@ -368,9 +414,15 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
useEffect(() => { useEffect(() => {
dispatch({ dispatch({
type: "REHYDRATE_THEME", type: "REHYDRATE_THEME",
payload: myViewProps?.view_props, payload: {
...myViewProps?.view_props,
filters: {
...myViewProps?.view_props?.filters,
...viewDetails?.query_data,
} as any,
},
}); });
}, [myViewProps]); }, [myViewProps, viewDetails]);
useEffect(() => { useEffect(() => {
// TODO: think of a better way to do this // TODO: think of a better way to do this
@ -380,11 +432,14 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
} else if (moduleId) { } else if (moduleId) {
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string), {}, false); mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string), {}, false);
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string)); mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string));
} else if (viewId) {
mutate(VIEW_ISSUES(viewId as string), {}, false);
mutate(VIEW_ISSUES(viewId as string));
} else { } else {
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string), {}, false); mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string), {}, false);
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string)); mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string));
} }
}, [state, projectId, cycleId, moduleId]); }, [state, projectId, cycleId, moduleId, viewId]);
return ( return (
<issueViewContext.Provider <issueViewContext.Provider

View File

@ -15,10 +15,12 @@ import {
CYCLE_ISSUES_WITH_PARAMS, CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS,
VIEW_ISSUES,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
// types // types
import type { IIssue } from "types"; import type { IIssue } from "types";
import viewsService from "services/views.service";
const useIssuesView = () => { const useIssuesView = () => {
const { const {
@ -36,18 +38,22 @@ const useIssuesView = () => {
} = useContext(issueViewContext); } = useContext(issueViewContext);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const params: any = { const params: any = {
order_by: orderBy, order_by: orderBy,
group_by: groupByProperty, group_by: groupByProperty,
assignees: filters.assignees ? filters.assignees.join(",") : undefined, assignees: filters?.assignees ? filters?.assignees.join(",") : undefined,
type: filters.type ? filters.type : undefined, state: filters?.state ? filters?.state.join(",") : undefined,
labels: filters.labels ? filters.labels.join(",") : undefined, priority: filters?.priority ? filters?.priority.join(",") : undefined,
issue__assignees__id: filters.issue__assignees__id type: filters?.type ? filters?.type : undefined,
? filters.issue__assignees__id.join(",") 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, : undefined,
issue__labels__id: filters.issue__labels__id ? filters.issue__labels__id.join(",") : undefined,
}; };
const { data: projectIssues } = useSWR( const { data: projectIssues } = useSWR(
@ -60,6 +66,14 @@ const useIssuesView = () => {
: null : null
); );
const { data: viewIssues } = useSWR(
workspaceSlug && projectId && viewId ? VIEW_ISSUES(viewId as string) : null,
workspaceSlug && projectId && viewId
? () =>
viewsService.getViewIssues(workspaceSlug as string, projectId as string, viewId as string)
: null
);
const { data: cycleIssues } = useSWR( const { data: cycleIssues } = useSWR(
workspaceSlug && projectId && cycleId && params workspaceSlug && projectId && cycleId && params
? CYCLE_ISSUES_WITH_PARAMS(cycleId as string) ? CYCLE_ISSUES_WITH_PARAMS(cycleId as string)
@ -95,11 +109,11 @@ const useIssuesView = () => {
[key: string]: IIssue[]; [key: string]: IIssue[];
} }
| undefined = useMemo(() => { | undefined = useMemo(() => {
const issuesToGroup = cycleIssues ?? moduleIssues ?? projectIssues; const issuesToGroup = viewIssues ?? cycleIssues ?? moduleIssues ?? projectIssues;
if (Array.isArray(issuesToGroup)) return { allIssues: issuesToGroup }; if (Array.isArray(issuesToGroup)) return { allIssues: issuesToGroup };
else return issuesToGroup; else return issuesToGroup;
}, [projectIssues, cycleIssues, moduleIssues]); }, [projectIssues, cycleIssues, moduleIssues, viewIssues]);
return { return {
groupedByIssues, groupedByIssues,

View File

@ -10,6 +10,8 @@ import projectService from "services/project.service";
import viewsService from "services/views.service"; import viewsService from "services/views.service";
// layouts // layouts
import AppLayout from "layouts/app-layout"; import AppLayout from "layouts/app-layout";
// contexts
import { IssueViewContextProvider } from "contexts/issue-view.context";
// components // components
import { CreateUpdateViewModal, DeleteViewModal } from "components/views"; import { CreateUpdateViewModal, DeleteViewModal } from "components/views";
// ui // ui
@ -18,6 +20,9 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
import { UserAuth } from "types"; import { UserAuth } from "types";
// fetch-keys // fetch-keys
import { PROJECT_DETAILS, VIEW_DETAILS, VIEW_ISSUES } from "constants/fetch-keys"; import { PROJECT_DETAILS, VIEW_DETAILS, VIEW_ISSUES } from "constants/fetch-keys";
import { IssuesFilterView, IssuesView } from "components/core";
import { HeaderButton } from "components/ui";
import { PlusIcon } from "@heroicons/react/24/outline";
const SingleView: React.FC<UserAuth> = (props) => { const SingleView: React.FC<UserAuth> = (props) => {
const router = useRouter(); const router = useRouter();
@ -42,15 +47,8 @@ const SingleView: React.FC<UserAuth> = (props) => {
: null : null
); );
const { data: viewIssues } = useSWR(
workspaceSlug && projectId && viewId ? VIEW_ISSUES(viewId as string) : null,
workspaceSlug && projectId && viewId
? () =>
viewsService.getViewIssues(workspaceSlug as string, projectId as string, viewId as string)
: null
);
return ( return (
<IssueViewContextProvider>
<AppLayout <AppLayout
breadcrumbs={ breadcrumbs={
<Breadcrumbs> <Breadcrumbs>
@ -60,9 +58,25 @@ const SingleView: React.FC<UserAuth> = (props) => {
/> />
</Breadcrumbs> </Breadcrumbs>
} }
right={
<div className="flex items-center gap-2">
<IssuesFilterView />
<HeaderButton
Icon={PlusIcon}
label="Add Issue"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
/>
</div>
}
> >
Content here <IssuesView userAuth={props} />
</AppLayout> </AppLayout>
</IssueViewContextProvider>
); );
}; };

View File

@ -0,0 +1,124 @@
import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR from "swr";
// lib
import { requiredAuth } from "lib/auth";
// services
import viewsService from "services/views.service";
import projectService from "services/project.service";
// layouts
import AppLayout from "layouts/app-layout";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { TrashIcon } from "@heroicons/react/20/solid";
// fetching keys
import { PROJECT_DETAILS, VIEWS_LIST } from "constants/fetch-keys";
// components
import { CustomMenu, Spinner } from "components/ui";
import { DeleteViewModal } from "components/views";
// types
import { IView } from "types";
import type { NextPage, GetServerSidePropsContext } from "next";
const ProjectViews: NextPage = () => {
const [selectedView, setSelectedView] = useState<IView | null>(null);
const {
query: { workspaceSlug, projectId },
} = useRouter();
const { data: activeProject } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { data: views } = useSWR(
workspaceSlug && projectId ? VIEWS_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => viewsService.getViews(workspaceSlug as string, projectId as string)
: null
);
return (
<AppLayout
meta={{
title: "Plane - Views",
}}
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Cycles`} />
</Breadcrumbs>
}
>
<DeleteViewModal
isOpen={!!selectedView}
data={selectedView}
onClose={() => setSelectedView(null)}
onSuccess={() => setSelectedView(null)}
/>
<div className="rounded-md border border-gray-400">
{views ? (
views.map((view) => (
<div
className="flex items-center justify-between border-b border-gray-400 p-4 last:border-b-0"
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="flex justify-center pt-20">
<Spinner />
</div>
)}
</div>
</AppLayout>
);
};
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const user = await requiredAuth(ctx.req?.headers.cookie);
const redirectAfterSignIn = ctx.resolvedUrl;
if (!user) {
return {
redirect: {
destination: `/signin?next=${redirectAfterSignIn}`,
permanent: false,
},
};
}
return {
props: {
user,
},
};
};
export default ProjectViews;

View File

@ -47,8 +47,8 @@ class ViewServices extends APIService {
}); });
} }
async deleteView(projectId: string, viewId: string): Promise<any> { async deleteView(workspaceSlug: string, projectId: string, viewId: string): Promise<any> {
return this.delete(`/api/projects/${projectId}/views/${viewId}/`) return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;

View File

@ -210,9 +210,11 @@ export interface IIssueActivity {
export interface IIssueFilterOptions { export interface IIssueFilterOptions {
type: "active" | "backlog" | null; type: "active" | "backlog" | null;
assignees: string[] | null; assignees: string[] | null;
state: string[] | null;
labels: string[] | null; labels: string[] | null;
issue__assignees__id: string[] | null; issue__assignees__id: string[] | null;
issue__labels__id: string[] | null; issue__labels__id: string[] | null;
priority: string[] | null;
} }
export interface IIssueViewOptions { export interface IIssueViewOptions {

View File

@ -7,7 +7,13 @@ export interface IView {
updated_by: string; updated_by: string;
name: string; name: string;
description: string; description: string;
query: { query: IQuery;
query_data: IQuery;
project: string;
workspace: string;
}
export interface IQuery {
state: string[] | null; state: string[] | null;
parent: string[] | null; parent: string[] | null;
labels: string[] | null; labels: string[] | null;
@ -29,7 +35,4 @@ export interface IView {
target_date: string[] | null; target_date: string[] | null;
completed_at: string[] | null; completed_at: string[] | null;
type: string; type: string;
};
project: string;
workspace: string;
} }