From 8c5f6932143d1676380c5eeea392afded36e3fc5 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:41:56 +0530 Subject: [PATCH 1/9] regression: focus changing issue with the peek overview editor (#4700) --- packages/editor/core/src/hooks/use-editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index 76071791b..563cb5122 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -112,7 +112,7 @@ export const useEditor = ({ if (value === null || value === undefined) return; if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) { try { - editor.commands.setContent(value); + editor.commands.setContent(value, false, { preserveWhitespace: "full" }); const currentSavedSelection = savedSelectionRef.current; if (currentSavedSelection) { const docLength = editor.state.doc.content.size; From 453459d271136818e96a76180a5706f4bc75e6a8 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Wed, 5 Jun 2024 12:47:16 +0530 Subject: [PATCH 2/9] [WEB-1459] chore: save users all / favorite project list collapse state into localstorage. (#4701) --- web/components/project/sidebar-list.tsx | 192 ++++++++++++++---------- 1 file changed, 111 insertions(+), 81 deletions(-) diff --git a/web/components/project/sidebar-list.tsx b/web/components/project/sidebar-list.tsx index b6eb0c708..698869f98 100644 --- a/web/components/project/sidebar-list.tsx +++ b/web/components/project/sidebar-list.tsx @@ -5,22 +5,30 @@ import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { ChevronDown, ChevronRight, Plus } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; +// types import { IProject } from "@plane/types"; -// hooks +// ui import { TOAST_TYPE, setToast } from "@plane/ui"; +// components import { CreateProjectModal, ProjectSidebarListItem } from "@/components/project"; +// constants import { EUserWorkspaceRoles } from "@/constants/workspace"; +// helpers import { cn } from "@/helpers/common.helper"; import { orderJoinedProjects } from "@/helpers/project.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks import { useAppTheme, useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store"; -// ui -// components -// helpers -// constants export const ProjectSidebarList: FC = observer(() => { + // get local storage data for isFavoriteProjectsListOpen and isAllProjectsListOpen + const isFavProjectsListOpenInLocalStorage = localStorage.getItem("isFavoriteProjectsListOpen"); + const isAllProjectsListOpenInLocalStorage = localStorage.getItem("isAllProjectsListOpen"); // states + const [isFavoriteProjectsListOpen, setIsFavoriteProjectsListOpen] = useState( + isFavProjectsListOpenInLocalStorage === "true" + ); + const [isAllProjectsListOpen, setIsAllProjectsListOpen] = useState(isAllProjectsListOpenInLocalStorage === "true"); const [isFavoriteProjectCreate, setIsFavoriteProjectCreate] = useState(false); const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isScrolled, setIsScrolled] = useState(false); // scroll animation state @@ -122,6 +130,16 @@ export const ProjectSidebarList: FC = observer(() => { ); }, [containerRef]); + const toggleListDisclosure = (isOpen: boolean, type: "all" | "favorite") => { + if (type === "all") { + setIsAllProjectsListOpen(isOpen); + localStorage.setItem("isAllProjectsListOpen", isOpen.toString()); + } else { + setIsFavoriteProjectsListOpen(isOpen); + localStorage.setItem("isFavoriteProjectsListOpen", isOpen.toString()); + } + }; + return ( <> {workspaceSlug && ( @@ -147,42 +165,48 @@ export const ProjectSidebarList: FC = observer(() => { >
{favoriteProjects && favoriteProjects.length > 0 && ( - - {({ open }) => ( - <> - {!isCollapsed && ( -
- - Favorites - {open ? : } - - {isAuthorizedUser && ( - + + <> + {!isCollapsed && ( +
+ toggleListDisclosure(!isFavoriteProjectsListOpen, "favorite")} + > + Favorites + {isFavoriteProjectsListOpen ? ( + + ) : ( + )} -
- )} - - + + {isAuthorizedUser && ( + + )} +
+ )} + + {isFavoriteProjectsListOpen && ( + {favoriteProjects.map((projectId, index) => ( { /> ))} - - - )} + )} + +
)}
{joinedProjects && joinedProjects.length > 0 && ( - - {({ open }) => ( - <> - {!isCollapsed && ( -
- - Your projects - {open ? : } - - {isAuthorizedUser && ( - + + <> + {!isCollapsed && ( +
+ toggleListDisclosure(!isAllProjectsListOpen, "all")} + > + Your projects + {isAllProjectsListOpen ? ( + + ) : ( + )} -
- )} - - + + {isAuthorizedUser && ( + + )} +
+ )} + + {isAllProjectsListOpen && ( + {joinedProjects.map((projectId, index) => ( { /> ))} - - - )} + )} + +
)}
From 93a22034bd61a934bd9ea1157d2505fd03d14143 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:48:50 +0530 Subject: [PATCH 3/9] [WEB-1501] chore: update selected entity details on entities list change (#4702) * chore: update selected entity detials on entities list change * chore: addd selectionHelpers as a prop --- .../gantt-chart/chart/main-content.tsx | 2 +- .../issues/bulk-operations/root.tsx | 2 ++ .../issues/issue-layouts/list/default.tsx | 2 +- .../spreadsheet/spreadsheet-view.tsx | 2 +- web/hooks/use-multiple-select.ts | 19 +++++++++++++++---- web/store/multiple_select.store.ts | 16 +++++++++++++++- 6 files changed, 35 insertions(+), 8 deletions(-) diff --git a/web/components/gantt-chart/chart/main-content.tsx b/web/components/gantt-chart/chart/main-content.tsx index e3b972237..e5bd7afbf 100644 --- a/web/components/gantt-chart/chart/main-content.tsx +++ b/web/components/gantt-chart/chart/main-content.tsx @@ -162,7 +162,7 @@ export const GanttChartMainContent: React.FC = observer((props) => { )} - + )} diff --git a/web/components/issues/bulk-operations/root.tsx b/web/components/issues/bulk-operations/root.tsx index 957f18609..f92e02279 100644 --- a/web/components/issues/bulk-operations/root.tsx +++ b/web/components/issues/bulk-operations/root.tsx @@ -3,9 +3,11 @@ import { observer } from "mobx-react"; import { BulkOperationsUpgradeBanner } from "@/components/issues"; // hooks import { useMultipleSelectStore } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; type Props = { className?: string; + selectionHelpers: TSelectionHelper; }; export const IssueBulkOperationsRoot: React.FC = observer((props) => { diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index c527276ef..daddc572f 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -170,7 +170,7 @@ const GroupByList: React.FC = observer((props) => { ) )} - + )} diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index c4b6e7d69..8287ea746 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -107,7 +107,7 @@ export const SpreadsheetView: React.FC = observer((props) => { )} - + )} diff --git a/web/hooks/use-multiple-select.ts b/web/hooks/use-multiple-select.ts index 9dcc0e17c..47f673624 100644 --- a/web/hooks/use-multiple-select.ts +++ b/web/hooks/use-multiple-select.ts @@ -33,6 +33,7 @@ export const useMultipleSelect = (props: Props) => { const router = useRouter(); // store hooks const { + selectedEntityIds, updateSelectedEntityDetails, bulkUpdateSelectedEntityDetails, getActiveEntityDetails, @@ -45,6 +46,7 @@ export const useMultipleSelect = (props: Props) => { clearSelection, getIsEntitySelected, getIsEntityActive, + getEntityDetailsFromEntityID, } = useMultipleSelectStore(); const groups = useMemo(() => Object.keys(entities), [entities]); @@ -248,10 +250,6 @@ export const useMultipleSelect = (props: Props) => { (groupID: string) => { const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID); const groupSelectionStatus = isGroupSelected(groupID); - // groupEntities.map((entity) => { - // console.log("group click"); - // handleEntitySelection(entity, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove"); - // }); handleEntitySelection(groupEntities, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove"); }, [entitiesList, handleEntitySelection, isGroupSelected] @@ -346,6 +344,19 @@ export const useMultipleSelect = (props: Props) => { }; }, [clearSelection, router.events]); + // when entities list change, remove entityIds from the selected entities array, which are not present in the new list + useEffect(() => { + selectedEntityIds.map((entityID) => { + const isEntityPresent = entitiesList.find((en) => en.entityID === entityID); + if (!isEntityPresent) { + const entityDetails = getEntityDetailsFromEntityID(entityID); + if (entityDetails) { + handleEntitySelection(entityDetails); + } + } + }); + }, [entitiesList, getEntityDetailsFromEntityID, handleEntitySelection, selectedEntityIds]); + /** * @description helper functions for selection */ diff --git a/web/store/multiple_select.store.ts b/web/store/multiple_select.store.ts index 14750f31a..c573cec5b 100644 --- a/web/store/multiple_select.store.ts +++ b/web/store/multiple_select.store.ts @@ -19,6 +19,7 @@ export type IMultipleSelectStore = { getPreviousActiveEntity: () => TEntityDetails | null; getNextActiveEntity: () => TEntityDetails | null; getActiveEntityDetails: () => TEntityDetails | null; + getEntityDetailsFromEntityID: (entityID: string) => TEntityDetails | null; // entity actions updateSelectedEntityDetails: (entityDetails: TEntityDetails, action: "add" | "remove") => void; bulkUpdateSelectedEntityDetails: (entitiesList: TEntityDetails[], action: "add" | "remove") => void; @@ -119,6 +120,16 @@ export class MultipleSelectStore implements IMultipleSelectStore { */ getActiveEntityDetails = computedFn(() => this.activeEntityDetails); + /** + * @description get the entity details from entityID + * @param {string} entityID + * @returns {TEntityDetails | null} + */ + getEntityDetailsFromEntityID = computedFn( + (entityID: string): TEntityDetails | null => + this.selectedEntityDetails.find((en) => en.entityID === entityID) ?? null + ); + // entity actions /** * @description add or remove entities @@ -159,8 +170,11 @@ export class MultipleSelectStore implements IMultipleSelectStore { if (entitiesList.length > 0) this.updateLastSelectedEntityDetails(entitiesList[entitiesList.length - 1]); }); } else { + const newEntities = differenceWith(this.selectedEntityDetails, entitiesList, (obj1, obj2) => + isEqual(obj1.entityID, obj2.entityID) + ); runInAction(() => { - this.selectedEntityDetails = differenceWith(this.selectedEntityDetails, entitiesList, isEqual); + this.selectedEntityDetails = newEntities; }); } }; From 52d8d6e7ce57aabf552959158c920ffd1c5d875b Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Wed, 5 Jun 2024 14:03:49 +0530 Subject: [PATCH 4/9] [WEB-1517] chore: remove drag handle from list drag block (#4698) * remove drag handle from list drag block * align list group header with list item * rearrange chevron for list subissues and rearrange spaces * adding default draggable property to control link * remove unnecessary dependencies for useEffect --- packages/ui/src/control-link/control-link.tsx | 13 ++++- .../issues/issue-layouts/list/block.tsx | 52 ++++++++++--------- .../list/headers/group-by-card.tsx | 4 +- .../issues/issue-layouts/list/list-group.tsx | 2 +- .../list/quick-add-issue-form.tsx | 2 +- 5 files changed, 42 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/control-link/control-link.tsx b/packages/ui/src/control-link/control-link.tsx index df1958476..83f3157cc 100644 --- a/packages/ui/src/control-link/control-link.tsx +++ b/packages/ui/src/control-link/control-link.tsx @@ -7,10 +7,11 @@ export type TControlLink = React.AnchorHTMLAttributes & { target?: string; disabled?: boolean; className?: string; + draggable?: boolean; }; export const ControlLink = React.forwardRef((props, ref) => { - const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props; + const { href, onClick, children, target = "_self", disabled = false, className, draggable = false, ...rest } = props; const LEFT_CLICK_EVENT_CODE = 0; const handleOnClick = (event: React.MouseEvent) => { @@ -33,7 +34,15 @@ export const ControlLink = React.forwardRef((pr if (disabled) return <>{children}; return ( - + {children} ); diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 56d88a730..358c72189 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -6,7 +6,7 @@ import { ChevronRight } from "lucide-react"; // types import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; // ui -import { Spinner, Tooltip, ControlLink, DragHandle } from "@plane/ui"; +import { Spinner, Tooltip, ControlLink, setToast, TOAST_TYPE } from "@plane/ui"; // components import { MultipleSelectEntityAction } from "@/components/core"; import { IssueProperties } from "@/components/issues/issue-layouts/properties"; @@ -57,7 +57,6 @@ export const IssueBlock = observer((props: IssueBlockProps) => { } = props; // ref const issueRef = useRef(null); - const dragHandleRef = useRef(null); // hooks const { workspaceSlug, projectId } = useAppRouter(); const { getProjectIdentifierById } = useProject(); @@ -78,14 +77,12 @@ export const IssueBlock = observer((props: IssueBlockProps) => { useEffect(() => { const element = issueRef.current; - const dragHandleElement = dragHandleRef.current; - if (!element || !dragHandleElement) return; + if (!element) return; return combine( draggable({ element, - dragHandle: dragHandleElement, canDrag: () => canDrag, getInitialData: () => ({ id: issueId, type: "ISSUE", groupId }), onDragStart: () => { @@ -96,7 +93,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => { }, }) ); - }, [issueRef?.current, canDrag, issueId, groupId, dragHandleRef?.current, setIsCurrentBlockDragging]); + }, [canDrag, issueId, groupId, setIsCurrentBlockDragging]); if (!issue) return null; @@ -135,20 +132,19 @@ export const IssueBlock = observer((props: IssueBlockProps) => { "bg-custom-background-80": isCurrentBlockDragging, } )} + onDragStart={() => { + if (!canDrag) { + setToast({ + type: TOAST_TYPE.WARNING, + title: "Cannot move issue", + message: "Drag and drop is disabled for the current grouping", + }); + } + }} >
-
-
- {/* drag handle */} -
- -
+
+
{/* select checkbox */} {projectId && canEditIssueProperties && ( {
)} + {displayProperties && displayProperties?.key && ( +
+ {projectIdentifier}-{issue.sequence_id} +
+ )} + {/* sub-issues chevron */} -
+
{subIssuesCount > 0 && ( )}
- {displayProperties && displayProperties?.key && ( -
- {projectIdentifier}-{issue.sequence_id} -
- )} {issue?.tempId !== undefined && (
@@ -206,7 +203,12 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
{issue?.is_draft ? ( - +

{issue.name}

) : ( diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index feb99a8a5..cfda91613 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -83,7 +83,7 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => { return ( <> -
+
{canSelectIssues && (
{ />
)} -
+
{icon ?? }
diff --git a/web/components/issues/issue-layouts/list/list-group.tsx b/web/components/issues/issue-layouts/list/list-group.tsx index 04457327e..64cbf807b 100644 --- a/web/components/issues/issue-layouts/list/list-group.tsx +++ b/web/components/issues/issue-layouts/list/list-group.tsx @@ -193,7 +193,7 @@ export const ListGroup = observer((props: Props) => { "border-custom-error-200": isDraggingOverColumn && !!group.isDropDisabled, })} > -
+
= observer((props
) : (
setIsOpen(true)} > From 249e71e424e9ccb0baee6ac2dc98d0f0a85b10ca Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 5 Jun 2024 14:20:57 +0530 Subject: [PATCH 5/9] fix: email validation (#4707) * fix: email validation on complete login or sign up functionality * dev: add try catch block * dev: split up code * dev: empty return --- .../plane/authentication/adapter/base.py | 181 ++++++++++++------ .../plane/authentication/adapter/error.py | 2 + .../plane/authentication/adapter/oauth.py | 10 +- 3 files changed, 129 insertions(+), 64 deletions(-) diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index 7b899e63c..5876e934f 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -4,6 +4,8 @@ import uuid # Django imports from django.utils import timezone +from django.core.validators import validate_email +from django.core.exceptions import ValidationError # Third party imports from zxcvbn import zxcvbn @@ -46,68 +48,71 @@ class Adapter: def authenticate(self): raise NotImplementedError - def complete_login_or_signup(self): - email = self.user_data.get("email") - user = User.objects.filter(email=email).first() - # Check if sign up case or login - is_signup = bool(user) - if not user: - # New user - (ENABLE_SIGNUP,) = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP", "1"), - }, - ] - ) - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], - error_message="SIGNUP_DISABLED", - payload={"email": email}, - ) - user = User(email=email, username=uuid.uuid4().hex) - - if self.user_data.get("user").get("is_password_autoset"): - user.set_password(uuid.uuid4().hex) - user.is_password_autoset = True - user.is_email_verified = True - else: - # Validate password - results = zxcvbn(self.code) - if results["score"] < 3: - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_PASSWORD" - ], - error_message="INVALID_PASSWORD", - payload={"email": email}, - ) - - user.set_password(self.code) - user.is_password_autoset = False - - avatar = self.user_data.get("user", {}).get("avatar", "") - first_name = self.user_data.get("user", {}).get("first_name", "") - last_name = self.user_data.get("user", {}).get("last_name", "") - user.avatar = avatar if avatar else "" - user.first_name = first_name if first_name else "" - user.last_name = last_name if last_name else "" - user.save() - Profile.objects.create(user=user) - - if not user.is_active: + def sanitize_email(self, email): + # Check if email is present + if not email: raise AuthenticationException( - AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], - error_message="USER_ACCOUNT_DEACTIVATED", + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + payload={"email": email}, ) + # Sanitize email + email = str(email).lower().strip() + + # validate email + try: + validate_email(email) + except ValidationError: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + payload={"email": email}, + ) + # Return email + return email + + def validate_password(self, email): + """Validate password strength""" + results = zxcvbn(self.code) + if results["score"] < 3: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + payload={"email": email}, + ) + return + + def __check_signup(self, email): + """Check if sign up is enabled or not and raise exception if not enabled""" + + # Get configuration value + (ENABLE_SIGNUP,) = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP", "1"), + }, + ] + ) + + # Check if sign up is disabled and invite is present or not + if ( + ENABLE_SIGNUP == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + # Raise exception + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], + error_message="SIGNUP_DISABLED", + payload={"email": email}, + ) + + return True + + def save_user_data(self, user): # Update user details user.last_login_medium = self.provider user.last_active = timezone.now() @@ -116,7 +121,63 @@ class Adapter: user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT") user.token_updated_at = timezone.now() user.save() + return user + def complete_login_or_signup(self): + # Get email + email = self.user_data.get("email") + + # Sanitize email + email = self.sanitize_email(email) + + # Check if the user is present + user = User.objects.filter(email=email).first() + # Check if sign up case or login + is_signup = bool(user) + # If user is not present, create a new user + if not user: + # New user + self.__check_signup(email) + + # Initialize user + user = User(email=email, username=uuid.uuid4().hex) + + # Check if password is autoset + if self.user_data.get("user").get("is_password_autoset"): + user.set_password(uuid.uuid4().hex) + user.is_password_autoset = True + user.is_email_verified = True + + # Validate password + else: + # Validate password + self.validate_password(email) + # Set password + user.set_password(self.code) + user.is_password_autoset = False + + # Set user details + avatar = self.user_data.get("user", {}).get("avatar", "") + first_name = self.user_data.get("user", {}).get("first_name", "") + last_name = self.user_data.get("user", {}).get("last_name", "") + user.avatar = avatar if avatar else "" + user.first_name = first_name if first_name else "" + user.last_name = last_name if last_name else "" + user.save() + + # Create profile + Profile.objects.create(user=user) + + if not user.is_active: + raise AuthenticationException( + AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + + # Save user data + user = self.save_user_data(user=user) + + # Call callback if present if self.callback: self.callback( user, @@ -124,7 +185,9 @@ class Adapter: self.request, ) + # Create or update account if token data is present if self.token_data: self.create_update_account(user=user) + # Return user return user diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 7b12db945..55ff10988 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -58,6 +58,8 @@ AUTHENTICATION_ERROR_CODES = { "ADMIN_USER_DEACTIVATED": 5190, # Rate limit "RATE_LIMIT_EXCEEDED": 5900, + # Unknown + "AUTHENTICATION_FAILED": 5999, } diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py index a917c002a..b1a92e79e 100644 --- a/apiserver/plane/authentication/adapter/oauth.py +++ b/apiserver/plane/authentication/adapter/oauth.py @@ -81,11 +81,11 @@ class OauthAdapter(Adapter): response.raise_for_status() return response.json() except requests.RequestException: - code = ( - "GOOGLE_OAUTH_PROVIDER_ERROR" - if self.provider == "google" - else "GITHUB_OAUTH_PROVIDER_ERROR" - ) + if self.provider == "google": + code = "GOOGLE_OAUTH_PROVIDER_ERROR" + if self.provider == "github": + code = "GITHUB_OAUTH_PROVIDER_ERROR" + raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code), From 911832d5465717f1c2a3386cbced0f24fc05d8ba Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 5 Jun 2024 15:17:43 +0530 Subject: [PATCH 6/9] fix: cache invalidation on new members invite (#4699) --- .../authentication/utils/workspace_project_join.py | 11 +++++++++++ apiserver/plane/utils/cache.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apiserver/plane/authentication/utils/workspace_project_join.py b/apiserver/plane/authentication/utils/workspace_project_join.py index 8910ec637..3b6f231ed 100644 --- a/apiserver/plane/authentication/utils/workspace_project_join.py +++ b/apiserver/plane/authentication/utils/workspace_project_join.py @@ -4,6 +4,7 @@ from plane.db.models import ( WorkspaceMember, WorkspaceMemberInvite, ) +from plane.utils.cache import invalidate_cache_directly def process_workspace_project_invitations(user): @@ -26,6 +27,16 @@ def process_workspace_project_invitations(user): ignore_conflicts=True, ) + [ + invalidate_cache_directly( + path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/", + url_params=False, + user=False, + multiple=True, + ) + for workspace_member_invite in workspace_member_invites + ] + # Check if user has any project invites project_member_invites = ProjectMemberInvite.objects.filter( email=user.email, accepted=True diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py index 071051129..bda942899 100644 --- a/apiserver/plane/utils/cache.py +++ b/apiserver/plane/utils/cache.py @@ -66,7 +66,7 @@ def invalidate_cache_directly( custom_path = path if path is not None else request.get_full_path() auth_header = ( None - if request.user.is_anonymous + if request and request.user.is_anonymous else str(request.user.id) if user else None ) key = generate_cache_key(custom_path, auth_header) From 272428b05efd20b83b22257207ac81b2c113d5a6 Mon Sep 17 00:00:00 2001 From: Henit Chobisa Date: Wed, 5 Jun 2024 15:56:36 +0530 Subject: [PATCH 7/9] fix: build test pull request running on non draft PRs (#4708) --- .github/workflows/build-test-pull-request.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index 5b94b215a..2e6f9c642 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -3,10 +3,11 @@ name: Build and Lint on Pull Request on: workflow_dispatch: pull_request: - types: ["opened", "synchronize"] + types: ["opened", "synchronize", "ready_for_review"] jobs: get-changed-files: + if: github.event.pull_request.draft == false runs-on: ubuntu-latest outputs: apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }} From 30fdc1015ceacd62f5038da30fb3c1c042870d1c Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 5 Jun 2024 15:17:43 +0530 Subject: [PATCH 8/9] fix: cache invalidation on new members invite (#4699) --- .../authentication/utils/workspace_project_join.py | 11 +++++++++++ apiserver/plane/utils/cache.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apiserver/plane/authentication/utils/workspace_project_join.py b/apiserver/plane/authentication/utils/workspace_project_join.py index 8910ec637..3b6f231ed 100644 --- a/apiserver/plane/authentication/utils/workspace_project_join.py +++ b/apiserver/plane/authentication/utils/workspace_project_join.py @@ -4,6 +4,7 @@ from plane.db.models import ( WorkspaceMember, WorkspaceMemberInvite, ) +from plane.utils.cache import invalidate_cache_directly def process_workspace_project_invitations(user): @@ -26,6 +27,16 @@ def process_workspace_project_invitations(user): ignore_conflicts=True, ) + [ + invalidate_cache_directly( + path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/", + url_params=False, + user=False, + multiple=True, + ) + for workspace_member_invite in workspace_member_invites + ] + # Check if user has any project invites project_member_invites = ProjectMemberInvite.objects.filter( email=user.email, accepted=True diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py index 071051129..bda942899 100644 --- a/apiserver/plane/utils/cache.py +++ b/apiserver/plane/utils/cache.py @@ -66,7 +66,7 @@ def invalidate_cache_directly( custom_path = path if path is not None else request.get_full_path() auth_header = ( None - if request.user.is_anonymous + if request and request.user.is_anonymous else str(request.user.id) if user else None ) key = generate_cache_key(custom_path, auth_header) From 452e8f39ffc74f51fdc5f41089a4075d2098926c Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Wed, 5 Jun 2024 17:47:48 +0530 Subject: [PATCH 9/9] chore: squashed migration (#4634) * chore: squashed migration * chore: removed instance migraion * chore: key changes * chore: issue activity back migration * dev: replaced estimate key with estimate id and replaced estimate type from number to string in issue * chore: estimate point value field * chore: estimate point activity * chore: removed the unused function * chore: resolved merge conflicts * chore: deploy board keys changed * chore: yarn lock file change * chore: resolved frontend build --------- Co-authored-by: guru_sainath --- apiserver/plane/api/views/project.py | 4 +- apiserver/plane/app/serializers/__init__.py | 2 +- apiserver/plane/app/serializers/project.py | 6 +- apiserver/plane/app/urls/project.py | 6 +- apiserver/plane/app/views/__init__.py | 2 +- apiserver/plane/app/views/project/base.py | 24 +- .../plane/bgtasks/issue_activites_task.py | 25 +- .../db/migrations/0067_issue_estimate.py | 260 ++++++++++++++++++ apiserver/plane/db/models/__init__.py | 2 +- apiserver/plane/db/models/deploy_board.py | 53 ++++ apiserver/plane/db/models/estimate.py | 5 +- apiserver/plane/db/models/issue.py | 9 +- apiserver/plane/db/models/project.py | 2 + apiserver/plane/space/views/inbox.py | 14 +- apiserver/plane/space/views/issue.py | 34 +-- apiserver/plane/space/views/project.py | 10 +- packages/types/src/issues/issue.d.ts | 2 +- web/components/core/activity.tsx | 20 +- web/components/dropdowns/estimate.tsx | 10 +- .../activity/actions/estimate.tsx | 16 +- .../issues/issue-detail/sidebar.tsx | 2 +- .../properties/all-properties.tsx | 2 +- web/components/issues/issue-layouts/utils.tsx | 4 +- web/store/estimate.store.ts | 6 +- .../issue/issue-details/activity.store.ts | 16 +- 25 files changed, 424 insertions(+), 112 deletions(-) create mode 100644 apiserver/plane/db/migrations/0067_issue_estimate.py create mode 100644 apiserver/plane/db/models/deploy_board.py diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 019ab704e..408e14fed 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -22,7 +22,7 @@ from plane.db.models import ( IssueProperty, Module, Project, - ProjectDeployBoard, + DeployBoard, ProjectMember, State, Workspace, @@ -99,7 +99,7 @@ class ProjectAPIEndpoint(BaseAPIView): ) .annotate( is_deployed=Exists( - ProjectDeployBoard.objects.filter( + DeployBoard.objects.filter( project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ) diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index bdcdf6c0d..d8364f931 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -30,7 +30,7 @@ from .project import ( ProjectIdentifierSerializer, ProjectLiteSerializer, ProjectMemberLiteSerializer, - ProjectDeployBoardSerializer, + DeployBoardSerializer, ProjectMemberAdminSerializer, ProjectPublicMemberSerializer, ProjectMemberRoleSerializer, diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 96d92f340..d9ea99f1e 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -13,7 +13,7 @@ from plane.db.models import ( ProjectMember, ProjectMemberInvite, ProjectIdentifier, - ProjectDeployBoard, + DeployBoard, ProjectPublicMember, ) @@ -206,14 +206,14 @@ class ProjectMemberLiteSerializer(BaseSerializer): read_only_fields = fields -class ProjectDeployBoardSerializer(BaseSerializer): +class DeployBoardSerializer(BaseSerializer): project_details = ProjectLiteSerializer(read_only=True, source="project") workspace_detail = WorkspaceLiteSerializer( read_only=True, source="workspace" ) class Meta: - model = ProjectDeployBoard + model = DeployBoard fields = "__all__" read_only_fields = [ "workspace", diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index 7ea636df8..d9c6f0f81 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -12,7 +12,7 @@ from plane.app.views import ( ProjectFavoritesViewSet, UserProjectInvitationsViewset, ProjectPublicCoverImagesEndpoint, - ProjectDeployBoardViewSet, + DeployBoardViewSet, UserProjectRolesEndpoint, ProjectArchiveUnarchiveEndpoint, ) @@ -157,7 +157,7 @@ urlpatterns = [ ), path( "workspaces//projects//project-deploy-boards/", - ProjectDeployBoardViewSet.as_view( + DeployBoardViewSet.as_view( { "get": "list", "post": "create", @@ -167,7 +167,7 @@ urlpatterns = [ ), path( "workspaces//projects//project-deploy-boards//", - ProjectDeployBoardViewSet.as_view( + DeployBoardViewSet.as_view( { "get": "retrieve", "patch": "partial_update", diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 0c489593d..592d897e0 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -4,7 +4,7 @@ from .project.base import ( ProjectUserViewsEndpoint, ProjectFavoritesViewSet, ProjectPublicCoverImagesEndpoint, - ProjectDeployBoardViewSet, + DeployBoardViewSet, ProjectArchiveUnarchiveEndpoint, ) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 39db11871..a62d6d6dd 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -28,7 +28,7 @@ from plane.app.views.base import BaseViewSet, BaseAPIView from plane.app.serializers import ( ProjectSerializer, ProjectListSerializer, - ProjectDeployBoardSerializer, + DeployBoardSerializer, ) from plane.app.permissions import ( @@ -46,7 +46,7 @@ from plane.db.models import ( Module, Cycle, Inbox, - ProjectDeployBoard, + DeployBoard, IssueProperty, Issue, ) @@ -138,7 +138,7 @@ class ProjectViewSet(BaseViewSet): ) .annotate( is_deployed=Exists( - ProjectDeployBoard.objects.filter( + DeployBoard.objects.filter( project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ) @@ -639,12 +639,12 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): return Response(files, status=status.HTTP_200_OK) -class ProjectDeployBoardViewSet(BaseViewSet): +class DeployBoardViewSet(BaseViewSet): permission_classes = [ ProjectMemberPermission, ] - serializer_class = ProjectDeployBoardSerializer - model = ProjectDeployBoard + serializer_class = DeployBoardSerializer + model = DeployBoard def get_queryset(self): return ( @@ -673,17 +673,17 @@ class ProjectDeployBoardViewSet(BaseViewSet): }, ) - project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create( + project_deploy_board, _ = DeployBoard.objects.get_or_create( anchor=f"{slug}/{project_id}", project_id=project_id, ) - project_deploy_board.comments = comments - project_deploy_board.reactions = reactions project_deploy_board.inbox = inbox - project_deploy_board.votes = votes - project_deploy_board.views = views + project_deploy_board.view_props = views + project_deploy_board.is_votes_enabled = votes + project_deploy_board.is_comments_enabled = comments + project_deploy_board.is_reactions_enabled = reactions project_deploy_board.save() - serializer = ProjectDeployBoardSerializer(project_deploy_board) + serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 007b3e48c..67cda14af 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -28,6 +28,7 @@ from plane.db.models import ( Project, State, User, + EstimatePoint, ) from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception @@ -448,21 +449,37 @@ def track_estimate_points( if current_instance.get("estimate_point") != requested_data.get( "estimate_point" ): + old_estimate = ( + EstimatePoint.objects.filter( + pk=current_instance.get("estimate_point") + ).first() + if current_instance.get("estimate_point") is not None + else None + ) + new_estimate = ( + EstimatePoint.objects.filter( + pk=requested_data.get("estimate_point") + ).first() + if requested_data.get("estimate_point") is not None + else None + ) issue_activities.append( IssueActivity( issue_id=issue_id, actor_id=actor_id, verb="updated", - old_value=( + old_identifier=( current_instance.get("estimate_point") if current_instance.get("estimate_point") is not None - else "" + else None ), - new_value=( + new_identifier=( requested_data.get("estimate_point") if requested_data.get("estimate_point") is not None - else "" + else None ), + old_value=old_estimate.value if old_estimate else None, + new_value=new_estimate.value if new_estimate else None, field="estimate_point", project_id=project_id, workspace_id=workspace_id, diff --git a/apiserver/plane/db/migrations/0067_issue_estimate.py b/apiserver/plane/db/migrations/0067_issue_estimate.py new file mode 100644 index 000000000..b341f9864 --- /dev/null +++ b/apiserver/plane/db/migrations/0067_issue_estimate.py @@ -0,0 +1,260 @@ +# # Generated by Django 4.2.7 on 2024-05-24 09:47 +# Python imports +import uuid +from uuid import uuid4 +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +import plane.db.models.deploy_board + + +def issue_estimate_point(apps, schema_editor): + Issue = apps.get_model("db", "Issue") + Project = apps.get_model("db", "Project") + EstimatePoint = apps.get_model("db", "EstimatePoint") + IssueActivity = apps.get_model("db", "IssueActivity") + updated_estimate_point = [] + updated_issue_activity = [] + + # loop through all the projects + for project in Project.objects.filter(estimate__isnull=False): + estimate_points = EstimatePoint.objects.filter( + estimate=project.estimate, project=project + ) + + for issue_activity in IssueActivity.objects.filter( + field="estimate_point", project=project + ): + if issue_activity.new_value: + new_identifier = estimate_points.filter( + key=issue_activity.new_value + ).first().id + issue_activity.new_identifier = new_identifier + new_value = estimate_points.filter( + key=issue_activity.new_value + ).first().value + issue_activity.new_value = new_value + + if issue_activity.old_value: + old_identifier = estimate_points.filter( + key=issue_activity.old_value + ).first().id + issue_activity.old_identifier = old_identifier + old_value = estimate_points.filter( + key=issue_activity.old_value + ).first().value + issue_activity.old_value = old_value + updated_issue_activity.append(issue_activity) + + for issue in Issue.objects.filter( + point__isnull=False, project=project + ): + # get the estimate id for the corresponding estimate point in the issue + estimate = estimate_points.filter(key=issue.point).first() + issue.estimate_point = estimate + updated_estimate_point.append(issue) + + Issue.objects.bulk_update( + updated_estimate_point, ["estimate_point"], batch_size=1000 + ) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["new_value", "old_value", "new_identifier", "old_identifier"], + batch_size=1000, + ) + + +def last_used_estimate(apps, schema_editor): + Project = apps.get_model("db", "Project") + Estimate = apps.get_model("db", "Estimate") + + # Get all estimate ids used in projects + estimate_ids = Project.objects.filter(estimate__isnull=False).values_list( + "estimate", flat=True + ) + + # Update all matching estimates + Estimate.objects.filter(id__in=estimate_ids).update(last_used=True) + + +def populate_deploy_board(apps, schema_editor): + DeployBoard = apps.get_model("db", "DeployBoard") + ProjectDeployBoard = apps.get_model("db", "ProjectDeployBoard") + + DeployBoard.objects.bulk_create( + [ + DeployBoard( + entity_identifier=deploy_board.project_id, + project_id=deploy_board.project_id, + entity_name="project", + anchor=uuid4().hex, + is_comments_enabled=deploy_board.comments, + is_reactions_enabled=deploy_board.reactions, + inbox=deploy_board.inbox, + is_votes_enabled=deploy_board.votes, + view_props=deploy_board.views, + workspace_id=deploy_board.workspace_id, + created_at=deploy_board.created_at, + updated_at=deploy_board.updated_at, + created_by_id=deploy_board.created_by_id, + updated_by_id=deploy_board.updated_by_id, + ) + for deploy_board in ProjectDeployBoard.objects.all() + ], + batch_size=100, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0066_account_id_token_cycle_logo_props_module_logo_props"), + ] + + operations = [ + migrations.CreateModel( + name="DeployBoard", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("entity_identifier", models.UUIDField(null=True)), + ( + "entity_name", + models.CharField( + choices=[ + ("project", "Project"), + ("issue", "Issue"), + ("module", "Module"), + ("cycle", "Task"), + ("page", "Page"), + ("view", "View"), + ], + max_length=30, + ), + ), + ( + "anchor", + models.CharField( + db_index=True, + default=plane.db.models.deploy_board.get_anchor, + max_length=255, + unique=True, + ), + ), + ("is_comments_enabled", models.BooleanField(default=False)), + ("is_reactions_enabled", models.BooleanField(default=False)), + ("is_votes_enabled", models.BooleanField(default=False)), + ("view_props", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "inbox", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="board_inbox", + to="db.inbox", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Deploy Board", + "verbose_name_plural": "Deploy Boards", + "db_table": "deploy_boards", + "ordering": ("-created_at",), + "unique_together": {("entity_name", "entity_identifier")}, + }, + ), + migrations.AddField( + model_name="estimate", + name="last_used", + field=models.BooleanField(default=False), + ), + # Rename the existing field + migrations.RenameField( + model_name="issue", + old_name="estimate_point", + new_name="point", + ), + # Add a new field with the original name as a foreign key + migrations.AddField( + model_name="issue", + name="estimate_point", + field=models.ForeignKey( + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_estimates", + to="db.EstimatePoint", + blank=True, + null=True, + ), + ), + migrations.AlterField( + model_name="estimate", + name="type", + field=models.CharField(default="categories", max_length=255), + ), + migrations.AlterField( + model_name="estimatepoint", + name="value", + field=models.CharField(max_length=255), + ), + migrations.RunPython(issue_estimate_point), + migrations.RunPython(last_used_estimate), + migrations.RunPython(populate_deploy_board), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index b11ce7aa3..36718d515 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -4,6 +4,7 @@ from .asset import FileAsset from .base import BaseModel from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties from .dashboard import Dashboard, DashboardWidget, Widget +from .deploy_board import DeployBoard from .estimate import Estimate, EstimatePoint from .exporter import ExporterHistory from .importer import Importer @@ -53,7 +54,6 @@ from .page import Page, PageFavorite, PageLabel, PageLog from .project import ( Project, ProjectBaseModel, - ProjectDeployBoard, ProjectFavorite, ProjectIdentifier, ProjectMember, diff --git a/apiserver/plane/db/models/deploy_board.py b/apiserver/plane/db/models/deploy_board.py new file mode 100644 index 000000000..41ffbc7c1 --- /dev/null +++ b/apiserver/plane/db/models/deploy_board.py @@ -0,0 +1,53 @@ +# Python imports +from uuid import uuid4 + +# Django imports +from django.db import models + +# Module imports +from .workspace import WorkspaceBaseModel + + +def get_anchor(): + return uuid4().hex + + +class DeployBoard(WorkspaceBaseModel): + TYPE_CHOICES = ( + ("project", "Project"), + ("issue", "Issue"), + ("module", "Module"), + ("cycle", "Task"), + ("page", "Page"), + ("view", "View"), + ) + + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField( + max_length=30, + choices=TYPE_CHOICES, + ) + anchor = models.CharField( + max_length=255, default=get_anchor, unique=True, db_index=True + ) + is_comments_enabled = models.BooleanField(default=False) + is_reactions_enabled = models.BooleanField(default=False) + inbox = models.ForeignKey( + "db.Inbox", + related_name="board_inbox", + on_delete=models.SET_NULL, + null=True, + ) + is_votes_enabled = models.BooleanField(default=False) + view_props = models.JSONField(default=dict) + + def __str__(self): + """Return name of the deploy board""" + return f"{self.entity_identifier} <{self.entity_name}>" + + class Meta: + unique_together = ["entity_name", "entity_identifier"] + verbose_name = "Deploy Board" + verbose_name_plural = "Deploy Boards" + db_table = "deploy_boards" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py index 6ff1186c3..0713d774f 100644 --- a/apiserver/plane/db/models/estimate.py +++ b/apiserver/plane/db/models/estimate.py @@ -11,7 +11,8 @@ class Estimate(ProjectBaseModel): description = models.TextField( verbose_name="Estimate Description", blank=True ) - type = models.CharField(max_length=255, default="Categories") + type = models.CharField(max_length=255, default="categories") + last_used = models.BooleanField(default=False) def __str__(self): """Return name of the estimate""" @@ -35,7 +36,7 @@ class EstimatePoint(ProjectBaseModel): default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] ) description = models.TextField(blank=True) - value = models.CharField(max_length=20) + value = models.CharField(max_length=255) def __str__(self): """Return name of the estimate""" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 527597ddc..2b07bd77b 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -119,11 +119,18 @@ class Issue(ProjectBaseModel): blank=True, related_name="state_issue", ) - estimate_point = models.IntegerField( + point = models.IntegerField( validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True, ) + estimate_point = models.ForeignKey( + "db.EstimatePoint", + on_delete=models.SET_NULL, + related_name="issue_estimates", + null=True, + blank=True, + ) name = models.CharField(max_length=255, verbose_name="Issue Name") description = models.JSONField(blank=True, default=dict) description_html = models.TextField(blank=True, default="

") diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 49fca1323..ba8dbf580 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -260,6 +260,8 @@ def get_default_views(): } +# DEPRECATED TODO: +# used to get the old anchors for the project deploy boards class ProjectDeployBoard(ProjectBaseModel): anchor = models.CharField( max_length=255, default=get_anchor, unique=True, db_index=True diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/inbox.py index 9f681c160..d15e7aa39 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/inbox.py @@ -18,7 +18,7 @@ from plane.db.models import ( State, IssueLink, IssueAttachment, - ProjectDeployBoard, + DeployBoard, ) from plane.app.serializers import ( IssueSerializer, @@ -39,7 +39,7 @@ class InboxIssuePublicViewSet(BaseViewSet): ] def get_queryset(self): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -59,7 +59,7 @@ class InboxIssuePublicViewSet(BaseViewSet): return InboxIssue.objects.none() def list(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if project_deploy_board.inbox is None: @@ -118,7 +118,7 @@ class InboxIssuePublicViewSet(BaseViewSet): ) def create(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if project_deploy_board.inbox is None: @@ -189,7 +189,7 @@ class InboxIssuePublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if project_deploy_board.inbox is None: @@ -256,7 +256,7 @@ class InboxIssuePublicViewSet(BaseViewSet): ) def retrieve(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if project_deploy_board.inbox is None: @@ -280,7 +280,7 @@ class InboxIssuePublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def destroy(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if project_deploy_board.inbox is None: diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index 8c4d6e150..7ffdf0911 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -44,7 +44,7 @@ from plane.db.models import ( ProjectMember, IssueReaction, CommentReaction, - ProjectDeployBoard, + DeployBoard, IssueVote, ProjectPublicMember, ) @@ -76,7 +76,7 @@ class IssueCommentPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -103,11 +103,11 @@ class IssueCommentPublicViewSet(BaseViewSet): .distinct() ).order_by("created_at") return IssueComment.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueComment.objects.none() def create(self, request, slug, project_id, issue_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -151,7 +151,7 @@ class IssueCommentPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, issue_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -184,7 +184,7 @@ class IssueCommentPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, issue_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -221,7 +221,7 @@ class IssueReactionPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -236,11 +236,11 @@ class IssueReactionPublicViewSet(BaseViewSet): .distinct() ) return IssueReaction.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueReaction.objects.none() def create(self, request, slug, project_id, issue_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -280,7 +280,7 @@ class IssueReactionPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, issue_id, reaction_code): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -319,7 +319,7 @@ class CommentReactionPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -334,11 +334,11 @@ class CommentReactionPublicViewSet(BaseViewSet): .distinct() ) return CommentReaction.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return CommentReaction.objects.none() def create(self, request, slug, project_id, comment_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -380,7 +380,7 @@ class CommentReactionPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, comment_id, reaction_code): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if not project_deploy_board.reactions: @@ -421,7 +421,7 @@ class IssueVotePublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -434,7 +434,7 @@ class IssueVotePublicViewSet(BaseViewSet): .filter(project_id=self.kwargs.get("project_id")) ) return IssueVote.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueVote.objects.none() def create(self, request, slug, project_id, issue_id): @@ -513,7 +513,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): ] def get(self, request, slug, project_id): - if not ProjectDeployBoard.objects.filter( + if not DeployBoard.objects.filter( workspace__slug=slug, project_id=project_id ).exists(): return Response( diff --git a/apiserver/plane/space/views/project.py b/apiserver/plane/space/views/project.py index 10a3c3879..2cace08da 100644 --- a/apiserver/plane/space/views/project.py +++ b/apiserver/plane/space/views/project.py @@ -11,10 +11,10 @@ from rest_framework.permissions import AllowAny # Module imports from .base import BaseAPIView -from plane.app.serializers import ProjectDeployBoardSerializer +from plane.app.serializers import DeployBoardSerializer from plane.db.models import ( Project, - ProjectDeployBoard, + DeployBoard, ) @@ -24,10 +24,10 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): ] def get(self, request, slug, project_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) - serializer = ProjectDeployBoardSerializer(project_deploy_board) + serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) @@ -41,7 +41,7 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): Project.objects.filter(workspace__slug=slug) .annotate( is_public=Exists( - ProjectDeployBoard.objects.filter( + DeployBoard.objects.filter( workspace__slug=slug, project_id=OuterRef("pk") ) ) diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 42c95dc4e..990b308e7 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -15,7 +15,7 @@ export type TIssue = { priority: TIssuePriorities; label_ids: string[]; assignee_ids: string[]; - estimate_point: number | null; + estimate_point: string | null; sub_issues_count: number; attachment_count: number; diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index 28d84ffe4..5def2d7a9 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -24,7 +24,7 @@ import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; import { capitalizeFirstLetter } from "@/helpers/string.helper"; -import { useEstimate, useLabel } from "@/hooks/store"; +import { useLabel } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types @@ -97,22 +97,6 @@ const LabelPill = observer(({ labelId, workspaceSlug }: { labelId: string; works ); }); -const EstimatePoint = observer((props: { point: string }) => { - const { point } = props; - const { areEstimatesEnabledForCurrentProject, getEstimatePointValue } = useEstimate(); - const currentPoint = Number(point) + 1; - - const estimateValue = getEstimatePointValue(Number(point), null); - - return ( - - {areEstimatesEnabledForCurrentProject - ? estimateValue - : `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`} - - ); -}); - const inboxActivityMessage = { declined: { showIssue: "declined issue", @@ -267,7 +251,7 @@ const activityDetails: { else return ( <> - set the estimate point to + set the estimate point to {activity.new_value} {showIssue && ( <> {" "} diff --git a/web/components/dropdowns/estimate.tsx b/web/components/dropdowns/estimate.tsx index 58243cc22..ce646f893 100644 --- a/web/components/dropdowns/estimate.tsx +++ b/web/components/dropdowns/estimate.tsx @@ -19,15 +19,15 @@ type Props = TDropdownProps & { button?: ReactNode; dropdownArrow?: boolean; dropdownArrowClassName?: string; - onChange: (val: number | null) => void; + onChange: (val: string | null) => void; onClose?: () => void; projectId: string; - value: number | null; + value: string | null; }; type DropdownOptions = | { - value: number | null; + value: string | null; query: string; content: JSX.Element; }[] @@ -80,7 +80,7 @@ export const EstimateDropdown: React.FC = observer((props) => { const activeEstimate = getProjectActiveEstimateDetails(projectId); const options: DropdownOptions = sortBy(activeEstimate?.points ?? [], "key")?.map((point) => ({ - value: point.key, + value: point.id, query: `${point?.value}`, content: (
@@ -120,7 +120,7 @@ export const EstimateDropdown: React.FC = observer((props) => { setQuery, }); - const dropdownOnChange = (val: number | null) => { + const dropdownOnChange = (val: string | null) => { onChange(val); handleClose(); }; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx index 9179bfa38..ef6736546 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { Triangle } from "lucide-react"; // hooks -import { useEstimate, useIssueDetail } from "@/hooks/store"; +import { useIssueDetail } from "@/hooks/store"; // components import { IssueActivityBlockComponent, IssueLink } from "./"; @@ -14,15 +14,11 @@ export const IssueEstimateActivity: FC = observer((props const { activity: { getActivityById }, } = useIssueDetail(); - const { areEstimatesEnabledForCurrentProject, getEstimatePointValue } = useEstimate(); const activity = getActivityById(activityId); if (!activity) return <>; - const estimateValue = getEstimatePointValue(Number(activity.new_value), null); - const currentPoint = Number(activity.new_value) + 1; - return (
issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })} projectId={projectId} disabled={!isEditable} diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index 22f14833e..3db467a16 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -220,7 +220,7 @@ export const IssueProperties: React.FC = observer((props) => { ); }; - const handleEstimate = (value: number | null) => { + const handleEstimate = (value: string | null) => { updateIssue && updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => { captureIssueEvent({ diff --git a/web/components/issues/issue-layouts/utils.tsx b/web/components/issues/issue-layouts/utils.tsx index 78048b4b4..9d18426b4 100644 --- a/web/components/issues/issue-layouts/utils.tsx +++ b/web/components/issues/issue-layouts/utils.tsx @@ -495,7 +495,7 @@ export const handleGroupDragDrop = async ( // update updatedIssue values based on the source and destination groupIds if (source.groupId && destination.groupId && source.groupId !== destination.groupId) { const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy]; - let groupValue = clone(sourceIssue[groupKey]); + let groupValue: any = clone(sourceIssue[groupKey]); // If groupValues is an array, remove source groupId and add destination groupId if (Array.isArray(groupValue)) { @@ -515,7 +515,7 @@ export const handleGroupDragDrop = async ( // update updatedIssue values based on the source and destination subGroupIds if (subGroupBy && source.subGroupId && destination.subGroupId && source.subGroupId !== destination.subGroupId) { const subGroupKey = ISSUE_FILTER_DEFAULT_DATA[subGroupBy]; - let subGroupValue = clone(sourceIssue[subGroupKey]); + let subGroupValue: any = clone(sourceIssue[subGroupKey]); // If subGroupValue is an array, remove source subGroupId and add destination subGroupId if (Array.isArray(subGroupValue)) { diff --git a/web/store/estimate.store.ts b/web/store/estimate.store.ts index 0bb35a23b..df31cd53c 100644 --- a/web/store/estimate.store.ts +++ b/web/store/estimate.store.ts @@ -19,7 +19,7 @@ export interface IEstimateStore { activeEstimateDetails: IEstimate | null; // computed actions areEstimatesEnabledForProject: (projectId: string) => boolean; - getEstimatePointValue: (estimateKey: number | null, projectId: string | null) => string; + getEstimatePointValue: (estimateKey: string | null, projectId: string | null) => string; getProjectEstimateById: (estimateId: string) => IEstimate | null; getProjectActiveEstimateDetails: (projectId: string) => IEstimate | null; // fetch actions @@ -110,10 +110,10 @@ export class EstimateStore implements IEstimateStore { /** * @description returns the point value for the given estimate key to display in the UI */ - getEstimatePointValue = computedFn((estimateKey: number | null, projectId: string | null) => { + getEstimatePointValue = computedFn((estimateKey: string | null, projectId: string | null) => { if (estimateKey === null) return "None"; const activeEstimate = projectId ? this.getProjectActiveEstimateDetails(projectId) : this.activeEstimateDetails; - return activeEstimate?.points?.find((point) => point.key === estimateKey)?.value || "None"; + return activeEstimate?.points?.find((point) => point.id === estimateKey)?.value || "None"; }); /** diff --git a/web/store/issue/issue-details/activity.store.ts b/web/store/issue/issue-details/activity.store.ts index 562f8ef5b..dd4fe10aa 100644 --- a/web/store/issue/issue-details/activity.store.ts +++ b/web/store/issue/issue-details/activity.store.ts @@ -4,10 +4,10 @@ import sortBy from "lodash/sortBy"; import uniq from "lodash/uniq"; import update from "lodash/update"; import { action, makeObservable, observable, runInAction } from "mobx"; +import { TIssueActivityComment, TIssueActivity, TIssueActivityMap, TIssueActivityIdMap } from "@plane/types"; // services import { IssueActivityService } from "@/services/issue"; // types -import { TIssueActivityComment, TIssueActivity, TIssueActivityMap, TIssueActivityIdMap } from "@plane/types"; import { IIssueDetail } from "./root.store"; export type TActivityLoader = "fetch" | "mutate" | undefined; @@ -117,10 +117,10 @@ export class IssueActivityStore implements IIssueActivityStore { this.loader = loaderType; let props = {}; - const _activityIds = this.getActivitiesByIssueId(issueId); - if (_activityIds && _activityIds.length > 0) { - const _activity = this.getActivityById(_activityIds[_activityIds.length - 1]); - if (_activity) props = { created_at__gt: _activity.created_at }; + const currentActivityIds = this.getActivitiesByIssueId(issueId); + if (currentActivityIds && currentActivityIds.length > 0) { + const currentActivity = this.getActivityById(currentActivityIds[currentActivityIds.length - 1]); + if (currentActivity) props = { created_at__gt: currentActivity.created_at }; } const activities = await this.issueActivityService.getIssueActivities(workspaceSlug, projectId, issueId, props); @@ -128,9 +128,9 @@ export class IssueActivityStore implements IIssueActivityStore { const activityIds = activities.map((activity) => activity.id); runInAction(() => { - update(this.activities, issueId, (_activityIds) => { - if (!_activityIds) return activityIds; - return uniq(concat(_activityIds, activityIds)); + update(this.activities, issueId, (currentActivityIds) => { + if (!currentActivityIds) return activityIds; + return uniq(concat(currentActivityIds, activityIds)); }); activities.forEach((activity) => { set(this.activityMap, activity.id, activity);