diff --git a/admin/next.config.js b/admin/next.config.js index 07f6664af8..2109cec69f 100644 --- a/admin/next.config.js +++ b/admin/next.config.js @@ -1,4 +1,5 @@ /** @type {import('next').NextConfig} */ + const nextConfig = { trailingSlash: true, reactStrictMode: false, diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index c75e9cfee4..10f64fd1cb 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -128,7 +128,7 @@ services: image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} - restart: no + restart: "no" command: ./bin/docker-entrypoint-migrator.sh volumes: - logs_migrator:/code/plane/logs diff --git a/space/.gitignore b/space/.gitignore index a2a963ee7e..a64f113f12 100644 --- a/space/.gitignore +++ b/space/.gitignore @@ -37,3 +37,6 @@ next-env.d.ts # env .env + +# Sentry Config File +.env.sentry-build-plugin diff --git a/space/instrumentation.ts b/space/instrumentation.ts new file mode 100644 index 0000000000..7b89a972e1 --- /dev/null +++ b/space/instrumentation.ts @@ -0,0 +1,9 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} diff --git a/space/next.config.js b/space/next.config.js index eb9dde88a3..d18ce805f4 100644 --- a/space/next.config.js +++ b/space/next.config.js @@ -28,12 +28,46 @@ const nextConfig = { }, }; -if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0", 10)) { - module.exports = withSentryConfig( - nextConfig, - { silent: true, authToken: process.env.SENTRY_AUTH_TOKEN }, - { hideSourceMaps: true } - ); + +const sentryConfig = { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + org: process.env.SENTRY_ORG_ID || "plane-hq", + project: process.env.SENTRY_PROJECT_ID || "plane-space", + authToken: process.env.SENTRY_AUTH_TOKEN, + // Only print logs for uploading source maps in CI + silent: true, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + tunnelRoute: "/monitoring", + + // Hides source maps from generated client bundles + hideSourceMaps: true, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, + + // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, +} + + +if (parseInt(process.env.SENTRY_MONITORING_ENABLED || "0", 10)) { + module.exports = withSentryConfig(nextConfig, sentryConfig); } else { module.exports = nextConfig; } + diff --git a/space/package.json b/space/package.json index a084c143b9..7ba146f27e 100644 --- a/space/package.json +++ b/space/package.json @@ -23,7 +23,7 @@ "@plane/rich-text-editor": "*", "@plane/types": "*", "@plane/ui": "*", - "@sentry/nextjs": "^7.108.0", + "@sentry/nextjs": "^8", "axios": "^1.3.4", "clsx": "^2.0.0", "dompurify": "^3.0.11", diff --git a/space/sentry.client.config.js b/space/sentry.client.config.js deleted file mode 100644 index ca473045b4..0000000000 --- a/space/sentry.client.config.js +++ /dev/null @@ -1,18 +0,0 @@ -// This file configures the initialization of Sentry on the browser. -// The config you add here will be used whenever a page is visited. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from "@sentry/nextjs"; - -const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; - -Sentry.init({ - dsn: SENTRY_DSN, - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1.0, - // ... - // Note: if you want to override the automatic release value, do not set a - // `release` value here - use the environment variable `SENTRY_RELEASE`, so - // that it will also get attached to your source maps -}); diff --git a/space/sentry.client.config.ts b/space/sentry.client.config.ts new file mode 100644 index 0000000000..c810306229 --- /dev/null +++ b/space/sentry.client.config.ts @@ -0,0 +1,31 @@ +// This file configures the initialization of Sentry on the client. +// The config you add here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + replaysOnErrorSampleRate: 1.0, + + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // You can remove this option if you're not planning to use the Sentry Session Replay feature: + integrations: [ + Sentry.replayIntegration({ + // Additional Replay configuration goes in here, for example: + maskAllText: true, + blockAllMedia: true, + }), + ], +}); diff --git a/space/sentry.edge.config.js b/space/sentry.edge.config.js deleted file mode 100644 index 8374ed4101..0000000000 --- a/space/sentry.edge.config.js +++ /dev/null @@ -1,18 +0,0 @@ -// This file configures the initialization of Sentry on the server. -// The config you add here will be used whenever middleware or an Edge route handles a request. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from "@sentry/nextjs"; - -const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; - -Sentry.init({ - dsn: SENTRY_DSN, - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1.0, - // ... - // Note: if you want to override the automatic release value, do not set a - // `release` value here - use the environment variable `SENTRY_RELEASE`, so - // that it will also get attached to your source maps -}); diff --git a/space/sentry.edge.config.ts b/space/sentry.edge.config.ts new file mode 100644 index 0000000000..2dbc6e93af --- /dev/null +++ b/space/sentry.edge.config.ts @@ -0,0 +1,17 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/space/sentry.server.config.js b/space/sentry.server.config.ts similarity index 56% rename from space/sentry.server.config.js rename to space/sentry.server.config.ts index d2acb07e15..e578f1530c 100644 --- a/space/sentry.server.config.js +++ b/space/sentry.server.config.ts @@ -4,15 +4,16 @@ import * as Sentry from "@sentry/nextjs"; -const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; - Sentry.init({ - dsn: SENTRY_DSN, + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", + // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1.0, - // ... - // Note: if you want to override the automatic release value, do not set a - // `release` value here - use the environment variable `SENTRY_RELEASE`, so - // that it will also get attached to your source maps + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + // Uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: process.env.NODE_ENV === 'development', }); diff --git a/turbo.json b/turbo.json index c08733c85c..4fb34d29d8 100644 --- a/turbo.json +++ b/turbo.json @@ -8,9 +8,6 @@ "NEXT_PUBLIC_SPACE_BASE_URL", "NEXT_PUBLIC_SPACE_BASE_PATH", "NEXT_PUBLIC_WEB_BASE_URL", - "NEXT_PUBLIC_SENTRY_DSN", - "NEXT_PUBLIC_SENTRY_ENVIRONMENT", - "NEXT_PUBLIC_ENABLE_SENTRY", "NEXT_PUBLIC_TRACK_EVENTS", "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_CRISP_ID", @@ -21,7 +18,12 @@ "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_DEBUG", "NEXT_PUBLIC_SUPPORT_EMAIL", - "SENTRY_AUTH_TOKEN" + "SENTRY_AUTH_TOKEN", + "SENTRY_ORG_ID", + "SENTRY_PROJECT_ID", + "NEXT_PUBLIC_SENTRY_ENVIRONMENT", + "NEXT_PUBLIC_SENTRY_DSN", + "SENTRY_MONITORING_ENABLED" ], "pipeline": { "build": { diff --git a/web/components/core/list/list-item.tsx b/web/components/core/list/list-item.tsx index ae32c9b317..89b23dbb50 100644 --- a/web/components/core/list/list-item.tsx +++ b/web/components/core/list/list-item.tsx @@ -2,6 +2,8 @@ import React, { FC } from "react"; import Link from "next/link"; // ui import { Tooltip } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; interface IListItemProps { title: string; @@ -12,6 +14,7 @@ interface IListItemProps { actionableItems?: JSX.Element; isMobile?: boolean; parentRef: React.RefObject; + className?: string; } export const ListItem: FC = (props) => { @@ -24,12 +27,18 @@ export const ListItem: FC = (props) => { onItemClick, isMobile = false, parentRef, + className = "", } = props; return (
-
+
diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index db9d94a8fd..0194ba01f1 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -1,5 +1,5 @@ import React from "react"; - +import { observer } from "mobx-react"; import Image from "next/image"; // headless ui import { Tab } from "@headlessui/react"; @@ -15,6 +15,7 @@ import { // hooks import { Avatar, StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "@/components/core"; +import { useProjectState } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; // images import emptyLabel from "public/empty-state/empty_label.svg"; @@ -44,20 +45,23 @@ type Props = { handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; }; -export const SidebarProgressStats: React.FC = ({ - distribution, - groupedIssues, - totalIssues, - module, - roundedTab, - noBackground, - isPeekView = false, - isCompleted = false, - filters, - handleFiltersUpdate, -}) => { +export const SidebarProgressStats: React.FC = observer((props) => { + const { + distribution, + groupedIssues, + totalIssues, + module, + roundedTab, + noBackground, + isPeekView = false, + isCompleted = false, + filters, + handleFiltersUpdate, + } = props; const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); + const { groupedProjectStates } = useProjectState(); + const currentValue = (tab: string | null) => { switch (tab) { case "Assignees": @@ -71,6 +75,12 @@ export const SidebarProgressStats: React.FC = ({ } }; + const getStateGroupState = (stateGroup: string) => { + const stateGroupStates = groupedProjectStates?.[stateGroup]; + const stateGroupStatesId = stateGroupStates?.map((state) => state.id); + return stateGroupStatesId; + }; + return ( = ({ } completed={groupedIssues[group]} total={totalIssues} + {...(!isPeekView && + !isCompleted && { + onClick: () => handleFiltersUpdate("state", getStateGroupState(group) ?? []), + })} /> ))} ); -}; +}); diff --git a/web/components/cycles/active-cycle/productivity.tsx b/web/components/cycles/active-cycle/productivity.tsx index e270b5ad8a..32d17df759 100644 --- a/web/components/cycles/active-cycle/productivity.tsx +++ b/web/components/cycles/active-cycle/productivity.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import Link from "next/link"; // types import { ICycle } from "@plane/types"; // components @@ -8,14 +9,19 @@ import { EmptyState } from "@/components/empty-state"; import { EmptyStateType } from "@/constants/empty-state"; export type ActiveCycleProductivityProps = { + workspaceSlug: string; + projectId: string; cycle: ICycle; }; export const ActiveCycleProductivity: FC = (props) => { - const { cycle } = props; + const { workspaceSlug, projectId, cycle } = props; return ( -
+

Issue burndown

@@ -53,6 +59,6 @@ export const ActiveCycleProductivity: FC = (props)
)} -
+ ); }; diff --git a/web/components/cycles/active-cycle/progress.tsx b/web/components/cycles/active-cycle/progress.tsx index 6aae998bed..fd537148ce 100644 --- a/web/components/cycles/active-cycle/progress.tsx +++ b/web/components/cycles/active-cycle/progress.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import Link from "next/link"; // types import { ICycle } from "@plane/types"; // ui @@ -10,11 +11,13 @@ import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle"; import { EmptyStateType } from "@/constants/empty-state"; export type ActiveCycleProgressProps = { + workspaceSlug: string; + projectId: string; cycle: ICycle; }; export const ActiveCycleProgress: FC = (props) => { - const { cycle } = props; + const { workspaceSlug, projectId, cycle } = props; const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({ id: index, @@ -31,7 +34,10 @@ export const ActiveCycleProgress: FC = (props) => { }; return ( -
+

Progress

@@ -85,6 +91,6 @@ export const ActiveCycleProgress: FC = (props) => {
)} -
+ ); }; diff --git a/web/components/cycles/active-cycle/root.tsx b/web/components/cycles/active-cycle/root.tsx index 625210fd49..8b51a692bf 100644 --- a/web/components/cycles/active-cycle/root.tsx +++ b/web/components/cycles/active-cycle/root.tsx @@ -62,13 +62,18 @@ export const ActiveCycleRoot: React.FC = observer((props) = cycleId={currentProjectActiveCycleId} workspaceSlug={workspaceSlug} projectId={projectId} + className="!border-b-transparent" /> )} -
-
- - - +
+
+ + +
diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index 92c11dd691..b2d9cb8828 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -22,10 +22,11 @@ type TCyclesListItem = { handleRemoveFromFavorites?: () => void; workspaceSlug: string; projectId: string; + className?: string; }; export const CyclesListItem: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId } = props; + const { cycleId, workspaceSlug, projectId, className = "" } = props; // refs const parentRef = useRef(null); // router @@ -83,6 +84,7 @@ export const CyclesListItem: FC = observer((props) => { onItemClick={(e) => { if (cycleDetails.archived_at) openCycleOverview(e); }} + className={className} prependTitleElement={ {isCompleted ? ( diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 2aa3afc48c..b2562aa771 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from "react"; import isEmpty from "lodash/isEmpty"; +import isEqual from "lodash/isEqual"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; @@ -199,14 +200,18 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; + let newValues = issueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); + if (key === "state") { + if (isEqual(newValues, value)) newValues = []; + else newValues = value; + } else { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } } else { if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); else newValues.push(value); diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx index d8399fc02b..402319af4c 100644 --- a/web/components/issues/issue-detail/parent-select.tsx +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -103,7 +103,7 @@ export const IssueParentSelect: React.FC = observer((props)
= observer((props) => { Sibling issues
- + issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })} diff --git a/web/components/issues/issue-detail/parent/sibling-item.tsx b/web/components/issues/issue-detail/parent/sibling-item.tsx index c6eef2a9e3..c66a188994 100644 --- a/web/components/issues/issue-detail/parent/sibling-item.tsx +++ b/web/components/issues/issue-detail/parent/sibling-item.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { observer } from "mobx-react"; import Link from "next/link"; // ui import { CustomMenu, LayersIcon } from "@plane/ui"; @@ -6,15 +7,15 @@ import { CustomMenu, LayersIcon } from "@plane/ui"; import { useIssueDetail, useProject } from "@/hooks/store"; type TIssueParentSiblingItem = { + workspaceSlug: string; issueId: string; }; -export const IssueParentSiblingItem: FC = (props) => { - const { issueId } = props; +export const IssueParentSiblingItem: FC = observer((props) => { + const { workspaceSlug, issueId } = props; // hooks const { getProjectById } = useProject(); const { - peekIssue, issue: { getIssueById }, } = useIssueDetail(); @@ -27,7 +28,7 @@ export const IssueParentSiblingItem: FC = (props) => { <> @@ -36,4 +37,4 @@ export const IssueParentSiblingItem: FC = (props) => { ); -}; +}); diff --git a/web/components/issues/issue-detail/parent/siblings.tsx b/web/components/issues/issue-detail/parent/siblings.tsx index 56e93fc0f1..e23d8a595f 100644 --- a/web/components/issues/issue-detail/parent/siblings.tsx +++ b/web/components/issues/issue-detail/parent/siblings.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { observer } from "mobx-react"; import useSWR from "swr"; import { TIssue } from "@plane/types"; // components @@ -8,25 +9,25 @@ import { useIssueDetail } from "@/hooks/store"; import { IssueParentSiblingItem } from "./sibling-item"; export type TIssueParentSiblings = { + workspaceSlug: string; currentIssue: TIssue; parentIssue: TIssue; }; -export const IssueParentSiblings: FC = (props) => { - const { currentIssue, parentIssue } = props; +export const IssueParentSiblings: FC = observer((props) => { + const { workspaceSlug, currentIssue, parentIssue } = props; // hooks const { - peekIssue, fetchSubIssues, subIssues: { subIssuesByIssueId }, } = useIssueDetail(); const { isLoading } = useSWR( - peekIssue && parentIssue && parentIssue.project_id - ? `ISSUE_PARENT_CHILD_ISSUES_${peekIssue?.workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` + parentIssue && parentIssue.project_id + ? `ISSUE_PARENT_CHILD_ISSUES_${workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` : null, - peekIssue && parentIssue && parentIssue.project_id - ? () => fetchSubIssues(peekIssue?.workspaceSlug, parentIssue.project_id, parentIssue.id) + parentIssue && parentIssue.project_id + ? () => fetchSubIssues(workspaceSlug, parentIssue.project_id, parentIssue.id) : null ); @@ -40,7 +41,10 @@ export const IssueParentSiblings: FC = (props) => {
) : subIssueIds && subIssueIds.length > 0 ? ( subIssueIds.map( - (issueId) => currentIssue.id != issueId && + (issueId) => + currentIssue.id != issueId && ( + + ) ) ) : (
@@ -49,4 +53,4 @@ export const IssueParentSiblings: FC = (props) => { )}
); -}; +}); diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index d2c847eccd..15163b188c 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; @@ -252,14 +253,18 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; + let newValues = issueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); + if (key === "state") { + if (isEqual(newValues, value)) newValues = []; + else newValues = value; + } else { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } } else { if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); else newValues.push(value); diff --git a/web/components/project/card-list.tsx b/web/components/project/card-list.tsx index 5f830c1f9c..97ebc1b14b 100644 --- a/web/components/project/card-list.tsx +++ b/web/components/project/card-list.tsx @@ -16,9 +16,11 @@ export const ProjectCardList = observer(() => { // store hooks const { toggleCreateProjectModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); - const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject(); + const { workspaceProjectIds, filteredProjectIds, getProjectById, loader } = useProject(); const { searchQuery, currentWorkspaceDisplayFilters } = useProjectFilter(); + if (!filteredProjectIds || !workspaceProjectIds || loader) return ; + if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects) return ( { /> ); - if (!filteredProjectIds) return ; - if (filteredProjectIds.length === 0) return (
diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index 5dcc31c3fc..4d45a515fa 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -1,8 +1,13 @@ -import { useRef, useState } from "react"; -import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview"; +import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"; +import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; +import { createRoot } from "react-dom/client"; import { MoreVertical, PenSquare, @@ -28,6 +33,7 @@ import { ContrastIcon, LayersIcon, setPromiseToast, + DropIndicator, } from "@plane/ui"; import { LeaveProjectModal, ProjectLogo, PublishProjectModal } from "@/components/project"; import { EUserProjectRoles } from "@/constants/project"; @@ -36,17 +42,23 @@ import { cn } from "@/helpers/common.helper"; import { useAppTheme, useEventTracker, useProject } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; +import { HIGHLIGHT_CLASS, highlightIssueOnDrop } from "../issues/issue-layouts/utils"; // helpers // components type Props = { projectId: string; - provided?: DraggableProvided; - snapshot?: DraggableStateSnapshot; handleCopyText: () => void; - shortContextMenu?: boolean; + handleOnProjectDrop?: ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => void; + projectListType: "JOINED" | "FAVORITES"; disableDrag?: boolean; + disableDrop?: boolean; + isLastChild: boolean; }; const navigation = (workspaceSlug: string, projectId: string) => [ @@ -89,7 +101,8 @@ const navigation = (workspaceSlug: string, projectId: string) => [ export const ProjectSidebarListItem: React.FC = observer((props) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { projectId, provided, snapshot, handleCopyText, shortContextMenu = false, disableDrag } = props; + const { projectId, handleCopyText, disableDrag, disableDrop, isLastChild, handleOnProjectDrop, projectListType } = + props; // store hooks const { sidebarCollapsed: isCollapsed, toggleSidebar } = useAppTheme(); const { setTrackElement } = useEventTracker(); @@ -99,8 +112,12 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false); const [publishModalOpen, setPublishModal] = useState(false); const [isMenuActive, setIsMenuActive] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined); // refs const actionSectionRef = useRef(null); + const projectRef = useRef(null); + const dragHandleRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId: URLProjectId } = router.query; @@ -160,7 +177,97 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { } }; + useEffect(() => { + const element = projectRef.current; + const dragHandleElement = dragHandleRef.current; + + if (!element) return; + + return combine( + draggable({ + element, + canDrag: () => !disableDrag, + dragHandle: dragHandleElement ?? undefined, + getInitialData: () => ({ id: projectId, dragInstanceId: "PROJECTS" }), + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + }, + onGenerateDragPreview: ({ nativeSetDragImage }) => { + // Add a custom drag image + setCustomNativeDragPreview({ + getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }), + render: ({ container }) => { + const root = createRoot(container); + root.render( +
+
+ {project && } +
+

{project?.name}

+
+ ); + return () => root.unmount(); + }, + nativeSetDragImage, + }); + }, + }), + dropTargetForElements({ + element, + canDrop: ({ source }) => + !disableDrop && source?.data?.id !== projectId && source?.data?.dragInstanceId === "PROJECTS", + getData: ({ input, element }) => { + const data = { id: projectId }; + + // attach instruction for last in list + return attachInstruction(data, { + input, + element, + currentLevel: 0, + indentPerLevel: 0, + mode: isLastChild ? "last-in-group" : "standard", + }); + }, + onDrag: ({ self }) => { + const extractedInstruction = extractInstruction(self?.data)?.type; + // check if the highlight is to be shown above or below + setInstruction( + extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined + ); + }, + onDragLeave: () => { + setInstruction(undefined); + }, + onDrop: ({ self, source }) => { + setInstruction(undefined); + const extractedInstruction = extractInstruction(self?.data)?.type; + const currentInstruction = extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined; + if (!currentInstruction) return; + + const sourceId = source?.data?.id as string | undefined; + const destinationId = self?.data?.id as string | undefined; + + handleOnProjectDrop && handleOnProjectDrop(sourceId, destinationId, currentInstruction === "DRAG_BELOW"); + + highlightIssueOnDrop(`sidebar-${sourceId}-${projectListType}`); + }, + }) + ); + }, [projectRef?.current, dragHandleRef?.current, projectId, isLastChild, projectListType, handleOnProjectDrop]); + useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); + useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS)); if (!project) return null; @@ -168,48 +275,47 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { <> setPublishModal(false)} /> - + {({ open }) => ( - <> +
+
- {provided && !disableDrag && ( + {!disableDrag && ( )} - + = observer((props) => { )} >
-
+
{!isCollapsed &&

{project.name}

} @@ -380,7 +486,8 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { })} - + {isLastChild && } +
)} diff --git a/web/components/project/sidebar-list.tsx b/web/components/project/sidebar-list.tsx index ed1c90692b..b6eb0c7082 100644 --- a/web/components/project/sidebar-list.tsx +++ b/web/components/project/sidebar-list.tsx @@ -1,5 +1,6 @@ import { useState, FC, useRef, useEffect } from "react"; -import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { ChevronDown, ChevronRight, Plus } from "lucide-react"; @@ -54,21 +55,28 @@ export const ProjectSidebarList: FC = observer(() => { }); }; - const onDragEnd = (result: DropResult) => { - const { source, destination, draggableId } = result; - if (!destination || !workspaceSlug) return; - if (source.index === destination.index) return; + const handleOnProjectDrop = ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => { + if (!sourceId || !destinationId || !workspaceSlug) return; + if (sourceId === destinationId) return; const joinedProjectsList: IProject[] = []; joinedProjects.map((projectId) => { const projectDetails = getProjectById(projectId); if (projectDetails) joinedProjectsList.push(projectDetails); }); + + const sourceIndex = joinedProjects.indexOf(sourceId); + const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId); + if (joinedProjectsList.length <= 0) return; - const updatedSortOrder = orderJoinedProjects(source.index, destination.index, draggableId, joinedProjectsList); + const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList); if (updatedSortOrder != undefined) - updateProjectView(workspaceSlug.toString(), draggableId, { sort_order: updatedSortOrder }).catch(() => { + updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", @@ -98,7 +106,21 @@ export const ProjectSidebarList: FC = observer(() => { currentContainerRef.removeEventListener("scroll", handleScroll); } }; - }, []); + }, [containerRef]); + + useEffect(() => { + const element = containerRef.current; + + if (!element) return; + + return combine( + autoScrollForElements({ + element, + canScroll: ({ source }) => source?.data?.dragInstanceId === "PROJECTS", + getAllowedAxis: () => "vertical", + }) + ); + }, [containerRef]); return ( <> @@ -123,156 +145,117 @@ export const ProjectSidebarList: FC = observer(() => { } )} > - - - {(provided) => ( -
- {favoriteProjects && favoriteProjects.length > 0 && ( - - {({ open }) => ( - <> - {!isCollapsed && ( -
- - Favorites - {open ? ( - - ) : ( - - )} - - {isAuthorizedUser && ( - - )} -
- )} - + {favoriteProjects && favoriteProjects.length > 0 && ( + + {({ open }) => ( + <> + {!isCollapsed && ( +
+ + Favorites + {open ? : } + + {isAuthorizedUser && ( +
- )} - - - - - {(provided) => ( -
- {joinedProjects && joinedProjects.length > 0 && ( - - {({ open }) => ( - <> - {!isCollapsed && ( -
- - Your projects - {open ? ( - - ) : ( - - )} - - {isAuthorizedUser && ( - - )} -
- )} - + + )} +
+ )} + + + {favoriteProjects.map((projectId, index) => ( + handleCopyText(projectId)} + projectListType="FAVORITES" + disableDrag + disableDrop + isLastChild={index === favoriteProjects.length - 1} + /> + ))} + + + + )} +
+ )} +
+
+ {joinedProjects && joinedProjects.length > 0 && ( + + {({ open }) => ( + <> + {!isCollapsed && ( +
+ + Your projects + {open ? : } + + {isAuthorizedUser && ( +
- )} - - + + + )} +
+ )} + + + {joinedProjects.map((projectId, index) => ( + handleCopyText(projectId)} + isLastChild={index === joinedProjects.length - 1} + handleOnProjectDrop={handleOnProjectDrop} + /> + ))} + + + + )} + + )} +
{isAuthorizedUser && joinedProjects && joinedProjects.length === 0 && (