From 4feec35773bd90a308cb93bb5059d6cf53425770 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Tue, 21 May 2024 14:55:29 +0530 Subject: [PATCH 1/6] [WEB-1301] chore: handled issues count in project, module, and cycle issues (#4538) * chore: handled issues count in project, module, and cycle issues * chore: changed the typo from getIssuesCount to issuesCount --- web/components/headers/cycle-issues.tsx | 15 +++----- web/components/headers/module-issues.tsx | 15 +++----- web/components/headers/project-issues.tsx | 13 +++---- web/helpers/issue.helper.ts | 43 +++++++++++++++++++++++ web/store/issue/cycle/issue.store.ts | 32 +++++++++++++---- web/store/issue/module/issue.store.ts | 29 ++++++++++++--- web/store/issue/project/issue.store.ts | 20 +++++++++++ 7 files changed, 127 insertions(+), 40 deletions(-) diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 3b6d40534e..e0d7e3c509 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -70,6 +70,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { // store hooks const { issuesFilter: { issueFilters, updateFilters }, + issues: { issuesCount }, } = useIssues(EIssuesStoreType.CYCLE); const { currentProjectCycleIds, getCycleById } = useCycle(); const { toggleCreateIssueModal } = useCommandPalette(); @@ -145,12 +146,6 @@ export const CycleIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const issueCount = cycleDetails - ? !issueFilters?.displayFilters?.sub_issue && cycleDetails?.sub_issues - ? cycleDetails.total_issues - cycleDetails?.sub_issues - : cycleDetails.total_issues - : undefined; - const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; return ( @@ -209,16 +204,16 @@ export const CycleIssuesHeader: React.FC = observer(() => {

{cycleDetails?.name && cycleDetails.name}

- {issueCount && issueCount > 0 ? ( + {issuesCount && issuesCount > 0 ? ( 1 ? "issues" : "issue" + tooltipContent={`There are ${issuesCount} ${ + issuesCount > 1 ? "issues" : "issue" } in this cycle`} position="bottom" > - {issueCount} + {issuesCount} ) : null} diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index 9a911103d9..538eca2cde 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -71,6 +71,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { // store hooks const { issuesFilter: { issueFilters }, + issues: { issuesCount }, } = useIssues(EIssuesStoreType.MODULE); const { updateFilters } = useIssuesActions(EIssuesStoreType.MODULE); const { projectModuleIds, getModuleById } = useModule(); @@ -145,12 +146,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const issueCount = moduleDetails - ? !issueFilters?.displayFilters?.sub_issue && moduleDetails.sub_issues - ? moduleDetails.total_issues - moduleDetails.sub_issues - : moduleDetails.total_issues - : undefined; - const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; return ( @@ -209,16 +204,16 @@ export const ModuleIssuesHeader: React.FC = observer(() => {

{moduleDetails?.name && moduleDetails.name}

- {issueCount && issueCount > 0 ? ( + {issuesCount && issuesCount > 0 ? ( 1 ? "issues" : "issue" + tooltipContent={`There are ${issuesCount} ${ + issuesCount > 1 ? "issues" : "issue" } in this module`} position="bottom" > - {issueCount} + {issuesCount} ) : null} diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 95983d85a3..8ba44719e9 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -43,6 +43,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { } = useMember(); const { issuesFilter: { issueFilters, updateFilters }, + issues: { issuesCount }, } = useIssues(EIssuesStoreType.PROJECT); const { toggleCreateIssueModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); @@ -105,12 +106,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const issueCount = currentProjectDetails - ? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails?.sub_issues - ? currentProjectDetails?.total_issues - currentProjectDetails?.sub_issues - : currentProjectDetails?.total_issues - : undefined; - const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; return ( @@ -153,14 +148,14 @@ export const ProjectIssuesHeader: React.FC = observer(() => { link={} />} /> - {issueCount && issueCount > 0 ? ( + {issuesCount && issuesCount > 0 ? ( 1 ? "issues" : "issue"} in this project`} + tooltipContent={`There are ${issuesCount} ${issuesCount > 1 ? "issues" : "issue"} in this project`} position="bottom" > - {issueCount} + {issuesCount} ) : null} diff --git a/web/helpers/issue.helper.ts b/web/helpers/issue.helper.ts index 614994767e..d01f996c76 100644 --- a/web/helpers/issue.helper.ts +++ b/web/helpers/issue.helper.ts @@ -2,12 +2,15 @@ import differenceInCalendarDays from "date-fns/differenceInCalendarDays"; import { v4 as uuidv4 } from "uuid"; // types import { + TGroupedIssues, TIssue, TIssueGroupByOptions, TIssueLayouts, TIssueOrderByOptions, TIssueParams, TStateGroups, + TSubGroupedIssues, + TUnGroupedIssues, } from "@plane/types"; import { IGanttBlock } from "@/components/gantt-chart"; // constants @@ -211,3 +214,43 @@ export const getDescriptionPlaceholder = (isFocused: boolean, description: strin if (!isDescriptionEmpty || isFocused) return "Press '/' for commands..."; else return "Click to add description"; }; + +export const issueCountBasedOnFilters = ( + issueIds: TUnGroupedIssues | TGroupedIssues | TSubGroupedIssues, + layout: TIssueLayouts, + groupBy: string | undefined, + subGroupBy: string | undefined +): number => { + let issuesCount = 0; + if (!layout) return issuesCount; + + if (["spreadsheet", "gantt_chart"].includes(layout)) { + issuesCount = (issueIds as TUnGroupedIssues)?.length; + } else if (layout === "calendar") { + Object.keys(issueIds || {}).map((groupId) => { + issuesCount += (issueIds as TGroupedIssues)?.[groupId]?.length; + }); + } else if (layout === "list") { + if (groupBy) { + Object.keys(issueIds || {}).map((groupId) => { + issuesCount += (issueIds as TGroupedIssues)?.[groupId]?.length; + }); + } else { + issuesCount = (issueIds as TUnGroupedIssues)?.length; + } + } else if (layout === "kanban") { + if (groupBy && subGroupBy) { + Object.keys(issueIds || {}).map((groupId) => { + Object.keys((issueIds as TSubGroupedIssues)?.[groupId] || {}).map((subGroupId) => { + issuesCount += (issueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId]?.length || 0; + }); + }); + } else if (groupBy) { + Object.keys(issueIds || {}).map((groupId) => { + issuesCount += (issueIds as TGroupedIssues)?.[groupId]?.length; + }); + } + } + + return issuesCount; +}; diff --git a/web/store/issue/cycle/issue.store.ts b/web/store/issue/cycle/issue.store.ts index 7a264dad0d..3632d03f71 100644 --- a/web/store/issue/cycle/issue.store.ts +++ b/web/store/issue/cycle/issue.store.ts @@ -6,6 +6,8 @@ import update from "lodash/update"; import { action, observable, makeObservable, computed, runInAction } from "mobx"; // types import { TIssue, TSubGroupedIssues, TGroupedIssues, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types"; +// helpers +import { issueCountBasedOnFilters } from "@/helpers/issue.helper"; // services import { CycleService } from "@/services/cycle.service"; import { IssueService } from "@/services/issue"; @@ -21,6 +23,7 @@ export interface ICycleIssues { issues: { [cycle_id: string]: string[] }; viewFlags: ViewFlags; // computed + issuesCount: number; groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; // actions getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; @@ -60,7 +63,7 @@ export interface ICycleIssues { ) => Promise; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; - removeCycleFromIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise + removeCycleFromIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; transferIssuesFromCycle: ( workspaceSlug: string, projectId: string, @@ -93,6 +96,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { loader: observable.ref, issues: observable, // computed + issuesCount: computed, groupedIssueIds: computed, // action fetchIssues: action, @@ -113,6 +117,22 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { this.cycleService = new CycleService(); } + get issuesCount() { + let issuesCount = 0; + + const displayFilters = this.rootStore?.cycleIssuesFilter?.issueFilters?.displayFilters; + const groupedIssueIds = this.groupedIssueIds; + if (!displayFilters || !groupedIssueIds) return issuesCount; + + const layout = displayFilters?.layout || undefined; + const groupBy = displayFilters?.group_by || undefined; + const subGroupBy = displayFilters?.sub_group_by || undefined; + + if (!layout) return issuesCount; + issuesCount = issueCountBasedOnFilters(groupedIssueIds, layout, groupBy, subGroupBy); + return issuesCount; + } + get groupedIssueIds() { const cycleId = this.rootIssueStore?.cycleId; if (!cycleId) return undefined; @@ -336,14 +356,14 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { /** * Remove a cycle from issue - * @param workspaceSlug - * @param projectId - * @param issueId - * @returns + * @param workspaceSlug + * @param projectId + * @param issueId + * @returns */ removeCycleFromIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id; - if(!issueCycleId) return; + if (!issueCycleId) return; try { // perform optimistic update, update store runInAction(() => { diff --git a/web/store/issue/module/issue.store.ts b/web/store/issue/module/issue.store.ts index e5c7b1cc1b..35066a052f 100644 --- a/web/store/issue/module/issue.store.ts +++ b/web/store/issue/module/issue.store.ts @@ -7,6 +7,8 @@ import update from "lodash/update"; import { action, observable, makeObservable, computed, runInAction } from "mobx"; // types import { TIssue, TLoader, TGroupedIssues, TSubGroupedIssues, TUnGroupedIssues, ViewFlags } from "@plane/types"; +// helpers +import { issueCountBasedOnFilters } from "@/helpers/issue.helper"; // services import { IssueService } from "@/services/issue"; import { ModuleService } from "@/services/module.service"; @@ -21,6 +23,7 @@ export interface IModuleIssues { issues: { [module_id: string]: string[] }; viewFlags: ViewFlags; // computed + issuesCount: number; groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; // actions getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; @@ -95,6 +98,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { loader: observable.ref, issues: observable, // computed + issuesCount: computed, groupedIssueIds: computed, // action fetchIssues: action, @@ -113,6 +117,22 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { this.moduleService = new ModuleService(); } + get issuesCount() { + let issuesCount = 0; + + const displayFilters = this.rootIssueStore?.moduleIssuesFilter?.issueFilters?.displayFilters; + const groupedIssueIds = this.groupedIssueIds; + if (!displayFilters || !groupedIssueIds) return issuesCount; + + const layout = displayFilters?.layout || undefined; + const groupBy = displayFilters?.group_by || undefined; + const subGroupBy = displayFilters?.sub_group_by || undefined; + + if (!layout) return issuesCount; + issuesCount = issueCountBasedOnFilters(groupedIssueIds, layout, groupBy, subGroupBy); + return issuesCount; + } + get groupedIssueIds() { const moduleId = this.rootIssueStore?.moduleId; if (!moduleId) return undefined; @@ -370,9 +390,9 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { /** * change modules array in issue - * @param workspaceSlug - * @param projectId - * @param issueId + * @param workspaceSlug + * @param projectId + * @param issueId * @param addModuleIds array of modules to be added * @param removeModuleIds array of modules to be removed */ @@ -404,7 +424,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { }); }); }); - if(originalModuleIds){ + if (originalModuleIds) { // update the root issue map with the new module ids let currentModuleIds = concat([...originalModuleIds], addModuleIds); currentModuleIds = pull(currentModuleIds, ...removeModuleIds); @@ -420,7 +440,6 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { if (!isEmpty(removeModuleIds)) { await this.moduleService.removeModulesFromIssueBulk(workspaceSlug, projectId, issueId, removeModuleIds); } - } catch (error) { // revert the issue back to its original module ids set(this.rootStore.issues.issuesMap, [issueId, "module_ids"], originalModuleIds); diff --git a/web/store/issue/project/issue.store.ts b/web/store/issue/project/issue.store.ts index 6e137b9d86..8591f494c9 100644 --- a/web/store/issue/project/issue.store.ts +++ b/web/store/issue/project/issue.store.ts @@ -5,6 +5,8 @@ import update from "lodash/update"; import { action, makeObservable, observable, runInAction, computed } from "mobx"; // types import { TIssue, TGroupedIssues, TSubGroupedIssues, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types"; +// helpers +import { issueCountBasedOnFilters } from "@/helpers/issue.helper"; // base class import { IssueService, IssueArchiveService } from "@/services/issue"; import { IssueHelperStore } from "../helpers/issue-helper.store"; @@ -17,6 +19,7 @@ export interface IProjectIssues { issues: Record; // Record of project_id as key and issue_ids as value viewFlags: ViewFlags; // computed + issuesCount: number; groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; // action @@ -51,6 +54,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { loader: observable.ref, issues: observable, // computed + issuesCount: computed, groupedIssueIds: computed, // action fetchIssues: action, @@ -68,6 +72,22 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { this.issueArchiveService = new IssueArchiveService(); } + get issuesCount() { + let issuesCount = 0; + + const displayFilters = this.rootStore?.projectIssuesFilter?.issueFilters?.displayFilters; + const groupedIssueIds = this.groupedIssueIds; + if (!displayFilters || !groupedIssueIds) return issuesCount; + + const layout = displayFilters?.layout || undefined; + const groupBy = displayFilters?.group_by || undefined; + const subGroupBy = displayFilters?.sub_group_by || undefined; + + if (!layout) return issuesCount; + issuesCount = issueCountBasedOnFilters(groupedIssueIds, layout, groupBy, subGroupBy); + return issuesCount; + } + get groupedIssueIds() { const projectId = this.rootStore?.projectId; if (!projectId) return undefined; From 410f04c327b933306dec2810790611583046ff36 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 21 May 2024 15:03:01 +0530 Subject: [PATCH 2/6] dev: rest framework throttling (#4534) --- .../plane/authentication/adapter/error.py | 2 ++ .../plane/authentication/adapter/exception.py | 15 +++++++++++ apiserver/plane/authentication/rate_limit.py | 26 +++++++++++++++++++ .../plane/authentication/views/app/check.py | 10 ++++++- .../views/app/password_management.py | 6 ++++- .../plane/authentication/views/space/check.py | 6 ++++- .../views/space/password_management.py | 5 ++++ 7 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 apiserver/plane/authentication/rate_limit.py diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 47dbc8e8a5..457a67f4fa 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -55,6 +55,8 @@ AUTHENTICATION_ERROR_CODES = { "ADMIN_USER_ALREADY_EXIST": 5180, "ADMIN_USER_DOES_NOT_EXIST": 5185, "ADMIN_USER_DEACTIVATED": 5190, + # Rate limit + "RATE_LIMIT_EXCEEDED": 5900, } diff --git a/apiserver/plane/authentication/adapter/exception.py b/apiserver/plane/authentication/adapter/exception.py index 12845ea025..a6f7637a98 100644 --- a/apiserver/plane/authentication/adapter/exception.py +++ b/apiserver/plane/authentication/adapter/exception.py @@ -1,5 +1,10 @@ +# Third party imports from rest_framework.views import exception_handler from rest_framework.exceptions import NotAuthenticated +from rest_framework.exceptions import Throttled + +# Module imports +from plane.authentication.adapter.error import AuthenticationException, AUTHENTICATION_ERROR_CODES def auth_exception_handler(exc, context): @@ -9,4 +14,14 @@ def auth_exception_handler(exc, context): if isinstance(exc, NotAuthenticated): response.status_code = 401 + # Check if an Throttled exception is raised. + if isinstance(exc, Throttled): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], + error_message="RATE_LIMIT_EXCEEDED", + ) + response.data = exc.get_error_dict() + response.status_code = 429 + + # Return the response that is generated by the default exception handler. return response diff --git a/apiserver/plane/authentication/rate_limit.py b/apiserver/plane/authentication/rate_limit.py new file mode 100644 index 0000000000..744bd38fec --- /dev/null +++ b/apiserver/plane/authentication/rate_limit.py @@ -0,0 +1,26 @@ +# Third party imports +from rest_framework.throttling import AnonRateThrottle +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class AuthenticationThrottle(AnonRateThrottle): + rate = "30/minute" + scope = "authentication" + + def throttle_failure_view(self, request, *args, **kwargs): + try: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], + error_message="RATE_LIMIT_EXCEEDED", + ) + except AuthenticationException as e: + return Response( + e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS + ) diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py index 4f164e3088..2448aee559 100644 --- a/apiserver/plane/authentication/views/app/check.py +++ b/apiserver/plane/authentication/views/app/check.py @@ -15,7 +15,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) - +from plane.authentication.rate_limit import AuthenticationThrottle class EmailCheckSignUpEndpoint(APIView): @@ -23,6 +23,10 @@ class EmailCheckSignUpEndpoint(APIView): AllowAny, ] + throttle_classes = [ + AuthenticationThrottle, + ] + def post(self, request): try: # Check instance configuration @@ -86,6 +90,10 @@ class EmailCheckSignInEndpoint(APIView): AllowAny, ] + throttle_classes = [ + AuthenticationThrottle, + ] + def post(self, request): try: # Check instance configuration diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py index dd14ceb914..43054867ef 100644 --- a/apiserver/plane/authentication/views/app/password_management.py +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -32,7 +32,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) - +from plane.authentication.rate_limit import AuthenticationThrottle def generate_password_token(user): uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) @@ -46,6 +46,10 @@ class ForgotPasswordEndpoint(APIView): AllowAny, ] + throttle_classes = [ + AuthenticationThrottle, + ] + def post(self, request): email = request.data.get("email") diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py index 83f52e28f5..1b20d19a26 100644 --- a/apiserver/plane/authentication/views/space/check.py +++ b/apiserver/plane/authentication/views/space/check.py @@ -15,7 +15,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) - +from plane.authentication.rate_limit import AuthenticationThrottle class EmailCheckEndpoint(APIView): @@ -23,6 +23,10 @@ class EmailCheckEndpoint(APIView): AllowAny, ] + throttle_classes = [ + AuthenticationThrottle, + ] + def post(self, request): # Check instance configuration instance = Instance.objects.first() diff --git a/apiserver/plane/authentication/views/space/password_management.py b/apiserver/plane/authentication/views/space/password_management.py index fa20fa618b..3e0379b964 100644 --- a/apiserver/plane/authentication/views/space/password_management.py +++ b/apiserver/plane/authentication/views/space/password_management.py @@ -32,6 +32,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) +from plane.authentication.rate_limit import AuthenticationThrottle def generate_password_token(user): @@ -46,6 +47,10 @@ class ForgotPasswordSpaceEndpoint(APIView): AllowAny, ] + throttle_classes = [ + AuthenticationThrottle, + ] + def post(self, request): email = request.data.get("email") From 0f5294c5e27f1bf29589f8124a25c712ce8cbf41 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 21 May 2024 15:04:10 +0530 Subject: [PATCH 3/6] [WEB - 1387]dev: custom csrf failure view (#4531) * dev: custom csrf view * dev: update template to use only css for styling --- .../plane/authentication/views/common.py | 10 ++- apiserver/plane/settings/common.py | 1 + apiserver/templates/csrf_failure.html | 66 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 apiserver/templates/csrf_failure.html diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py index 640f744ceb..3e95d6ed88 100644 --- a/apiserver/plane/authentication/views/common.py +++ b/apiserver/plane/authentication/views/common.py @@ -1,3 +1,6 @@ +# Django imports +from django.shortcuts import render + # Third party imports from rest_framework import status from rest_framework.permissions import AllowAny @@ -17,7 +20,7 @@ from plane.authentication.adapter.error import ( ) from django.middleware.csrf import get_token from plane.utils.cache import invalidate_cache - +from plane.authentication.utils.host import base_host class CSRFTokenEndpoint(APIView): @@ -34,6 +37,11 @@ class CSRFTokenEndpoint(APIView): ) +def csrf_failure(request, reason=""): + """Custom CSRF failure view""" + return render(request, "csrf_failure.html", {"reason": reason, "root_url": base_host(request=request)}) + + class ChangePasswordEndpoint(APIView): def post(self, request): user = User.objects.get(pk=request.user.id) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index ed756642bc..853478c753 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -345,6 +345,7 @@ CSRF_COOKIE_SECURE = secure_origins CSRF_COOKIE_HTTPONLY = True CSRF_TRUSTED_ORIGINS = cors_allowed_origins CSRF_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) +CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure" # Base URLs ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) diff --git a/apiserver/templates/csrf_failure.html b/apiserver/templates/csrf_failure.html new file mode 100644 index 0000000000..b5a58cb021 --- /dev/null +++ b/apiserver/templates/csrf_failure.html @@ -0,0 +1,66 @@ + + + + + + + CSRF Verification Failed + + + +
+
+

CSRF Verification Failed

+
+
+

+ It looks like your form submission has expired or there was a problem + with your request. +

+

Please try the following:

+
    +
  • Refresh the page and try submitting the form again.
  • +
  • Ensure that cookies are enabled in your browser.
  • +
+ Go to Home Page +
+
+ + From afc2ca65cf17062e55e64173823a1b6d47532096 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Tue, 21 May 2024 16:25:57 +0530 Subject: [PATCH 4/6] [WEB-1138] feat: List lssue Layout Drag and Drop (#4536) * List Dnd Complete feature * fix minor bugs in list dnd * remove double overlay in kanban post refactor * add missing dependencies to useEffects * make provision to add to the last issue of the group * show current child issues to also be disabled if the parent issue is being dragged * fix last issue border * fix code static analysis suggestions * prevent context menu on drag handle --- .../ui/src}/drag-handle.tsx | 14 +- packages/ui/src/index.ts | 1 + .../issue-layouts/group-drag-overlay.tsx | 61 +++++ .../issue-layouts/kanban/base-kanban-root.tsx | 96 +------ .../issues/issue-layouts/kanban/block.tsx | 4 +- .../issue-layouts/kanban/kanban-group.tsx | 71 ++--- .../issue-layouts/list/base-list-root.tsx | 14 +- .../issues/issue-layouts/list/block-root.tsx | 86 ++++++- .../issues/issue-layouts/list/block.tsx | 59 ++++- .../issues/issue-layouts/list/blocks-list.tsx | 34 ++- .../issues/issue-layouts/list/default.tsx | 132 +++++----- .../issues/issue-layouts/list/list-group.tsx | 242 ++++++++++++++++++ web/components/issues/issue-layouts/utils.tsx | 28 +- .../labels/label-block/label-item-block.tsx | 3 +- web/constants/issue.ts | 10 + web/hooks/use-group-dragndrop.ts | 124 +++++++++ web/store/issue/archived/issue.store.ts | 21 ++ web/store/issue/issue_kanban_view.store.ts | 3 +- 18 files changed, 751 insertions(+), 252 deletions(-) rename {web/components/labels/label-block => packages/ui/src}/drag-handle.tsx (57%) create mode 100644 web/components/issues/issue-layouts/group-drag-overlay.tsx create mode 100644 web/components/issues/issue-layouts/list/list-group.tsx create mode 100644 web/hooks/use-group-dragndrop.ts diff --git a/web/components/labels/label-block/drag-handle.tsx b/packages/ui/src/drag-handle.tsx similarity index 57% rename from web/components/labels/label-block/drag-handle.tsx rename to packages/ui/src/drag-handle.tsx index 64aaa075f6..d04f7929f2 100644 --- a/web/components/labels/label-block/drag-handle.tsx +++ b/packages/ui/src/drag-handle.tsx @@ -1,19 +1,29 @@ +import React from "react"; import { forwardRef } from "react"; import { MoreVertical } from "lucide-react"; interface IDragHandle { isDragging: boolean; + disabled?: boolean; } export const DragHandle = forwardRef((props, ref) => { - const { isDragging } = props; + const { isDragging, disabled = false } = props; + + if (disabled) { + return
; + } return (
); diff --git a/web/components/issues/issue-layouts/list/block-root.tsx b/web/components/issues/issue-layouts/list/block-root.tsx index 42f1d58e3a..3832ab7a6d 100644 --- a/web/components/issues/issue-layouts/list/block-root.tsx +++ b/web/components/issues/issue-layouts/list/block-root.tsx @@ -1,12 +1,18 @@ -import React, { FC, MutableRefObject, useState } from "react"; +import React, { FC, MutableRefObject, useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; import { observer } from "mobx-react"; import { IIssueDisplayProperties, TIssue, TIssueMap } from "@plane/types"; // components +import { DropIndicator } from "@plane/ui"; import RenderIfVisible from "@/components/core/render-if-visible-HOC"; import { IssueBlock } from "@/components/issues/issue-layouts/list"; // hooks import { useIssueDetail } from "@/hooks/store"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; // types +import { HIGHLIGHT_CLASS, getIssueBlockId } from "../utils"; import { TRenderQuickActions } from "./list-view-types"; type Props = { @@ -20,6 +26,11 @@ type Props = { nestingLevel: number; spacingLeft?: number; containerRef: MutableRefObject; + groupId: string; + isDragAllowed: boolean; + canDropOverIssue: boolean; + isParentIssueBeingDragged?: boolean; + isLastChild?: boolean; }; export const IssueBlockRoot: FC = observer((props) => { @@ -27,6 +38,7 @@ export const IssueBlockRoot: FC = observer((props) => { issueIds, issueId, issuesMap, + groupId, updateIssue, quickActions, canEditProperties, @@ -34,26 +46,84 @@ export const IssueBlockRoot: FC = observer((props) => { nestingLevel, spacingLeft = 14, containerRef, + isDragAllowed, + canDropOverIssue, + isParentIssueBeingDragged = false, + isLastChild = false, } = props; // states const [isExpanded, setExpanded] = useState(false); + const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined); + const [isCurrentBlockDragging, setIsCurrentBlockDragging] = useState(false); + // ref + const issueBlockRef = useRef(null); // store hooks const { subIssues: subIssuesStore } = useIssueDetail(); + const isSubIssue = nestingLevel !== 0; + + useEffect(() => { + const blockElement = issueBlockRef.current; + + if (!blockElement) return; + + return combine( + dropTargetForElements({ + element: blockElement, + canDrop: ({ source }) => source?.data?.id !== issueId && !isSubIssue && canDropOverIssue, + getData: ({ input, element }) => { + const data = { id: issueId, type: "ISSUE" }; + + // attach instruction for last in list + return attachInstruction(data, { + input, + element, + currentLevel: 0, + indentPerLevel: 0, + mode: isLastChild ? "last-in-group" : "standard", + }); + }, + onDrag: ({ self }) => { + const extractedInstruction = extractInstruction(self?.data)?.type; + // check if the highlight is to be shown above or below + setInstruction( + extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined + ); + }, + onDragLeave: () => { + setInstruction(undefined); + }, + onDrop: () => { + setInstruction(undefined); + }, + }) + ); + }, [issueId, isLastChild, issueBlockRef, isSubIssue, canDropOverIssue, setInstruction]); + + useOutsideClickDetector(issueBlockRef, () => { + issueBlockRef?.current?.classList?.remove(HIGHLIGHT_CLASS); + }); + if (!issueId) return null; const subIssues = subIssuesStore.subIssuesByIssueId(issueId); return ( - <> +
+ = observer((props) => { setExpanded={setExpanded} nestingLevel={nestingLevel} spacingLeft={spacingLeft} + canDrag={!isSubIssue && isDragAllowed} + isCurrentBlockDragging={isParentIssueBeingDragged || isCurrentBlockDragging} + setIsCurrentBlockDragging={setIsCurrentBlockDragging} /> @@ -81,8 +154,13 @@ export const IssueBlockRoot: FC = observer((props) => { nestingLevel={nestingLevel + 1} spacingLeft={spacingLeft + (displayProperties?.key ? 12 : 0)} containerRef={containerRef} + groupId={groupId} + isDragAllowed={isDragAllowed} + canDropOverIssue={canDropOverIssue} + isParentIssueBeingDragged={isParentIssueBeingDragged || isCurrentBlockDragging} /> ))} - + {isLastChild && } +
); }); diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 0f2d02ff4a..d38eb11997 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -1,10 +1,12 @@ -import { Dispatch, MouseEvent, SetStateAction, useRef } from "react"; +import { Dispatch, MouseEvent, SetStateAction, useEffect, useRef } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { observer } from "mobx-react-lite"; import { ChevronRight } from "lucide-react"; // types import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; // ui -import { Spinner, Tooltip, ControlLink } from "@plane/ui"; +import { Spinner, Tooltip, ControlLink, DragHandle } from "@plane/ui"; // components import { IssueProperties } from "@/components/issues/issue-layouts/properties"; // helpers @@ -18,6 +20,7 @@ import { TRenderQuickActions } from "./list-view-types"; interface IssueBlockProps { issueId: string; issuesMap: TIssueMap; + groupId: string; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; @@ -26,12 +29,16 @@ interface IssueBlockProps { spacingLeft?: number; isExpanded: boolean; setExpanded: Dispatch>; + isCurrentBlockDragging: boolean; + setIsCurrentBlockDragging: React.Dispatch>; + canDrag: boolean; } -export const IssueBlock: React.FC = observer((props: IssueBlockProps) => { +export const IssueBlock = observer((props: IssueBlockProps) => { const { issuesMap, issueId, + groupId, updateIssue, quickActions, displayProperties, @@ -40,9 +47,13 @@ export const IssueBlock: React.FC = observer((props: IssueBlock spacingLeft = 14, isExpanded, setExpanded, + isCurrentBlockDragging, + setIsCurrentBlockDragging, + canDrag, } = props; - // refs - const parentRef = useRef(null); + // ref + const issueRef = useRef(null); + const dragHandleRef = useRef(null); // hooks const { workspaceSlug } = useAppRouter(); const { getProjectIdentifierById } = useProject(); @@ -59,6 +70,29 @@ export const IssueBlock: React.FC = observer((props: IssueBlock const issue = issuesMap[issueId]; const subIssues = subIssuesStore.subIssuesByIssueId(issueId); const { isMobile } = usePlatformOS(); + + useEffect(() => { + const element = issueRef.current; + const dragHandleElement = dragHandleRef.current; + + if (!element || !dragHandleElement) return; + + return combine( + draggable({ + element, + dragHandle: dragHandleElement, + canDrag: () => canDrag, + getInitialData: () => ({ id: issueId, type: "ISSUE", groupId }), + onDragStart: () => { + setIsCurrentBlockDragging(true); + }, + onDrop: () => { + setIsCurrentBlockDragging(false); + }, + }) + ); + }, [issueRef?.current, canDrag, issueId, groupId, dragHandleRef?.current, setIsCurrentBlockDragging]); + if (!issue) return null; const canEditIssueProperties = canEditProperties(issue.project_id); @@ -84,13 +118,14 @@ export const IssueBlock: React.FC = observer((props: IssueBlock return (
@@ -98,11 +133,11 @@ export const IssueBlock: React.FC = observer((props: IssueBlock
- -
+ +
{subIssuesCount > 0 && ( )} = observer((props) => { className="relative flex-shrink-0 text-left" > - m.key === peekMode)?.icon ?? ""} /> + m.key === peekMode)?.icon ?? ""} className="text-[1rem]" /> = observer((props) => { {(peekMode === "side" || peekMode === "modal") && (
)} diff --git a/space/components/issues/peek-overview/issue-reaction.tsx b/space/components/issues/peek-overview/issue-reaction.tsx index 28090d36aa..9911253122 100644 --- a/space/components/issues/peek-overview/issue-reaction.tsx +++ b/space/components/issues/peek-overview/issue-reaction.tsx @@ -19,7 +19,6 @@ export const IssueReactions: React.FC = () => {
-
)} {canReact && ( diff --git a/space/components/issues/peek-overview/issue-vote-reactions.tsx b/space/components/issues/peek-overview/issue-vote-reactions.tsx index 073938daad..388d590723 100644 --- a/space/components/issues/peek-overview/issue-vote-reactions.tsx +++ b/space/components/issues/peek-overview/issue-vote-reactions.tsx @@ -97,7 +97,7 @@ export const IssueVotes: React.FC = observer((props) => { if (user) handleVote(e, 1); else router.push(`/?next_path=${pathName}?${queryParam}`); }} - className={`flex items-center justify-center gap-x-1 overflow-hidden rounded border px-2 focus:outline-none ${ + className={`flex items-center justify-center gap-x-1 overflow-hidden rounded border px-2 h-7 focus:outline-none ${ isUpVotedByUser ? "border-custom-primary-200 text-custom-primary-200" : "border-custom-border-300" }`} > @@ -131,7 +131,7 @@ export const IssueVotes: React.FC = observer((props) => { if (user) handleVote(e, -1); else router.push(`/?next_path=${pathName}?${queryParam}`); }} - className={`flex items-center justify-center gap-x-1 overflow-hidden rounded border px-2 focus:outline-none ${ + className={`flex items-center justify-center gap-x-1 h-7 overflow-hidden rounded border px-2 focus:outline-none ${ isDownVotedByUser ? "border-red-600 text-red-600" : "border-custom-border-300" }`} > diff --git a/web/components/account/oauth/github-button.tsx b/web/components/account/oauth/github-button.tsx index be9b59f249..6d274c125f 100644 --- a/web/components/account/oauth/github-button.tsx +++ b/web/components/account/oauth/github-button.tsx @@ -25,8 +25,8 @@ export const GithubOAuthButton: FC = (props) => { return (