mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
[WEB-534] fix: application sidebar project, favourite project sidebar DND (#3778)
* fix: application sidebar project and favorite project reordering issue resolved * fix: enabled posthog * chore: project sort order in POST * chore: project member sort order * chore: project sorting order * fix: handle dragdrop functionality from store to helper function in project sidebar * fix: resolved build error --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
This commit is contained in:
parent
dad682a7c3
commit
01e09873e6
@ -77,6 +77,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
sort_order = ProjectMember.objects.filter(
|
||||||
|
member=self.request.user,
|
||||||
|
project_id=OuterRef("pk"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
is_active=True,
|
||||||
|
).values("sort_order")
|
||||||
return self.filter_queryset(
|
return self.filter_queryset(
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
@ -147,6 +153,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(sort_order=Subquery(sort_order))
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"project_projectmember",
|
"project_projectmember",
|
||||||
@ -166,16 +173,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
for field in request.GET.get("fields", "").split(",")
|
for field in request.GET.get("fields", "").split(",")
|
||||||
if field
|
if field
|
||||||
]
|
]
|
||||||
|
|
||||||
sort_order_query = ProjectMember.objects.filter(
|
|
||||||
member=request.user,
|
|
||||||
project_id=OuterRef("pk"),
|
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
|
||||||
is_active=True,
|
|
||||||
).values("sort_order")
|
|
||||||
projects = (
|
projects = (
|
||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.annotate(sort_order=Subquery(sort_order_query))
|
|
||||||
.order_by("sort_order", "name")
|
.order_by("sort_order", "name")
|
||||||
)
|
)
|
||||||
if request.GET.get("per_page", False) and request.GET.get(
|
if request.GET.get("per_page", False) and request.GET.get(
|
||||||
@ -204,7 +203,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
# Add the user as Administrator to the project
|
# Add the user as Administrator to the project
|
||||||
project_member = ProjectMember.objects.create(
|
_ = ProjectMember.objects.create(
|
||||||
project_id=serializer.data["id"],
|
project_id=serializer.data["id"],
|
||||||
member=request.user,
|
member=request.user,
|
||||||
role=20,
|
role=20,
|
||||||
|
@ -37,6 +37,7 @@ type Props = {
|
|||||||
snapshot?: DraggableStateSnapshot;
|
snapshot?: DraggableStateSnapshot;
|
||||||
handleCopyText: () => void;
|
handleCopyText: () => void;
|
||||||
shortContextMenu?: boolean;
|
shortContextMenu?: boolean;
|
||||||
|
disableDrag?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigation = (workspaceSlug: string, projectId: string) => [
|
const navigation = (workspaceSlug: string, projectId: string) => [
|
||||||
@ -79,7 +80,7 @@ const navigation = (workspaceSlug: string, projectId: string) => [
|
|||||||
|
|
||||||
export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { projectId, provided, snapshot, handleCopyText, shortContextMenu = false } = props;
|
const { projectId, provided, snapshot, handleCopyText, shortContextMenu = false, disableDrag } = props;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { theme: themeStore } = useApplication();
|
const { theme: themeStore } = useApplication();
|
||||||
const { setTrackElement } = useEventTracker();
|
const { setTrackElement } = useEventTracker();
|
||||||
@ -163,7 +164,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
|||||||
snapshot?.isDragging ? "opacity-60" : ""
|
snapshot?.isDragging ? "opacity-60" : ""
|
||||||
} ${isMenuActive ? "!bg-custom-sidebar-background-80" : ""}`}
|
} ${isMenuActive ? "!bg-custom-sidebar-background-80" : ""}`}
|
||||||
>
|
>
|
||||||
{provided && (
|
{provided && !disableDrag && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltipContent={project.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"}
|
tooltipContent={project.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"}
|
||||||
position="top-right"
|
position="top-right"
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useState, FC, useRef, useEffect } from "react";
|
import { useState, FC, useRef, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd";
|
import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd";
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
@ -11,9 +11,11 @@ import useToast from "hooks/use-toast";
|
|||||||
import { CreateProjectModal, ProjectSidebarListItem } from "components/project";
|
import { CreateProjectModal, ProjectSidebarListItem } from "components/project";
|
||||||
// helpers
|
// helpers
|
||||||
import { copyUrlToClipboard } from "helpers/string.helper";
|
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||||
|
import { orderJoinedProjects } from "helpers/project.helper";
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
// constants
|
// constants
|
||||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
|
import { IProject } from "@plane/types";
|
||||||
|
|
||||||
export const ProjectSidebarList: FC = observer(() => {
|
export const ProjectSidebarList: FC = observer(() => {
|
||||||
// states
|
// states
|
||||||
@ -32,9 +34,9 @@ export const ProjectSidebarList: FC = observer(() => {
|
|||||||
membership: { currentWorkspaceRole },
|
membership: { currentWorkspaceRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
const {
|
const {
|
||||||
|
getProjectById,
|
||||||
joinedProjectIds: joinedProjects,
|
joinedProjectIds: joinedProjects,
|
||||||
favoriteProjectIds: favoriteProjects,
|
favoriteProjectIds: favoriteProjects,
|
||||||
orderProjectsWithSortOrder,
|
|
||||||
updateProjectView,
|
updateProjectView,
|
||||||
} = useProject();
|
} = useProject();
|
||||||
// router
|
// router
|
||||||
@ -55,22 +57,27 @@ export const ProjectSidebarList: FC = observer(() => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnd = async (result: DropResult) => {
|
const onDragEnd = (result: DropResult) => {
|
||||||
const { source, destination, draggableId } = result;
|
const { source, destination, draggableId } = result;
|
||||||
|
|
||||||
if (!destination || !workspaceSlug) return;
|
if (!destination || !workspaceSlug) return;
|
||||||
|
|
||||||
if (source.index === destination.index) return;
|
if (source.index === destination.index) return;
|
||||||
|
|
||||||
const updatedSortOrder = orderProjectsWithSortOrder(source.index, destination.index, draggableId);
|
const joinedProjectsList: IProject[] = [];
|
||||||
|
joinedProjects.map((projectId) => {
|
||||||
updateProjectView(workspaceSlug.toString(), draggableId, { sort_order: updatedSortOrder }).catch(() => {
|
const _project = getProjectById(projectId);
|
||||||
setToastAlert({
|
if (_project) joinedProjectsList.push(_project);
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Something went wrong. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
if (joinedProjectsList.length <= 0) return;
|
||||||
|
|
||||||
|
const updatedSortOrder = orderJoinedProjects(source.index, destination.index, draggableId, joinedProjectsList);
|
||||||
|
if (updatedSortOrder != undefined)
|
||||||
|
updateProjectView(workspaceSlug.toString(), draggableId, { sort_order: updatedSortOrder }).catch(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Something went wrong. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCollapsed = sidebarCollapsed || false;
|
const isCollapsed = sidebarCollapsed || false;
|
||||||
@ -176,6 +183,7 @@ export const ProjectSidebarList: FC = observer(() => {
|
|||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
handleCopyText={() => handleCopyText(projectId)}
|
handleCopyText={() => handleCopyText(projectId)}
|
||||||
shortContextMenu
|
shortContextMenu
|
||||||
|
disableDrag
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
45
web/helpers/project.helper.ts
Normal file
45
web/helpers/project.helper.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { IProject } from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the sort order of the project.
|
||||||
|
* @param sortIndex
|
||||||
|
* @param destinationIndex
|
||||||
|
* @param projectId
|
||||||
|
* @returns number | undefined
|
||||||
|
*/
|
||||||
|
export const orderJoinedProjects = (
|
||||||
|
sourceIndex: number,
|
||||||
|
destinationIndex: number,
|
||||||
|
currentProjectId: string,
|
||||||
|
joinedProjects: IProject[]
|
||||||
|
): number | undefined => {
|
||||||
|
if (!currentProjectId || sourceIndex < 0 || destinationIndex < 0 || joinedProjects.length <= 0) return undefined;
|
||||||
|
|
||||||
|
let updatedSortOrder: number | undefined = undefined;
|
||||||
|
const sortOrderDefaultValue = 10000;
|
||||||
|
|
||||||
|
if (destinationIndex === 0) {
|
||||||
|
// updating project at the top of the project
|
||||||
|
const currentSortOrder = joinedProjects[destinationIndex].sort_order || 0;
|
||||||
|
updatedSortOrder = currentSortOrder - sortOrderDefaultValue;
|
||||||
|
} else if (destinationIndex === joinedProjects.length - 1) {
|
||||||
|
// updating project at the bottom of the project
|
||||||
|
const currentSortOrder = joinedProjects[destinationIndex - 1].sort_order || 0;
|
||||||
|
updatedSortOrder = currentSortOrder + sortOrderDefaultValue;
|
||||||
|
} else {
|
||||||
|
// updating project in the middle of the project
|
||||||
|
if (sourceIndex > destinationIndex) {
|
||||||
|
const destinationTopProjectSortOrder = joinedProjects[destinationIndex - 1].sort_order || 0;
|
||||||
|
const destinationBottomProjectSortOrder = joinedProjects[destinationIndex].sort_order || 0;
|
||||||
|
const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2;
|
||||||
|
updatedSortOrder = updatedValue;
|
||||||
|
} else {
|
||||||
|
const destinationTopProjectSortOrder = joinedProjects[destinationIndex].sort_order || 0;
|
||||||
|
const destinationBottomProjectSortOrder = joinedProjects[destinationIndex + 1].sort_order || 0;
|
||||||
|
const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2;
|
||||||
|
updatedSortOrder = updatedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedSortOrder;
|
||||||
|
};
|
@ -50,7 +50,7 @@ export const AppProvider: FC<IAppProvider> = observer((props) => {
|
|||||||
<CrispWrapper user={currentUser}>
|
<CrispWrapper user={currentUser}>
|
||||||
<PostHogProvider
|
<PostHogProvider
|
||||||
user={currentUser}
|
user={currentUser}
|
||||||
currentWorkspaceId= {currentWorkspace?.id}
|
currentWorkspaceId={currentWorkspace?.id}
|
||||||
workspaceRole={currentWorkspaceRole}
|
workspaceRole={currentWorkspaceRole}
|
||||||
projectRole={currentProjectRole}
|
projectRole={currentProjectRole}
|
||||||
posthogAPIKey={envConfig?.posthog_api_key || null}
|
posthogAPIKey={envConfig?.posthog_api_key || null}
|
||||||
|
@ -72,9 +72,6 @@ export class ProjectService extends APIService {
|
|||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
data: {
|
data: {
|
||||||
view_props?: IProjectViewProps;
|
|
||||||
default_props?: IProjectViewProps;
|
|
||||||
preferences?: ProjectPreferences;
|
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
}
|
}
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||||
import { computedFn } from "mobx-utils";
|
import { computedFn } from "mobx-utils";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
|
import sortBy from "lodash/sortBy";
|
||||||
// types
|
// types
|
||||||
import { RootStore } from "../root.store";
|
import { RootStore } from "../root.store";
|
||||||
import { IProject } from "@plane/types";
|
import { IProject } from "@plane/types";
|
||||||
// services
|
// services
|
||||||
import { IssueLabelService, IssueService } from "services/issue";
|
import { IssueLabelService, IssueService } from "services/issue";
|
||||||
import { ProjectService, ProjectStateService } from "services/project";
|
import { ProjectService, ProjectStateService } from "services/project";
|
||||||
|
import { cloneDeep, update } from "lodash";
|
||||||
export interface IProjectStore {
|
export interface IProjectStore {
|
||||||
// observables
|
// observables
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@ -28,8 +30,6 @@ export interface IProjectStore {
|
|||||||
// favorites actions
|
// favorites actions
|
||||||
addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
|
addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
|
||||||
removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
|
removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
|
||||||
// project-order action
|
|
||||||
orderProjectsWithSortOrder: (sourceIndex: number, destinationIndex: number, projectId: string) => number;
|
|
||||||
// project-view action
|
// project-view action
|
||||||
updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>;
|
updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>;
|
||||||
// CRUD actions
|
// CRUD actions
|
||||||
@ -71,8 +71,6 @@ export class ProjectStore implements IProjectStore {
|
|||||||
// favorites actions
|
// favorites actions
|
||||||
addProjectToFavorites: action,
|
addProjectToFavorites: action,
|
||||||
removeProjectFromFavorites: action,
|
removeProjectFromFavorites: action,
|
||||||
// project-order action
|
|
||||||
orderProjectsWithSortOrder: action,
|
|
||||||
// project-view action
|
// project-view action
|
||||||
updateProjectView: action,
|
updateProjectView: action,
|
||||||
// CRUD actions
|
// CRUD actions
|
||||||
@ -128,7 +126,11 @@ export class ProjectStore implements IProjectStore {
|
|||||||
get joinedProjectIds() {
|
get joinedProjectIds() {
|
||||||
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
|
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
|
||||||
if (!currentWorkspace) return [];
|
if (!currentWorkspace) return [];
|
||||||
const projectIds = Object.values(this.projectMap ?? {})
|
|
||||||
|
let projects = Object.values(this.projectMap ?? {});
|
||||||
|
projects = sortBy(projects, "sort_order");
|
||||||
|
|
||||||
|
const projectIds = projects
|
||||||
.filter((project) => project.workspace === currentWorkspace.id && project.is_member)
|
.filter((project) => project.workspace === currentWorkspace.id && project.is_member)
|
||||||
.map((project) => project.id);
|
.map((project) => project.id);
|
||||||
return projectIds;
|
return projectIds;
|
||||||
@ -140,7 +142,11 @@ export class ProjectStore implements IProjectStore {
|
|||||||
get favoriteProjectIds() {
|
get favoriteProjectIds() {
|
||||||
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
|
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
|
||||||
if (!currentWorkspace) return [];
|
if (!currentWorkspace) return [];
|
||||||
const projectIds = Object.values(this.projectMap ?? {})
|
|
||||||
|
let projects = Object.values(this.projectMap ?? {});
|
||||||
|
projects = sortBy(projects, "created_at");
|
||||||
|
|
||||||
|
const projectIds = projects
|
||||||
.filter((project) => project.workspace === currentWorkspace.id && project.is_favorite)
|
.filter((project) => project.workspace === currentWorkspace.id && project.is_favorite)
|
||||||
.map((project) => project.id);
|
.map((project) => project.id);
|
||||||
return projectIds;
|
return projectIds;
|
||||||
@ -253,41 +259,6 @@ export class ProjectStore implements IProjectStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the sort order of the project.
|
|
||||||
* @param sortIndex
|
|
||||||
* @param destinationIndex
|
|
||||||
* @param projectId
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
orderProjectsWithSortOrder = (sortIndex: number, destinationIndex: number, projectId: string) => {
|
|
||||||
try {
|
|
||||||
const workspaceSlug = this.rootStore.app.router.workspaceSlug;
|
|
||||||
if (!workspaceSlug) return 0;
|
|
||||||
const projectsList = Object.values(this.projectMap || {}) || [];
|
|
||||||
let updatedSortOrder = projectsList[sortIndex].sort_order;
|
|
||||||
if (destinationIndex === 0) updatedSortOrder = (projectsList[0].sort_order as number) - 1000;
|
|
||||||
else if (destinationIndex === projectsList.length - 1)
|
|
||||||
updatedSortOrder = (projectsList[projectsList.length - 1].sort_order as number) + 1000;
|
|
||||||
else {
|
|
||||||
const destinationSortingOrder = projectsList[destinationIndex].sort_order as number;
|
|
||||||
const relativeDestinationSortingOrder =
|
|
||||||
sortIndex < destinationIndex
|
|
||||||
? (projectsList[destinationIndex + 1].sort_order as number)
|
|
||||||
: (projectsList[destinationIndex - 1].sort_order as number);
|
|
||||||
|
|
||||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
|
||||||
}
|
|
||||||
runInAction(() => {
|
|
||||||
set(this.projectMap, [projectId, "sort_order"], updatedSortOrder);
|
|
||||||
});
|
|
||||||
return updatedSortOrder;
|
|
||||||
} catch (error) {
|
|
||||||
console.log("failed to update sort order of the projects");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the project view
|
* Updates the project view
|
||||||
* @param workspaceSlug
|
* @param workspaceSlug
|
||||||
@ -295,12 +266,18 @@ export class ProjectStore implements IProjectStore {
|
|||||||
* @param viewProps
|
* @param viewProps
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
updateProjectView = async (workspaceSlug: string, projectId: string, viewProps: any) => {
|
updateProjectView = async (workspaceSlug: string, projectId: string, viewProps: { sort_order: number }) => {
|
||||||
|
const currentProjectSortOrder = this.getProjectById(projectId)?.sort_order;
|
||||||
try {
|
try {
|
||||||
|
runInAction(() => {
|
||||||
|
set(this.projectMap, [projectId, "sort_order"], viewProps?.sort_order);
|
||||||
|
});
|
||||||
const response = await this.projectService.setProjectView(workspaceSlug, projectId, viewProps);
|
const response = await this.projectService.setProjectView(workspaceSlug, projectId, viewProps);
|
||||||
await this.fetchProjects(workspaceSlug);
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
runInAction(() => {
|
||||||
|
set(this.projectMap, [projectId, "sort_order"], currentProjectSortOrder);
|
||||||
|
});
|
||||||
console.log("Failed to update sort order of the projects");
|
console.log("Failed to update sort order of the projects");
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -2812,7 +2812,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react@*", "@types/react@^18.2.42":
|
"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42":
|
||||||
version "18.2.42"
|
version "18.2.42"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7"
|
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7"
|
||||||
integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==
|
integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==
|
||||||
|
Loading…
Reference in New Issue
Block a user