diff --git a/apiserver/plane/app/views/project.py b/apiserver/plane/app/views/project.py index a21d71c57..5b88e3652 100644 --- a/apiserver/plane/app/views/project.py +++ b/apiserver/plane/app/views/project.py @@ -1010,11 +1010,18 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): def get(self, request): files = [] - s3 = boto3.client( - "s3", - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) + s3_client_params = { + "service_name": "s3", + "aws_access_key_id": settings.AWS_ACCESS_KEY_ID, + "aws_secret_access_key": settings.AWS_SECRET_ACCESS_KEY, + } + + # Use AWS_S3_ENDPOINT_URL if it is present in the settings + if hasattr(settings, "AWS_S3_ENDPOINT_URL") and settings.AWS_S3_ENDPOINT_URL: + s3_client_params["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL + + s3 = boto3.client(**s3_client_params) + params = { "Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Prefix": "static/project-cover/", @@ -1027,9 +1034,19 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): if not content["Key"].endswith( "/" ): # This line ensures we're only getting files, not "sub-folders" - files.append( - f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" - ) + if ( + hasattr(settings, "AWS_S3_CUSTOM_DOMAIN") + and settings.AWS_S3_CUSTOM_DOMAIN + and hasattr(settings, "AWS_S3_URL_PROTOCOL") + and settings.AWS_S3_URL_PROTOCOL + ): + files.append( + f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/{content['Key']}" + ) + else: + files.append( + f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" + ) return Response(files, status=status.HTTP_200_OK) diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 6832297e9..0e7a18fa8 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -30,7 +30,7 @@ openpyxl==3.1.2 beautifulsoup4==4.12.2 dj-database-url==2.1.0 posthog==3.0.2 -cryptography==41.0.5 +cryptography==41.0.6 lxml==4.9.3 boto3==1.28.40 diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 645e99cb8..15150aa40 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -39,7 +39,7 @@ function download(){ echo "" echo "Latest version is now available for you to use" echo "" - echo "In case of Upgrade, your new setting file is availabe as 'variables-upgrade.env'. Please compare and set the required values in '.env 'file." + echo "In case of Upgrade, your new setting file is available as 'variables-upgrade.env'. Please compare and set the required values in '.env 'file." echo "" } diff --git a/web/components/common/new-empty-state.tsx b/web/components/common/new-empty-state.tsx index 058a3988f..508d2e5da 100644 --- a/web/components/common/new-empty-state.tsx +++ b/web/components/common/new-empty-state.tsx @@ -19,7 +19,7 @@ type Props = { icon?: any; text: string; onClick: () => void; - }; + } | null; disabled?: boolean; }; diff --git a/web/components/instance/email-form.tsx b/web/components/instance/email-form.tsx index 60e6c7bf6..82da7553e 100644 --- a/web/components/instance/email-form.tsx +++ b/web/components/instance/email-form.tsx @@ -21,6 +21,7 @@ export interface EmailFormValues { EMAIL_HOST_PASSWORD: string; EMAIL_USE_TLS: string; // EMAIL_USE_SSL: string; + EMAIL_FROM: string; } export const InstanceEmailForm: FC = (props) => { @@ -45,6 +46,7 @@ export const InstanceEmailForm: FC = (props) => { EMAIL_HOST_PASSWORD: config["EMAIL_HOST_PASSWORD"], EMAIL_USE_TLS: config["EMAIL_USE_TLS"], // EMAIL_USE_SSL: config["EMAIL_USE_SSL"], + EMAIL_FROM: config["EMAIL_FROM"], }, }); @@ -168,6 +170,31 @@ export const InstanceEmailForm: FC = (props) => { +
+
+

From address

+ ( + + )} + /> +

+ You will have to verify your email address to being sending emails. +

+
+
diff --git a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx index 1d70e2289..b080bc838 100644 --- a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -120,8 +120,8 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { workspaceSlug={workspaceSlug.toString()} projectId={peekProjectId.toString()} issueId={peekIssueId.toString()} - handleIssue={async (issueToUpdate) => - await handleIssues(issueToUpdate.target_date ?? "", issueToUpdate as IIssue, EIssueActions.UPDATE) + handleIssue={async (issueToUpdate, action: EIssueActions) => + await handleIssues(issueToUpdate.target_date ?? "", issueToUpdate as IIssue, action) } /> )} diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index c2532b802..f8eead33f 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -36,13 +36,17 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { const menuActionRef = useRef(null); - const handleIssuePeekOverview = (issue: IIssue) => { + const handleIssuePeekOverview = (issue: IIssue, event: React.MouseEvent) => { const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, - }); + if (event.ctrlKey || event.metaKey) { + const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`; + window.open(issueUrl, "_blank"); // Open link in a new tab + } else { + router.push({ + pathname: router.pathname, + query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, + }); + } }; useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); @@ -75,7 +79,7 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { {...provided.draggableProps} {...provided.dragHandleProps} ref={provided.innerRef} - onClick={() => handleIssuePeekOverview(issue)} + onClick={(e) => handleIssuePeekOverview(issue, e)} > {issue?.tempId !== undefined && (
diff --git a/web/components/issues/issue-layouts/empty-states/project.tsx b/web/components/issues/issue-layouts/empty-states/project.tsx index 27c11dbbf..7db04b36a 100644 --- a/web/components/issues/issue-layouts/empty-states/project.tsx +++ b/web/components/issues/issue-layouts/empty-states/project.tsx @@ -31,14 +31,18 @@ export const ProjectEmptyState: React.FC = observer(() => { description: "Redesign the Plane UI, Rebrand the company, or Launch the new fuel injection system are examples of issues that likely have sub-issues.", }} - primaryButton={{ - text: "Create your first issue", - icon: , - onClick: () => { - setTrackElement("PROJECT_EMPTY_STATE"); - commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT); - }, - }} + primaryButton={ + isEditingAllowed + ? { + text: "Create your first issue", + icon: , + onClick: () => { + setTrackElement("PROJECT_EMPTY_STATE"); + commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT); + }, + } + : null + } disabled={!isEditingAllowed} />
diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index 62df8fc79..15b851661 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // mobx store @@ -25,6 +25,8 @@ import { IViewIssuesStore, } from "store/issues"; import { TUnGroupedIssues } from "store/issues/types"; +import { EIssueActions } from "../types"; +// constants import { EUserWorkspaceRoles } from "constants/workspace"; interface IBaseGanttRoot { @@ -35,10 +37,15 @@ interface IBaseGanttRoot { | IViewIssuesFilterStore; issueStore: IProjectIssuesStore | IModuleIssuesStore | ICycleIssuesStore | IViewIssuesStore; viewId?: string; + issueActions: { + [EIssueActions.DELETE]: (issue: IIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: IIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: IIssue) => Promise; + }; } export const BaseGanttRoot: React.FC = observer((props: IBaseGanttRoot) => { - const { issueFiltersStore, issueStore, viewId } = props; + const { issueFiltersStore, issueStore, viewId, issueActions } = props; const router = useRouter(); const { workspaceSlug, peekIssueId, peekProjectId } = router.query; @@ -64,11 +71,14 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan await issueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, payload, viewId); }; - const updateIssue = async (projectId: string, issueId: string, payload: Partial) => { - if (!workspaceSlug) return; - - await issueStore.updateIssue(workspaceSlug.toString(), projectId, issueId, payload, viewId); - }; + const handleIssues = useCallback( + async (issue: IIssue, action: EIssueActions) => { + if (issueActions[action]) { + await issueActions[action]!(issue); + } + }, + [issueActions] + ); const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; @@ -102,8 +112,8 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan workspaceSlug={workspaceSlug.toString()} projectId={peekProjectId.toString()} issueId={peekIssueId.toString()} - handleIssue={async (issueToUpdate) => { - await updateIssue(peekProjectId.toString(), peekIssueId.toString(), issueToUpdate); + handleIssue={async (issueToUpdate, action) => { + await handleIssues(issueToUpdate as IIssue, action); }} /> )} diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index 916dceedf..41085978a 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -9,13 +9,17 @@ import { IIssue } from "types"; export const IssueGanttBlock = ({ data }: { data: IIssue }) => { const router = useRouter(); - const handleIssuePeekOverview = () => { + const handleIssuePeekOverview = (event: React.MouseEvent) => { const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: data?.id, peekProjectId: data?.project }, - }); + if (event.ctrlKey || event.metaKey) { + const issueUrl = `/${data?.workspace_detail.slug}/projects/${data?.project_detail.id}/issues/${data?.id}`; + window.open(issueUrl, "_blank"); // Open link in a new tab + } else { + router.push({ + pathname: router.pathname, + query: { ...query, peekIssueId: data?.id, peekProjectId: data?.project }, + }); + } }; return ( diff --git a/web/components/issues/issue-layouts/gantt/cycle-root.tsx b/web/components/issues/issue-layouts/gantt/cycle-root.tsx index 536650694..e09092fec 100644 --- a/web/components/issues/issue-layouts/gantt/cycle-root.tsx +++ b/web/components/issues/issue-layouts/gantt/cycle-root.tsx @@ -4,15 +4,43 @@ import { useMobxStore } from "lib/mobx/store-provider"; // components import { BaseGanttRoot } from "./base-gantt-root"; import { useRouter } from "next/router"; +// types +import { EIssueActions } from "../types"; +import { IIssue } from "types"; export const CycleGanttLayout: React.FC = observer(() => { const router = useRouter(); - const { cycleId } = router.query; + const { cycleId, workspaceSlug } = router.query; const { cycleIssues: cycleIssueStore, cycleIssuesFilter: cycleIssueFilterStore } = useMobxStore(); + const issueActions = { + [EIssueActions.UPDATE]: async (issue: IIssue) => { + if (!workspaceSlug || !cycleId) return; + + await cycleIssueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue, cycleId.toString()); + }, + [EIssueActions.DELETE]: async (issue: IIssue) => { + if (!workspaceSlug || !cycleId) return; + + await cycleIssueStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id, cycleId.toString()); + }, + [EIssueActions.REMOVE]: async (issue: IIssue) => { + if (!workspaceSlug || !cycleId || !issue.bridge_id) return; + + await cycleIssueStore.removeIssueFromCycle( + workspaceSlug.toString(), + issue.project, + cycleId.toString(), + issue.id, + issue.bridge_id + ); + }, + }; + return ( { const router = useRouter(); - const { moduleId } = router.query; + const { moduleId, workspaceSlug } = router.query; const { moduleIssues: moduleIssueStore, moduleIssuesFilter: moduleIssueFilterStore } = useMobxStore(); + const issueActions = { + [EIssueActions.UPDATE]: async (issue: IIssue) => { + if (!workspaceSlug || !moduleId) return; + + await moduleIssueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue, moduleId.toString()); + }, + [EIssueActions.DELETE]: async (issue: IIssue) => { + if (!workspaceSlug || !moduleId) return; + + await moduleIssueStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id, moduleId.toString()); + }, + [EIssueActions.REMOVE]: async (issue: IIssue) => { + if (!workspaceSlug || !moduleId || !issue.bridge_id) return; + + await moduleIssueStore.removeIssueFromModule( + workspaceSlug.toString(), + issue.project, + moduleId.toString(), + issue.id, + issue.bridge_id + ); + }, + }; + return ( { const { projectIssues: projectIssuesStore, projectIssuesFilter: projectIssueFiltersStore } = useMobxStore(); + const router = useRouter(); + const { workspaceSlug } = router.query; - return ; + const issueActions = { + [EIssueActions.UPDATE]: async (issue: IIssue) => { + if (!workspaceSlug) return; + + await projectIssuesStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue); + }, + [EIssueActions.DELETE]: async (issue: IIssue) => { + if (!workspaceSlug) return; + + await projectIssuesStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id); + }, + }; + return ( + + ); }); diff --git a/web/components/issues/issue-layouts/gantt/project-view-root.tsx b/web/components/issues/issue-layouts/gantt/project-view-root.tsx index 3155aae6f..c39c32fe8 100644 --- a/web/components/issues/issue-layouts/gantt/project-view-root.tsx +++ b/web/components/issues/issue-layouts/gantt/project-view-root.tsx @@ -1,11 +1,35 @@ import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components import { BaseGanttRoot } from "./base-gantt-root"; +// types +import { EIssueActions } from "../types"; +import { IIssue } from "types"; export const ProjectViewGanttLayout: React.FC = observer(() => { const { viewIssues: projectIssueViewStore, viewIssuesFilter: projectIssueViewFiltersStore } = useMobxStore(); + const router = useRouter(); + const { workspaceSlug } = router.query; - return ; + const issueActions = { + [EIssueActions.UPDATE]: async (issue: IIssue) => { + if (!workspaceSlug) return; + + await projectIssueViewStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue); + }, + [EIssueActions.DELETE]: async (issue: IIssue) => { + if (!workspaceSlug) return; + + await projectIssueViewStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id); + }, + }; + return ( + + ); }); diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 057ba0b87..b536b1fa8 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -346,8 +346,8 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas workspaceSlug={workspaceSlug.toString()} projectId={peekProjectId.toString()} issueId={peekIssueId.toString()} - handleIssue={async (issueToUpdate) => - await handleIssues(sub_group_by, group_by, issueToUpdate as IIssue, EIssueActions.UPDATE) + handleIssue={async (issueToUpdate, action: EIssueActions) => + await handleIssues(sub_group_by, group_by, issueToUpdate as IIssue, action) } /> )} diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 065b409a6..b48698fa7 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { Draggable } from "@hello-pangea/dnd"; +import { Draggable, DraggableStateSnapshot } from "@hello-pangea/dnd"; import isEqual from "lodash/isEqual"; // components import { KanBanProperties } from "./properties"; @@ -32,11 +32,23 @@ interface IssueDetailsBlockProps { quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode; displayProperties: IIssueDisplayProperties | null; isReadOnly: boolean; + snapshot: DraggableStateSnapshot; + isDragDisabled: boolean; } const KanbanIssueDetailsBlock: React.FC = (props) => { - const { sub_group_id, columnId, issue, showEmptyGroup, handleIssues, quickActions, displayProperties, isReadOnly } = - props; + const { + sub_group_id, + columnId, + issue, + showEmptyGroup, + handleIssues, + quickActions, + displayProperties, + isReadOnly, + snapshot, + isDragDisabled, + } = props; const router = useRouter(); @@ -44,20 +56,29 @@ const KanbanIssueDetailsBlock: React.FC = (props) => { if (issueToUpdate) handleIssues(sub_group_by, group_by, issueToUpdate, EIssueActions.UPDATE); }; - const handleIssuePeekOverview = () => { + const handleIssuePeekOverview = (event: React.MouseEvent) => { const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, - }); + if (event.ctrlKey || event.metaKey) { + const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`; + window.open(issueUrl, "_blank"); // Open link in a new tab + } else { + router.push({ + pathname: router.pathname, + query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, + }); + } }; return ( - <> +
{displayProperties && displayProperties?.key && ( -
-
+
+
{issue.project_detail.identifier}-{issue.sequence_id}
@@ -70,9 +91,7 @@ const KanbanIssueDetailsBlock: React.FC = (props) => {
)} -
- {issue.name} -
+
{issue.name}
= (props) => { isReadOnly={isReadOnly} />
- +
); }; @@ -132,22 +151,18 @@ export const KanbanIssueBlock: React.FC = (props) => { {issue.tempId !== undefined && (
)} -
- -
+
)} diff --git a/web/components/issues/issue-layouts/kanban/properties.tsx b/web/components/issues/issue-layouts/kanban/properties.tsx index 788607690..9590c9068 100644 --- a/web/components/issues/issue-layouts/kanban/properties.tsx +++ b/web/components/issues/issue-layouts/kanban/properties.tsx @@ -57,7 +57,7 @@ export const KanBanProperties: React.FC = observer((props) => ); }; - const handleStartDate = (date: string) => { + const handleStartDate = (date: string | null) => { handleIssues( !sub_group_id && sub_group_id === "null" ? null : sub_group_id, !group_id && group_id === "null" ? null : group_id, @@ -65,7 +65,7 @@ export const KanBanProperties: React.FC = observer((props) => ); }; - const handleTargetDate = (date: string) => { + const handleTargetDate = (date: string | null) => { handleIssues( !sub_group_id && sub_group_id === "null" ? null : sub_group_id, !group_id && group_id === "null" ? null : group_id, @@ -122,7 +122,7 @@ export const KanBanProperties: React.FC = observer((props) => {displayProperties && displayProperties?.start_date && ( handleStartDate(date)} + onChange={(date) => handleStartDate(date)} disabled={isReadOnly} type="start_date" /> @@ -132,7 +132,7 @@ export const KanBanProperties: React.FC = observer((props) => {displayProperties && displayProperties?.due_date && ( handleTargetDate(date)} + onChange={(date) => handleTargetDate(date)} disabled={isReadOnly} type="target_date" /> diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index 92956509e..55b2fce55 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -168,7 +168,9 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { workspaceSlug={workspaceSlug.toString()} projectId={peekProjectId.toString()} issueId={peekIssueId.toString()} - handleIssue={async (issueToUpdate) => await handleIssues(issueToUpdate as IIssue, EIssueActions.UPDATE)} + handleIssue={async (issueToUpdate, action: EIssueActions) => + await handleIssues(issueToUpdate as IIssue, action) + } /> )} diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 0667a7fa9..562f599ab 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -25,20 +25,27 @@ export const IssueBlock: React.FC = (props) => { handleIssues(issueToUpdate, EIssueActions.UPDATE); }; - const handleIssuePeekOverview = () => { + const handleIssuePeekOverview = (event: React.MouseEvent) => { const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, - }); + if (event.ctrlKey || event.metaKey) { + const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`; + window.open(issueUrl, "_blank"); // Open link in a new tab + } else { + router.push({ + pathname: router.pathname, + query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, + }); + } }; const canEditIssueProperties = canEditProperties(issue.project); return ( <> -
+ ); }; diff --git a/web/components/issues/issue-layouts/list/properties.tsx b/web/components/issues/issue-layouts/list/properties.tsx index 8b6f54010..eeff3b273 100644 --- a/web/components/issues/issue-layouts/list/properties.tsx +++ b/web/components/issues/issue-layouts/list/properties.tsx @@ -40,11 +40,11 @@ export const ListProperties: FC = observer((props) => { handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, assignees: ids }); }; - const handleStartDate = (date: string) => { + const handleStartDate = (date: string | null) => { handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, start_date: date }); }; - const handleTargetDate = (date: string) => { + const handleTargetDate = (date: string | null) => { handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, target_date: date }); }; @@ -106,7 +106,7 @@ export const ListProperties: FC = observer((props) => { {displayProperties && displayProperties?.start_date && ( handleStartDate(date)} + onChange={(date) => handleStartDate(date)} disabled={isReadonly} type="start_date" /> @@ -116,7 +116,7 @@ export const ListProperties: FC = observer((props) => { {displayProperties && displayProperties?.due_date && ( handleTargetDate(date)} + onChange={(date) => handleTargetDate(date)} disabled={isReadonly} type="target_date" /> diff --git a/web/components/issues/issue-layouts/properties/assignee.tsx b/web/components/issues/issue-layouts/properties/assignee.tsx index acebed498..01dec9b83 100644 --- a/web/components/issues/issue-layouts/properties/assignee.tsx +++ b/web/components/issues/issue-layouts/properties/assignee.tsx @@ -142,7 +142,10 @@ export const IssuePropertyAssignee: React.FC = observer( className={`flex w-full items-center justify-between gap-1 text-xs ${ disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer" } ${buttonClassName}`} - onClick={() => (!projectId || !_members[projectId]) && getProjectMembers()} + onClick={(e) => { + e.stopPropagation(); + (!projectId || !_members[projectId]) && getProjectMembers(); + }} > {label} {!hideDropdownArrow && !disabled &&
diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index 25aaf2f88..7f3204196 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -47,7 +47,7 @@ export const PeekOverviewProperties: FC = observer((pro } = useMobxStore(); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, peekProjectId: projectId } = router.query; const handleState = (_state: string) => { issueUpdate({ ...issue, state: _state }); @@ -116,7 +116,12 @@ export const PeekOverviewProperties: FC = observer((pro

State

- +
@@ -129,6 +134,7 @@ export const PeekOverviewProperties: FC = observer((pro
@@ -210,7 +216,12 @@ export const PeekOverviewProperties: FC = observer((pro

Parent

- +
@@ -226,6 +237,7 @@ export const PeekOverviewProperties: FC = observer((pro
@@ -240,6 +252,7 @@ export const PeekOverviewProperties: FC = observer((pro
@@ -253,6 +266,7 @@ export const PeekOverviewProperties: FC = observer((pro
) => void; + handleIssue: (issue: Partial, action: EIssueActions) => Promise; isArchived?: boolean; children?: ReactNode; } @@ -30,8 +31,6 @@ export const IssuePeekOverview: FC = observer((props) => { const { peekIssueId } = router.query; const { - user: { currentProjectRole }, - issue: { removeIssueFromStructure }, issueDetail: { createIssueComment, updateIssueComment, @@ -58,6 +57,7 @@ export const IssuePeekOverview: FC = observer((props) => { }, archivedIssues: { deleteArchivedIssue }, project: { currentProjectDetails }, + workspaceMember: { currentWorkspaceUserProjectsRole }, } = useMobxStore(); const { setToastAlert } = useToast(); @@ -98,7 +98,7 @@ export const IssuePeekOverview: FC = observer((props) => { const issueUpdate = async (_data: Partial) => { if (handleIssue) { - await handleIssue(_data); + await handleIssue(_data, EIssueActions.UPDATE); fetchIssueActivity(workspaceSlug, projectId, issueId); } }; @@ -133,7 +133,7 @@ export const IssuePeekOverview: FC = observer((props) => { const handleDeleteIssue = async () => { if (isArchived) await deleteArchivedIssue(workspaceSlug, projectId, issue!); - else removeIssueFromStructure(workspaceSlug, projectId, issue!); + else await handleIssue(issue!, EIssueActions.DELETE); const { query } = router; if (query.peekIssueId) { setPeekId(null); @@ -146,7 +146,8 @@ export const IssuePeekOverview: FC = observer((props) => { } }; - const userRole = currentProjectRole ?? EUserWorkspaceRoles.GUEST; + const userRole = + (currentWorkspaceUserProjectsRole && currentWorkspaceUserProjectsRole[projectId]) ?? EUserWorkspaceRoles.GUEST; return ( diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index 8294784f8..1d9122459 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode, useState } from "react"; +import { FC, ReactNode, useRef, useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; @@ -14,6 +14,8 @@ import { PeekOverviewIssueDetails, PeekOverviewProperties, } from "components/issues"; +// hooks +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // ui import { Button, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Spinner } from "@plane/ui"; // types @@ -107,6 +109,8 @@ export const IssueView: FC = observer((props) => { const [peekMode, setPeekMode] = useState("side-peek"); const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); + // ref + const issuePeekOverviewRef = useRef(null); const updateRoutePeekId = () => { if (issueId != peekIssueId) { @@ -151,6 +155,8 @@ export const IssueView: FC = observer((props) => { const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode); + useOutsideClickDetector(issuePeekOverviewRef, () => removeRoutePeekId()); + return ( <> {issue && !isArchived && ( @@ -178,6 +184,7 @@ export const IssueView: FC = observer((props) => { {issueId === peekIssueId && (
= observer((props if (workspaceSlug && projectId) cycleStore.fetchCycles(workspaceSlug, projectId, "all"); }; - const cycles = cycleStore.projectCycles; + const cycles = cycleStore.cycles?.[projectId]?.["all"] ?? []; const selectedCycle = cycles ? cycles?.find((i) => i.id === value) : undefined; diff --git a/web/components/issues/sidebar-select/assignee.tsx b/web/components/issues/sidebar-select/assignee.tsx index 34e3bc06a..dffa46232 100644 --- a/web/components/issues/sidebar-select/assignee.tsx +++ b/web/components/issues/sidebar-select/assignee.tsx @@ -10,6 +10,7 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { value: string[]; + projectId: string; onChange: (val: string[]) => void; disabled?: boolean; }; @@ -17,9 +18,9 @@ type Props = { // services const projectMemberService = new ProjectMemberService(); -export const SidebarAssigneeSelect: React.FC = ({ value, onChange, disabled = false }) => { +export const SidebarAssigneeSelect: React.FC = ({ value, projectId, onChange, disabled = false }) => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; const { data: members } = useSWR( workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, diff --git a/web/components/issues/sidebar-select/cycle.tsx b/web/components/issues/sidebar-select/cycle.tsx index 86c9dcfca..5f5e6c386 100644 --- a/web/components/issues/sidebar-select/cycle.tsx +++ b/web/components/issues/sidebar-select/cycle.tsx @@ -14,6 +14,7 @@ import { CYCLE_ISSUES, INCOMPLETE_CYCLES_LIST, ISSUE_DETAILS } from "constants/f type Props = { issueDetail: IIssue | undefined; + projectId: string; handleCycleChange?: (cycleId: string) => void; disabled?: boolean; handleIssueUpdate?: () => void; @@ -26,7 +27,7 @@ export const SidebarCycleSelect: React.FC = (props) => { const { issueDetail, disabled = false, handleIssueUpdate, handleCycleChange } = props; // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId: _projectId, peekProjectId } = router.query; // mobx store const { cycleIssues: { removeIssueFromCycle, addIssueToCycle }, @@ -34,6 +35,8 @@ export const SidebarCycleSelect: React.FC = (props) => { const [isUpdating, setIsUpdating] = useState(false); + const projectId = _projectId ?? peekProjectId; + const { data: incompleteCycles } = useSWR( workspaceSlug && projectId ? INCOMPLETE_CYCLES_LIST(projectId as string) : null, workspaceSlug && projectId diff --git a/web/components/issues/sidebar-select/label.tsx b/web/components/issues/sidebar-select/label.tsx index b7ef3f48d..ca7abd8be 100644 --- a/web/components/issues/sidebar-select/label.tsx +++ b/web/components/issues/sidebar-select/label.tsx @@ -18,6 +18,7 @@ import { IIssue, IIssueLabel } from "types"; type Props = { issueDetails: IIssue | undefined; + projectId: string; labelList: string[]; submitChanges: (formData: any) => void; isNotAllowed: boolean; @@ -30,12 +31,12 @@ const defaultValues: Partial = { }; export const SidebarLabelSelect: React.FC = observer((props) => { - const { issueDetails, labelList, submitChanges, isNotAllowed, uneditable } = props; + const { issueDetails, projectId, labelList, submitChanges, isNotAllowed, uneditable } = props; // states const [createLabelForm, setCreateLabelForm] = useState(false); // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; // toast const { setToastAlert } = useToast(); // mobx store diff --git a/web/components/issues/sidebar-select/module.tsx b/web/components/issues/sidebar-select/module.tsx index c96799bc6..6b6db072b 100644 --- a/web/components/issues/sidebar-select/module.tsx +++ b/web/components/issues/sidebar-select/module.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { mutate } from "swr"; +import useSWR, { mutate } from "swr"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // ui @@ -9,28 +9,40 @@ import { CustomSearchSelect, DiceIcon, Spinner, Tooltip } from "@plane/ui"; // types import { IIssue } from "types"; // fetch-keys -import { ISSUE_DETAILS, MODULE_ISSUES } from "constants/fetch-keys"; +import { ISSUE_DETAILS, MODULE_ISSUES, MODULE_LIST } from "constants/fetch-keys"; +// services +import { ModuleService } from "services/module.service"; type Props = { issueDetail: IIssue | undefined; + projectId: string; handleModuleChange?: (moduleId: string) => void; disabled?: boolean; handleIssueUpdate?: () => void; }; +// services +const moduleService = new ModuleService(); + export const SidebarModuleSelect: React.FC = observer((props) => { - const { issueDetail, disabled = false, handleIssueUpdate, handleModuleChange } = props; + const { issueDetail, projectId, disabled = false, handleIssueUpdate, handleModuleChange } = props; // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; // mobx store const { - module: { projectModules }, moduleIssues: { removeIssueFromModule, addIssueToModule }, } = useMobxStore(); const [isUpdating, setIsUpdating] = useState(false); + const { data: projectModules } = useSWR( + workspaceSlug && projectId ? MODULE_LIST(projectId as string) : null, + workspaceSlug && projectId + ? () => moduleService.getModules(workspaceSlug as string, projectId as string) + : null + ); + const handleModuleStoreChange = async (moduleId: string) => { if (!workspaceSlug || !issueDetail || !moduleId) return; diff --git a/web/components/issues/sidebar-select/parent.tsx b/web/components/issues/sidebar-select/parent.tsx index cdeb09e90..d0a834190 100644 --- a/web/components/issues/sidebar-select/parent.tsx +++ b/web/components/issues/sidebar-select/parent.tsx @@ -12,15 +12,16 @@ import { IIssue, ISearchIssueResponse } from "types"; type Props = { onChange: (value: string) => void; issueDetails: IIssue | undefined; + projectId: string; disabled?: boolean; }; -export const SidebarParentSelect: React.FC = ({ onChange, issueDetails, disabled = false }) => { +export const SidebarParentSelect: React.FC = ({ onChange, issueDetails, projectId, disabled = false }) => { const [isParentModalOpen, setIsParentModalOpen] = useState(false); const [selectedParentIssue, setSelectedParentIssue] = useState(null); const router = useRouter(); - const { projectId, issueId } = router.query; + const { issueId } = router.query; return ( <> diff --git a/web/components/issues/sidebar-select/state.tsx b/web/components/issues/sidebar-select/state.tsx index 0fb3fe42f..decbf9459 100644 --- a/web/components/issues/sidebar-select/state.tsx +++ b/web/components/issues/sidebar-select/state.tsx @@ -15,6 +15,7 @@ import { STATES_LIST } from "constants/fetch-keys"; type Props = { value: string; + projectId: string; onChange: (val: string) => void; disabled?: boolean; }; @@ -22,9 +23,9 @@ type Props = { // services const stateService = new ProjectStateService(); -export const SidebarStateSelect: React.FC = ({ value, onChange, disabled = false }) => { +export const SidebarStateSelect: React.FC = ({ value, projectId, onChange, disabled = false }) => { const router = useRouter(); - const { workspaceSlug, projectId, inboxIssueId } = router.query; + const { workspaceSlug, inboxIssueId } = router.query; const { data: states } = useSWR( workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/sidebar.tsx index 4cb4d74a1..49c277ae2 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/sidebar.tsx @@ -290,6 +290,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { render={({ field: { value } }) => ( submitChanges({ state: val })} disabled={!isAllowed || uneditable} /> @@ -311,6 +312,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { render={({ field: { value } }) => ( submitChanges({ assignees: val })} disabled={!isAllowed || uneditable} /> @@ -382,6 +384,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { onChange(val); }} issueDetails={issueDetail} + projectId={projectId as string} disabled={!isAllowed || uneditable} /> )} @@ -536,6 +539,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
@@ -551,6 +555,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
@@ -569,6 +574,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
void; handleUpdateIssue: (issue: IIssue, data: Partial) => void; + handleDeleteIssue: (issue: IIssue) => Promise; } export const SubIssues: React.FC = ({ @@ -45,17 +47,22 @@ export const SubIssues: React.FC = ({ copyText, handleIssueCrudOperation, handleUpdateIssue, + handleDeleteIssue, }) => { const router = useRouter(); const { peekProjectId, peekIssueId } = router.query; - const handleIssuePeekOverview = () => { + const handleIssuePeekOverview = (event: React.MouseEvent) => { const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, - }); + if (event.ctrlKey || event.metaKey) { + const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`; + window.open(issueUrl, "_blank"); // Open link in a new tab + } else { + router.push({ + pathname: router.pathname, + query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, + }); + } }; return ( @@ -65,7 +72,13 @@ export const SubIssues: React.FC = ({ workspaceSlug={workspaceSlug} projectId={peekProjectId.toString()} issueId={peekIssueId.toString()} - handleIssue={async (issueToUpdate) => await handleUpdateIssue(issue, { ...issue, ...issueToUpdate })} + handleIssue={async (issueToUpdate, action) => { + if (action === EIssueActions.UPDATE) { + await handleUpdateIssue(issue, { ...issue, ...issueToUpdate }); + } else if (action === EIssueActions.DELETE) { + await handleDeleteIssue(issue); + } + }} /> )}
@@ -176,6 +189,7 @@ export const SubIssues: React.FC = ({ {issuesLoader.visibility.includes(issue?.id) && issue?.sub_issues_count > 0 && ( void; handleUpdateIssue: (issue: IIssue, data: Partial) => void; + handleDeleteIssue: (issue: IIssue) => Promise } const issueService = new IssueService(); @@ -44,6 +45,7 @@ export const SubIssuesRootList: React.FC = ({ copyText, handleIssueCrudOperation, handleUpdateIssue, + handleDeleteIssue }) => { const { data: issues, isLoading } = useSWR( workspaceSlug && projectId && parentIssue && parentIssue?.id ? SUB_ISSUES(parentIssue?.id) : null, @@ -70,6 +72,7 @@ export const SubIssuesRootList: React.FC = ({ issues.sub_issues.length > 0 && issues.sub_issues.map((issue: IIssue) => ( = observer((props) => { [updateIssueStructure, projectId, updateIssue, user, workspaceSlug] ); + const handleDeleteIssue = useCallback( + async (issue: IIssue) => { + if (!workspaceSlug || !projectId || !user) return; + + await removeIssue(workspaceSlug.toString(), projectId.toString(), issue.id); + await mutate(SUB_ISSUES(parentIssue?.id)); + }, + [removeIssue, projectId, user, workspaceSlug, parentIssue?.id] + ); + const isEditable = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const mutateSubIssues = (parentIssueId: string | null) => { @@ -236,6 +246,7 @@ export const SubIssuesRoot: React.FC = observer((props) => { {issuesLoader.visibility.includes(parentIssue?.id) && workspaceSlug && projectId && (
{ description: "A cart module, a chassis module, and a warehouse module are all good example of this grouping.", }} - primaryButton={{ - icon: , - text: "Build your first module", - onClick: () => commandPaletteStore.toggleCreateModuleModal(true), - }} + primaryButton={ + isEditingAllowed + ? { + icon: , + text: "Build your first module", + onClick: () => commandPaletteStore.toggleCreateModuleModal(true), + } + : null + } disabled={!isEditingAllowed} /> )} diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index 35bf88567..3c9552465 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -626,13 +626,15 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { No links added yet
- + {isEditingAllowed && ( + + )}
)}
diff --git a/web/components/page-views/workspace-dashboard.tsx b/web/components/page-views/workspace-dashboard.tsx index 2f300c990..ff378e23d 100644 --- a/web/components/page-views/workspace-dashboard.tsx +++ b/web/components/page-views/workspace-dashboard.tsx @@ -93,13 +93,17 @@ export const WorkspaceDashboardView = observer(() => { direction: "right", description: "A project could be a product’s roadmap, a marketing campaign, or launching a new car.", }} - primaryButton={{ - text: "Build your first project", - onClick: () => { - setTrackElement("DASHBOARD_PAGE"); - commandPaletteStore.toggleCreateProjectModal(true); - }, - }} + primaryButton={ + isEditingAllowed + ? { + text: "Build your first project", + onClick: () => { + setTrackElement("DASHBOARD_PAGE"); + commandPaletteStore.toggleCreateProjectModal(true); + }, + } + : null + } disabled={!isEditingAllowed} /> ) diff --git a/web/components/pages/pages-list/list-view.tsx b/web/components/pages/pages-list/list-view.tsx index 9f94a6671..bb35edfa0 100644 --- a/web/components/pages/pages-list/list-view.tsx +++ b/web/components/pages/pages-list/list-view.tsx @@ -58,11 +58,15 @@ export const PagesListView: FC = observer(({ pages }) => { "We wrote Parth and Meera’s love story. You could write your project’s mission, goals, and eventual vision.", direction: "right", }} - primaryButton={{ - icon: , - text: "Create your first page", - onClick: () => toggleCreatePageModal(true), - }} + primaryButton={ + isEditingAllowed + ? { + icon: , + text: "Create your first page", + onClick: () => toggleCreatePageModal(true), + } + : null + } disabled={!isEditingAllowed} /> )} diff --git a/web/components/pages/pages-list/recent-pages-list.tsx b/web/components/pages/pages-list/recent-pages-list.tsx index 21fb8277f..4648ec1e4 100644 --- a/web/components/pages/pages-list/recent-pages-list.tsx +++ b/web/components/pages/pages-list/recent-pages-list.tsx @@ -66,11 +66,15 @@ export const RecentPagesList: FC = observer(() => { "We wrote Parth and Meera’s love story. You could write your project’s mission, goals, and eventual vision.", direction: "right", }} - primaryButton={{ - icon: , - text: "Create your first page", - onClick: () => commandPaletteStore.toggleCreatePageModal(true), - }} + primaryButton={ + isEditingAllowed + ? { + icon: , + text: "Create your first page", + onClick: () => commandPaletteStore.toggleCreatePageModal(true), + } + : null + } disabled={!isEditingAllowed} /> diff --git a/web/components/project/card-list.tsx b/web/components/project/card-list.tsx index 979ade4ec..76f3112b6 100644 --- a/web/components/project/card-list.tsx +++ b/web/components/project/card-list.tsx @@ -67,13 +67,17 @@ export const ProjectCardList: FC = observer((props) => { direction: "right", description: "A project could be a product’s roadmap, a marketing campaign, or launching a new car.", }} - primaryButton={{ - text: "Start your first project", - onClick: () => { - setTrackElement("PROJECTS_EMPTY_STATE"); - commandPaletteStore.toggleCreateProjectModal(true); - }, - }} + primaryButton={ + isEditingAllowed + ? { + text: "Start your first project", + onClick: () => { + setTrackElement("PROJECTS_EMPTY_STATE"); + commandPaletteStore.toggleCreateProjectModal(true); + }, + } + : null + } disabled={!isEditingAllowed} /> )} diff --git a/web/components/project/priority-select.tsx b/web/components/project/priority-select.tsx index bb069170a..67b6d0d4d 100644 --- a/web/components/project/priority-select.tsx +++ b/web/components/project/priority-select.tsx @@ -93,6 +93,7 @@ export const PrioritySelect: React.FC = ({ className={`flex h-full w-full items-center justify-between gap-1 ${ disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer" } ${buttonClassName}`} + onClick={(e) => e.stopPropagation()} > {label} {!hideDropdownArrow && !disabled &&