diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml deleted file mode 100644 index 908f1ea93..000000000 --- a/.github/workflows/auto-merge.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Create PR on Sync - -on: - workflow_dispatch: - push: - branches: - - "sync/**" - -env: - CURRENT_BRANCH: ${{ github.ref_name }} - SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce" - TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop - GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows - REVIEWER: ${{ vars.SYNC_PR_REVIEWER }} - ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }} - ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }} - -jobs: - Check_Branch: - runs-on: ubuntu-latest - outputs: - BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }} - steps: - - name: Check if current branch matches the secret - id: check-branch - run: | - if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then - echo "MATCH=true" >> $GITHUB_OUTPUT - else - echo "MATCH=false" >> $GITHUB_OUTPUT - fi - Auto_Merge: - if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }} - needs: [Check_Branch] - runs-on: ubuntu-latest - permissions: - pull-requests: write - contents: write - steps: - - name: Checkout code - uses: actions/checkout@v4.1.1 - with: - fetch-depth: 0 # Fetch all history for all branches and tags - - - name: Setup Git - run: | - git config user.name "$ACCOUNT_USER_NAME" - git config user.email "$ACCOUNT_USER_EMAIL" - - - name: Setup GH CLI and Git Config - run: | - type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg - sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null - sudo apt update - sudo apt install gh -y - - - name: Create PR to Target Branch - run: | - # get all pull requests and check if there is already a PR - PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --json number | jq '.[] | .number') - if [ -n "$PR_EXISTS" ]; then - echo "Pull Request already exists: $PR_EXISTS" - else - echo "Creating new pull request" - PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "") - echo "Pull Request created: $PR_URL" - fi diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index ad1a605b6..a46fd74d2 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -1,28 +1,53 @@ -name: Create Sync Action +name: Create PR on Sync on: workflow_dispatch: push: branches: - - preview + - "sync/**" env: - SOURCE_BRANCH_NAME: ${{ github.ref_name }} + CURRENT_BRANCH: ${{ github.ref_name }} + SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce" + TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop + GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows + REVIEWER: ${{ vars.SYNC_PR_REVIEWER }} + ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }} + ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }} jobs: - sync_changes: - runs-on: ubuntu-20.04 + Check_Branch: + runs-on: ubuntu-latest + outputs: + BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }} + steps: + - name: Check if current branch matches the secret + id: check-branch + run: | + if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then + echo "MATCH=true" >> $GITHUB_OUTPUT + else + echo "MATCH=false" >> $GITHUB_OUTPUT + fi + Auto_Merge: + if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }} + needs: [Check_Branch] + runs-on: ubuntu-latest permissions: pull-requests: write - contents: read + contents: write steps: - - name: Checkout Code + - name: Checkout code uses: actions/checkout@v4.1.1 with: - persist-credentials: false - fetch-depth: 0 + fetch-depth: 0 # Fetch all history for all branches and tags - - name: Setup GH CLI + - name: Setup Git + run: | + git config user.name "$ACCOUNT_USER_NAME" + git config user.email "$ACCOUNT_USER_EMAIL" + + - name: Setup GH CLI and Git Config run: | type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg @@ -31,25 +56,14 @@ jobs: sudo apt update sudo apt install gh -y - - name: Push Changes to Target Repo A - env: - GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + - name: Create PR to Target Branch run: | - TARGET_REPO="${{ secrets.TARGET_REPO_A }}" - TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}" - SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" - - git checkout $SOURCE_BRANCH - git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git" - git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH - - - name: Push Changes to Target Repo B - env: - GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} - run: | - TARGET_REPO="${{ secrets.TARGET_REPO_B }}" - TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}" - SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" - - git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git" - git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH + # get all pull requests and check if there is already a PR + PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number') + if [ -n "$PR_EXISTS" ]; then + echo "Pull Request already exists: $PR_EXISTS" + else + echo "Creating new pull request" + PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "") + echo "Pull Request created: $PR_URL" + fi diff --git a/.github/workflows/repo-sync.yml b/.github/workflows/repo-sync.yml new file mode 100644 index 000000000..9ac4771ef --- /dev/null +++ b/.github/workflows/repo-sync.yml @@ -0,0 +1,44 @@ +name: Sync Repositories + +on: + workflow_dispatch: + push: + branches: + - preview + +env: + SOURCE_BRANCH_NAME: ${{ github.ref_name }} + +jobs: + sync_changes: + runs-on: ubuntu-20.04 + permissions: + pull-requests: write + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@v4.1.1 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup GH CLI + run: | + type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt update + sudo apt install gh -y + + - name: Push Changes to Target Repo + env: + GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + run: | + TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}" + TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}" + SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" + + git checkout $SOURCE_BRANCH + git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git" + git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH diff --git a/admin/app/login/components/sign-in-form.tsx b/admin/app/login/components/sign-in-form.tsx index ba0883c83..c28d0f27b 100644 --- a/admin/app/login/components/sign-in-form.tsx +++ b/admin/app/login/components/sign-in-form.tsx @@ -113,6 +113,7 @@ export const InstanceSignInForm: FC = (props) => { placeholder="name@company.com" value={formData.email} onChange={(e) => handleFormChange("email", e.target.value)} + autoFocus /> diff --git a/admin/app/setup/components/sign-up-form.tsx b/admin/app/setup/components/sign-up-form.tsx index d700ce62c..8ef8add62 100644 --- a/admin/app/setup/components/sign-up-form.tsx +++ b/admin/app/setup/components/sign-up-form.tsx @@ -39,6 +39,7 @@ type TFormData = { email: string; company_name: string; password: string; + confirm_password?: string; is_telemetry_enabled: boolean; }; @@ -66,6 +67,7 @@ export const InstanceSignUpForm: FC = (props) => { const [showPassword, setShowPassword] = useState(false); const [csrfToken, setCsrfToken] = useState(undefined); const [formData, setFormData] = useState(defaultFromData); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); const handleFormChange = (key: keyof TFormData, value: string | boolean) => setFormData((prev) => ({ ...prev, [key]: value })); @@ -107,7 +109,11 @@ export const InstanceSignUpForm: FC = (props) => { const isButtonDisabled = useMemo( () => - formData.first_name && formData.email && formData.password && getPasswordStrength(formData.password) >= 3 + formData.first_name && + formData.email && + formData.password && + getPasswordStrength(formData.password) >= 3 && + formData.password === formData.confirm_password ? false : true, [formData] @@ -144,6 +150,7 @@ export const InstanceSignUpForm: FC = (props) => { placeholder="Wilber" value={formData.first_name} onChange={(e) => handleFormChange("first_name", e.target.value)} + autoFocus />
@@ -214,6 +221,8 @@ export const InstanceSignUpForm: FC = (props) => { value={formData.password} onChange={(e) => handleFormChange("password", e.target.value)} hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} /> {showPassword ? (
+ +
+ +
+ handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + /> + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
+ {!!formData.confirm_password && formData.password !== formData.confirm_password && ( + Passwords don{"'"}t match + )}
- handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} - checked={formData.is_telemetry_enabled} - /> +
+ handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} + checked={formData.is_telemetry_enabled} + /> +
- + See More
diff --git a/admin/components/common/banner.tsx b/admin/components/common/banner.tsx index 13d1583a2..932a0c629 100644 --- a/admin/components/common/banner.tsx +++ b/admin/components/common/banner.tsx @@ -1,5 +1,5 @@ import { FC } from "react"; -import { AlertCircle, CheckCircle } from "lucide-react"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; type TBanner = { type: "success" | "error"; @@ -10,19 +10,21 @@ export const Banner: FC = (props) => { const { type, message } = props; return ( -
+
{type === "error" ? ( - -
-
-

{message}

+
+

{message}

diff --git a/admin/layouts/default-layout.tsx b/admin/layouts/default-layout.tsx index a798ae055..d46368a66 100644 --- a/admin/layouts/default-layout.tsx +++ b/admin/layouts/default-layout.tsx @@ -2,7 +2,6 @@ import { FC, ReactNode } from "react"; import Image from "next/image"; -import { usePathname } from "next/navigation"; // logo import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; @@ -12,21 +11,16 @@ type TDefaultLayout = { export const DefaultLayout: FC = (props) => { const { children } = props; - const pathname = usePathname(); - - console.log("pathname", pathname); return ( -
-
-
-
- Plane Logo - Plane -
+
+
+
+ Plane Logo + Plane
-
{children}
+
{children}
); }; diff --git a/admin/lib/wrappers/instance-wrapper.tsx b/admin/lib/wrappers/instance-wrapper.tsx index 4edbcbde4..739820f99 100644 --- a/admin/lib/wrappers/instance-wrapper.tsx +++ b/admin/lib/wrappers/instance-wrapper.tsx @@ -52,6 +52,7 @@ export const InstanceWrapper: FC = observer((props) => { ); if (instance?.instance?.is_setup_done && pageType === EInstancePageType.PRE_SETUP) redirect("/"); + if (!instance?.instance?.is_setup_done && pageType === EInstancePageType.POST_SETUP) redirect("/setup"); return <>{children}; diff --git a/admin/public/favicon/android-chrome-192x192.png b/admin/public/favicon/android-chrome-192x192.png new file mode 100644 index 000000000..62e95acfc Binary files /dev/null and b/admin/public/favicon/android-chrome-192x192.png differ diff --git a/admin/public/favicon/android-chrome-512x512.png b/admin/public/favicon/android-chrome-512x512.png new file mode 100644 index 000000000..41400832b Binary files /dev/null and b/admin/public/favicon/android-chrome-512x512.png differ diff --git a/admin/public/favicon/apple-touch-icon.png b/admin/public/favicon/apple-touch-icon.png new file mode 100644 index 000000000..5273d4951 Binary files /dev/null and b/admin/public/favicon/apple-touch-icon.png differ diff --git a/admin/public/favicon/favicon-16x16.png b/admin/public/favicon/favicon-16x16.png new file mode 100644 index 000000000..8ddbd49c0 Binary files /dev/null and b/admin/public/favicon/favicon-16x16.png differ diff --git a/admin/public/favicon/favicon-32x32.png b/admin/public/favicon/favicon-32x32.png new file mode 100644 index 000000000..80cbe7a68 Binary files /dev/null and b/admin/public/favicon/favicon-32x32.png differ diff --git a/admin/public/favicon/favicon.ico b/admin/public/favicon/favicon.ico new file mode 100644 index 000000000..9094a07c7 Binary files /dev/null and b/admin/public/favicon/favicon.ico differ diff --git a/admin/public/favicon/site.webmanifest b/admin/public/favicon/site.webmanifest new file mode 100644 index 000000000..45dc8a206 --- /dev/null +++ b/admin/public/favicon/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index 894af3cbb..50c3e2eb2 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -29,8 +29,8 @@ class SignInAuthEndpoint(View): if instance is None or not instance.is_setup_done: # Redirection params params = { - "error_code": "REQUIRED_EMAIL_PASSWORD", - "error_message": "Both email and password are required", + "error_code": "INSTANCE_NOT_CONFIGURED", + "error_message": "Instance is not configured", } if next_path: params["next_path"] = str(next_path) diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py index da14acbef..5706b4eb8 100644 --- a/apiserver/plane/authentication/views/app/magic.py +++ b/apiserver/plane/authentication/views/app/magic.py @@ -26,7 +26,7 @@ from plane.authentication.utils.workspace_project_join import ( from plane.bgtasks.magic_link_code_task import magic_link from plane.license.models import Instance from plane.authentication.utils.host import base_host -from plane.db.models import User +from plane.db.models import User, Profile class MagicGenerateEndpoint(APIView): @@ -123,11 +123,12 @@ class MagicSignInEndpoint(View): request=request, key=f"magic_{email}", code=code ) user = provider.authenticate() + profile = Profile.objects.get(user=user) # Login the user and record his device info user_login(request=request, user=user) # Process workspace and project invitations process_workspace_project_invitations(user=user) - if user.is_password_autoset: + if user.is_password_autoset and profile.is_onboarded: path = "accounts/set-password" else: # Get the redirection path diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py index bef7154cf..eafeac66d 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -22,7 +22,7 @@ from plane.authentication.utils.login import user_login from plane.bgtasks.magic_link_code_task import magic_link from plane.license.models import Instance from plane.authentication.utils.host import base_host -from plane.db.models import User +from plane.db.models import User, Profile class MagicGenerateSpaceEndpoint(APIView): @@ -122,9 +122,18 @@ class MagicSignInSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user) # redirect to referer path + if user.is_password_autoset and profile.is_onboarded: + path = "spaces/accounts/set-password" + else: + # Get the redirection path + path = ( + str(next_path) + if next_path + else "spaces" + ) url = urljoin( base_host(request=request), - str(next_path) if next_path else "spaces", + path ) return HttpResponseRedirect(url) diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index 78d252c81..a90be65f1 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -34,7 +34,7 @@ interface CustomEditorProps { suggestions?: () => Promise; }; handleEditorReady?: (value: boolean) => void; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; } diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index f4dbaee3b..f6afdfbc1 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -43,7 +43,7 @@ type TArguments = { cancelUploadImage?: () => void; uploadFile: UploadImage; }; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; }; @@ -147,7 +147,7 @@ export const CoreEditorExtensions = ({ if (placeholder) { if (typeof placeholder === "string") return placeholder; - else return placeholder(editor.isFocused); + else return placeholder(editor.isFocused, editor.getHTML()); } return "Press '/' for commands..."; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 3c36ed11c..1f1c5f706 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -31,7 +31,7 @@ interface IDocumentEditor { suggestions: () => Promise; }; tabIndex?: number; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); } const DocumentEditor = (props: IDocumentEditor) => { diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index 71846eca7..fe9453110 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -32,7 +32,7 @@ export interface ILiteTextEditor { suggestions?: () => Promise; }; tabIndex?: number; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); } const LiteTextEditor = (props: ILiteTextEditor) => { diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index e82615b95..0cb32e543 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -35,7 +35,7 @@ export type IRichTextEditor = { highlights: () => Promise; suggestions: () => Promise; }; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; }; diff --git a/packages/types/src/auth.d.ts b/packages/types/src/auth.d.ts index 15b4f2018..068062fc7 100644 --- a/packages/types/src/auth.d.ts +++ b/packages/types/src/auth.d.ts @@ -26,6 +26,6 @@ export interface IPasswordSignInData { password: string; } -export interface ICsrfTokenData { +export interface ICsrfTokenData { csrf_token: string; -}; \ No newline at end of file +} diff --git a/packages/ui/src/dropdowns/context-menu/index.ts b/packages/ui/src/dropdowns/context-menu/index.ts new file mode 100644 index 000000000..9665324ca --- /dev/null +++ b/packages/ui/src/dropdowns/context-menu/index.ts @@ -0,0 +1,2 @@ +export * from "./item"; +export * from "./root"; diff --git a/packages/ui/src/dropdowns/context-menu/item.tsx b/packages/ui/src/dropdowns/context-menu/item.tsx new file mode 100644 index 000000000..99ef790e3 --- /dev/null +++ b/packages/ui/src/dropdowns/context-menu/item.tsx @@ -0,0 +1,54 @@ +import React from "react"; +// helpers +import { cn } from "../../../helpers"; +// types +import { TContextMenuItem } from "./root"; + +type ContextMenuItemProps = { + handleActiveItem: () => void; + handleClose: () => void; + isActive: boolean; + item: TContextMenuItem; +}; + +export const ContextMenuItem: React.FC = (props) => { + const { handleActiveItem, handleClose, isActive, item } = props; + + if (item.shouldRender === false) return null; + + return ( + + ); +}; diff --git a/packages/ui/src/dropdowns/context-menu/root.tsx b/packages/ui/src/dropdowns/context-menu/root.tsx new file mode 100644 index 000000000..47a52b8c2 --- /dev/null +++ b/packages/ui/src/dropdowns/context-menu/root.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useRef, useState } from "react"; +import ReactDOM from "react-dom"; +// components +import { ContextMenuItem } from "./item"; +// helpers +import { cn } from "../../../helpers"; +// hooks +import useOutsideClickDetector from "../../hooks/use-outside-click-detector"; + +export type TContextMenuItem = { + key: string; + title: string; + description?: string; + icon?: React.FC; + action: () => void; + shouldRender?: boolean; + closeOnClick?: boolean; + disabled?: boolean; + className?: string; + iconClassName?: string; +}; + +type ContextMenuProps = { + parentRef: React.RefObject; + items: TContextMenuItem[]; +}; + +const ContextMenuWithoutPortal: React.FC = (props) => { + const { parentRef, items } = props; + // states + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState({ + x: 0, + y: 0, + }); + const [activeItemIndex, setActiveItemIndex] = useState(0); + // refs + const contextMenuRef = useRef(null); + // derived values + const renderedItems = items.filter((item) => item.shouldRender !== false); + + const handleClose = () => { + setIsOpen(false); + setActiveItemIndex(0); + }; + + // calculate position of context menu + useEffect(() => { + const parentElement = parentRef.current; + const contextMenu = contextMenuRef.current; + if (!parentElement || !contextMenu) return; + + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const contextMenuWidth = contextMenu.clientWidth; + const contextMenuHeight = contextMenu.clientHeight; + + const clickX = e?.pageX || 0; + const clickY = e?.pageY || 0; + + // check if there's enough space at the bottom, otherwise show at the top + let top = clickY; + if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight; + + // check if there's enough space on the right, otherwise show on the left + let left = clickX; + if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth; + + setPosition({ x: left, y: top }); + setIsOpen(true); + }; + + const hideContextMenu = (e: KeyboardEvent) => { + if (isOpen && e.key === "Escape") handleClose(); + }; + + parentElement.addEventListener("contextmenu", handleContextMenu); + window.addEventListener("keydown", hideContextMenu); + + return () => { + parentElement.removeEventListener("contextmenu", handleContextMenu); + window.removeEventListener("keydown", hideContextMenu); + }; + }, [contextMenuRef, isOpen, parentRef, setIsOpen, setPosition]); + + // handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveItemIndex((prev) => (prev + 1) % renderedItems.length); + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length); + } + if (e.key === "Enter") { + e.preventDefault(); + const item = renderedItems[activeItemIndex]; + if (!item.disabled) { + renderedItems[activeItemIndex].action(); + if (item.closeOnClick !== false) handleClose(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [activeItemIndex, isOpen, renderedItems, setIsOpen]); + + // close on clicking outside + useOutsideClickDetector(contextMenuRef, handleClose); + + return ( +
+
+ {renderedItems.map((item, index) => ( + setActiveItemIndex(index)} + handleClose={handleClose} + isActive={index === activeItemIndex} + item={item} + /> + ))} +
+
+ ); +}; + +export const ContextMenu: React.FC = (props) => { + let contextMenu = ; + const portal = document.querySelector("#context-menu-portal"); + if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal); + return contextMenu; +}; diff --git a/packages/ui/src/dropdowns/index.ts b/packages/ui/src/dropdowns/index.ts index 0ad9cbb22..d77eac129 100644 --- a/packages/ui/src/dropdowns/index.ts +++ b/packages/ui/src/dropdowns/index.ts @@ -1,3 +1,4 @@ +export * from "./context-menu"; export * from "./custom-menu"; export * from "./custom-select"; export * from "./custom-search-select"; diff --git a/space/components/accounts/auth-forms/password.tsx b/space/components/accounts/auth-forms/password.tsx index a9aeec30f..9ec087e4e 100644 --- a/space/components/accounts/auth-forms/password.tsx +++ b/space/components/accounts/auth-forms/password.tsx @@ -180,7 +180,7 @@ export const PasswordForm: React.FC = (props) => { )}
{!!passwordFormData.confirm_password && passwordFormData.password !== passwordFormData.confirm_password && ( - Password doesn{"'"}t match + Passwords don{"'"}t match )}
)} diff --git a/space/components/accounts/auth-forms/root.tsx b/space/components/accounts/auth-forms/root.tsx index 1fce06d18..ba05797a2 100644 --- a/space/components/accounts/auth-forms/root.tsx +++ b/space/components/accounts/auth-forms/root.tsx @@ -26,43 +26,28 @@ type TTitle = { }; type THeaderSubheader = { - [mode in EAuthModes]: { - [step in Exclude]: TTitle; - }; + [mode in EAuthModes]: TTitle; }; -const Titles: THeaderSubheader = { +const titles: THeaderSubheader = { [EAuthModes.SIGN_IN]: { - [EAuthSteps.PASSWORD]: { - header: "Sign in to Plane", - subHeader: "Get back to your projects and make progress", - }, - [EAuthSteps.UNIQUE_CODE]: { - header: "Sign in to Plane", - subHeader: "Get back to your projects and make progress", - }, + header: "Sign in to upvote or comment", + subHeader: "Contribute in nudging the features you want to get built.", }, [EAuthModes.SIGN_UP]: { - [EAuthSteps.PASSWORD]: { - header: "Create your account", - subHeader: "Progress, visualize, and measure work how it works best for you.", - }, - [EAuthSteps.UNIQUE_CODE]: { - header: "Create your account", - subHeader: "Progress, visualize, and measure work how it works best for you.", - }, + header: "Comment or react to issues", + subHeader: "Use plane to add your valuable inputs to features.", }, }; -// TODO: Better approach for this. -const getHeaderSubHeader = (mode: EAuthModes | null, step: EAuthSteps): TTitle => { +const getHeaderSubHeader = (mode: EAuthModes | null): TTitle => { if (mode) { - return (Titles[mode] as any)[step]; + return titles[mode]; } return { - header: "Get started with Plane", - subHeader: "Progress, visualize, and measure work how it works best for you.", + header: "Comment or react to issues", + subHeader: "Use plane to add your valuable inputs to features.", }; }; @@ -81,7 +66,7 @@ export const AuthRoot = observer(() => { // derived values const isSmtpConfigured = instance?.config?.is_smtp_configured; - const { header, subHeader } = getHeaderSubHeader(authMode, authStep); + const { header, subHeader } = getHeaderSubHeader(authMode); const handelEmailVerification = async (data: IEmailCheckData) => { // update the global email state diff --git a/space/components/accounts/auth-forms/unique-code.tsx b/space/components/accounts/auth-forms/unique-code.tsx index 9bbf392a7..525430bc6 100644 --- a/space/components/accounts/auth-forms/unique-code.tsx +++ b/space/components/accounts/auth-forms/unique-code.tsx @@ -1,7 +1,10 @@ import React, { useEffect, useState } from "react"; // hooks -// types +import useTimer from "hooks/use-timer"; +import useToast from "hooks/use-toast"; import { useRouter } from "next/router"; +// types +import { IEmailCheckData } from "types/auth"; // icons import { CircleCheck, XCircle } from "lucide-react"; // ui @@ -10,9 +13,6 @@ import { Button, Input } from "@plane/ui"; import { API_BASE_URL } from "@/helpers/common.helper"; // services import { AuthService } from "@/services/authentication.service"; -import useTimer from "hooks/use-timer"; -import useToast from "hooks/use-toast"; -import { IEmailCheckData } from "types/auth"; import { EAuthModes } from "./root"; type Props = { @@ -175,8 +175,8 @@ export const UniqueCodeForm: React.FC = (props) => { variant="primary" className="w-full" size="lg" - // disabled={!isValid || hasEmailChanged} loading={isRequestingNewCode} + disabled={isRequestingNewCode || !uniqueCodeFormData.code} > {isRequestingNewCode ? "Sending code" : submitButtonText} diff --git a/space/components/accounts/onboarding-form.tsx b/space/components/accounts/onboarding-form.tsx index 618e838c9..b43af9ffe 100644 --- a/space/components/accounts/onboarding-form.tsx +++ b/space/components/accounts/onboarding-form.tsx @@ -210,7 +210,7 @@ export const OnBoardingForm: React.FC = observer((props) => { disabled={isButtonDisabled} loading={isSubmitting} > - {isSubmitting ? "Updating..." : "Continue"} + Continue ); diff --git a/space/pages/_document.tsx b/space/pages/_document.tsx index bf83a722c..ae4455438 100644 --- a/space/pages/_document.tsx +++ b/space/pages/_document.tsx @@ -6,6 +6,7 @@ class MyDocument extends Document { +
diff --git a/space/pages/accounts/reset-password.tsx b/space/pages/accounts/reset-password.tsx index fe2d42340..be7b28dbc 100644 --- a/space/pages/accounts/reset-password.tsx +++ b/space/pages/accounts/reset-password.tsx @@ -181,7 +181,7 @@ const ResetPasswordPage: NextPage = () => { )}
{!!resetFormData.confirm_password && resetFormData.password !== resetFormData.confirm_password && ( - Password doesn{"'"}t match + Passwords don{"'"}t match )}
)} diff --git a/turbo.json b/turbo.json index 893a4f39b..5c49f686d 100644 --- a/turbo.json +++ b/turbo.json @@ -4,6 +4,7 @@ "NODE_ENV", "NEXT_PUBLIC_API_BASE_URL", "NEXT_PUBLIC_DEPLOY_URL", + "NEXT_PUBLIC_GOD_MODE_URL", "NEXT_PUBLIC_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_ENVIRONMENT", "NEXT_PUBLIC_ENABLE_SENTRY", @@ -16,7 +17,6 @@ "NEXT_PUBLIC_DEPLOY_WITH_NGINX", "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST", - "NEXT_PUBLIC_GOD_MODE", "NEXT_PUBLIC_POSTHOG_DEBUG", "SENTRY_AUTH_TOKEN" ], diff --git a/web/components/account/auth-forms/auth-banner.tsx b/web/components/account/auth-forms/auth-banner.tsx new file mode 100644 index 000000000..6d805e5bf --- /dev/null +++ b/web/components/account/auth-forms/auth-banner.tsx @@ -0,0 +1,29 @@ +import { FC } from "react"; +import { Info, X } from "lucide-react"; +// helpers +import { TAuthErrorInfo } from "@/helpers/authentication.helper"; + +type TAuthBanner = { + bannerData: TAuthErrorInfo | undefined; + handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void; +}; + +export const AuthBanner: FC = (props) => { + const { bannerData, handleBannerData } = props; + + if (!bannerData) return <>; + return ( +
+
+ +
+
{bannerData?.message}
+
handleBannerData && handleBannerData(undefined)} + > + +
+
+ ); +}; diff --git a/web/components/account/auth-forms/auth-header.tsx b/web/components/account/auth-forms/auth-header.tsx new file mode 100644 index 000000000..026db650f --- /dev/null +++ b/web/components/account/auth-forms/auth-header.tsx @@ -0,0 +1,99 @@ +import { FC, useEffect, useState } from "react"; +import { IWorkspaceMemberInvitation } from "@plane/types"; +// components +import { WorkspaceLogo } from "@/components/workspace/logo"; +// helpers +import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; +// services +import { WorkspaceService } from "@/services/workspace.service"; + +type TAuthHeader = { + workspaceSlug: string | undefined; + invitationId: string | undefined; + invitationEmail: string | undefined; + authMode: EAuthModes; + currentAuthStep: EAuthSteps; + handleLoader: (isLoading: boolean) => void; +}; + +const Titles = { + [EAuthModes.SIGN_IN]: { + [EAuthSteps.EMAIL]: { + header: "Sign in to Plane", + subHeader: "Get back to your projects and make progress", + }, + [EAuthSteps.PASSWORD]: { + header: "Sign in to Plane", + subHeader: "Get back to your projects and make progress", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Sign in to Plane", + subHeader: "Get back to your projects and make progress", + }, + }, + [EAuthModes.SIGN_UP]: { + [EAuthSteps.EMAIL]: { + header: "Create your account", + subHeader: "Start tracking your projects with Plane", + }, + [EAuthSteps.PASSWORD]: { + header: "Create your account", + subHeader: "Progress, visualize, and measure work how it works best for you.", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Create your account", + subHeader: "Progress, visualize, and measure work how it works best for you.", + }, + }, +}; + +const workSpaceService = new WorkspaceService(); + +export const AuthHeader: FC = (props) => { + const { workspaceSlug, invitationId, invitationEmail, authMode, currentAuthStep, handleLoader } = props; + // state + const [invitation, setInvitation] = useState(undefined); + + const getHeaderSubHeader = ( + step: EAuthSteps, + mode: EAuthModes, + invitation: IWorkspaceMemberInvitation | undefined, + email: string | undefined + ) => { + if (invitation && email && invitation.email === email && invitation.workspace) { + const workspace = invitation.workspace; + return { + header: ( + <> + Join {workspace.name} + + ), + subHeader: `${ + mode == EAuthModes.SIGN_UP ? "Create an account" : "Sign in" + } to start managing work with your team.`, + }; + } + + return Titles[mode][step]; + }; + + useEffect(() => { + if (workspaceSlug && invitationId) { + handleLoader(true); + workSpaceService + .getWorkspaceInvitation(workspaceSlug, invitationId) + .then((res) => setInvitation(res)) + .catch(() => setInvitation(undefined)) + .finally(() => handleLoader(false)); + } else setInvitation(undefined); + }, [workspaceSlug, invitationId, handleLoader]); + + const { header, subHeader } = getHeaderSubHeader(currentAuthStep, authMode, invitation, invitationEmail); + + return ( +
+

{header}

+

{subHeader}

+
+ ); +}; diff --git a/web/components/account/auth-forms/email.tsx b/web/components/account/auth-forms/email.tsx index ed6465458..3110dd87b 100644 --- a/web/components/account/auth-forms/email.tsx +++ b/web/components/account/auth-forms/email.tsx @@ -1,6 +1,5 @@ -import React from "react"; +import { FC, FormEvent, useMemo, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Controller, useForm } from "react-hook-form"; // icons import { CircleAlert, XCircle } from "lucide-react"; // types @@ -10,82 +9,72 @@ import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "@/helpers/string.helper"; -type Props = { - onSubmit: (data: IEmailCheckData) => Promise; +type TAuthEmailForm = { defaultEmail: string; + onSubmit: (data: IEmailCheckData) => Promise; }; -type TEmailFormValues = { - email: string; -}; - -export const AuthEmailForm: React.FC = observer((props) => { +export const AuthEmailForm: FC = observer((props) => { const { onSubmit, defaultEmail } = props; - // hooks - const { - control, - formState: { errors, isSubmitting, isValid }, - handleSubmit, - } = useForm({ - defaultValues: { - email: defaultEmail, - }, - mode: "onChange", - reValidateMode: "onChange", - }); + // states + const [isSubmitting, setIsSubmitting] = useState(false); + const [email, setEmail] = useState(defaultEmail); - const handleFormSubmit = async (data: TEmailFormValues) => { + const emailError = useMemo( + () => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined), + [email] + ); + + const handleFormSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); const payload: IEmailCheckData = { - email: data.email, + email: email, }; - onSubmit(payload); + await onSubmit(payload); + setIsSubmitting(false); }; return ( -
+
- checkEmailValidity(value) || "Email is invalid", - }} - render={({ field: { value, onChange } }) => ( - <> -
- - {value.length > 0 && ( - onChange("")} - /> - )} -
- {errors.email && ( -

- - {errors.email.message} -

- )} - +
+ setEmail(e.target.value)} + hasError={Boolean(emailError?.email)} + placeholder="name@company.com" + className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + autoFocus + /> + {email.length > 0 && ( + setEmail("")} + /> )} - /> +
+ {emailError?.email && ( +

+ + {emailError.email} +

+ )}
-
diff --git a/web/components/account/auth-forms/index.ts b/web/components/account/auth-forms/index.ts index c607000c7..e5d77069c 100644 --- a/web/components/account/auth-forms/index.ts +++ b/web/components/account/auth-forms/index.ts @@ -1,5 +1,10 @@ +export * from "./sign-up-root"; +export * from "./sign-in-root"; + +export * from "./auth-header"; +export * from "./auth-banner"; + export * from "./email"; export * from "./forgot-password-popover"; export * from "./password"; -export * from "./root"; export * from "./unique-code"; diff --git a/web/components/account/auth-forms/password.tsx b/web/components/account/auth-forms/password.tsx index bffdf5a99..2bd08632c 100644 --- a/web/components/account/auth-forms/password.tsx +++ b/web/components/account/auth-forms/password.tsx @@ -6,10 +6,11 @@ import { Eye, EyeOff, XCircle } from "lucide-react"; // ui import { Button, Input } from "@plane/ui"; // components -import { EAuthModes, EAuthSteps, ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/account"; +import { ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/account"; // constants import { FORGOT_PASSWORD } from "@/constants/event-tracker"; // helpers +import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; import { API_BASE_URL } from "@/helpers/common.helper"; import { getPasswordStrength } from "@/helpers/password.helper"; // hooks @@ -58,7 +59,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); }, [csrfToken]); - const redirectToUniqueCodeLogin = async () => { + const redirectToUniqueCodeSignIn = async () => { handleStepChange(EAuthSteps.UNIQUE_CODE); }; @@ -94,51 +95,78 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { ); return ( - <> -
- -
- -
- handleFormChange("email", e.target.value)} - // hasError={Boolean(errors.email)} - placeholder="name@company.com" - className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + + +
+ +
+ handleFormChange("email", e.target.value)} + // hasError={Boolean(errors.email)} + placeholder="name@company.com" + className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + /> + {passwordFormData.email.length > 0 && ( + - {passwordFormData.email.length > 0 && ( - - )} -
+ )}
+
+
+ +
+ handleFormChange("password", e.target.value)} + placeholder="Enter password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoFocus + /> + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
+ {passwordSupport} +
+ {mode === EAuthModes.SIGN_UP && (
-
- {mode === EAuthModes.SIGN_UP && getPasswordStrength(passwordFormData.password) >= 3 && ( -
- -
- handleFormChange("confirm_password", e.target.value)} - placeholder="Confirm password" - className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" - /> - {showPassword ? ( - setShowPassword(false)} - /> - ) : ( - setShowPassword(true)} - /> - )} -
- {!!passwordFormData.confirm_password && passwordFormData.password !== passwordFormData.confirm_password && ( - Password doesn{"'"}t match - )} -
- )} -
- {mode === EAuthModes.SIGN_IN ? ( - <> - - {instance && isSmtpConfigured && ( - - )} - - ) : ( - + {!!passwordFormData.confirm_password && passwordFormData.password !== passwordFormData.confirm_password && ( + Passwords don{"'"}t match )}
- - + )} +
+ {mode === EAuthModes.SIGN_IN ? ( + <> + + {instance && isSmtpConfigured && ( + + )} + + ) : ( + + )} +
+ ); }); + diff --git a/web/components/account/auth-forms/root.tsx b/web/components/account/auth-forms/root.tsx deleted file mode 100644 index 6ed70422e..000000000 --- a/web/components/account/auth-forms/root.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import React, { useEffect, useState } from "react"; -import isEmpty from "lodash/isEmpty"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -// types -import { IEmailCheckData, IWorkspaceMemberInvitation } from "@plane/types"; -// ui -import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { - AuthEmailForm, - AuthPasswordForm, - OAuthOptions, - TermsAndConditions, - UniqueCodeForm, -} from "@/components/account"; -import { WorkspaceLogo } from "@/components/workspace/logo"; -import { useInstance } from "@/hooks/store"; -// services -import { AuthService } from "@/services/auth.service"; -import { WorkspaceService } from "@/services/workspace.service"; - -const authService = new AuthService(); -const workSpaceService = new WorkspaceService(); - -export enum EAuthSteps { - EMAIL = "EMAIL", - PASSWORD = "PASSWORD", - UNIQUE_CODE = "UNIQUE_CODE", - OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD", -} - -export enum EAuthModes { - SIGN_IN = "SIGN_IN", - SIGN_UP = "SIGN_UP", -} - -type Props = { - mode: EAuthModes; -}; - -const Titles = { - [EAuthModes.SIGN_IN]: { - [EAuthSteps.EMAIL]: { - header: "Sign in to Plane", - subHeader: "Get back to your projects and make progress", - }, - [EAuthSteps.PASSWORD]: { - header: "Sign in to Plane", - subHeader: "Get back to your projects and make progress", - }, - [EAuthSteps.UNIQUE_CODE]: { - header: "Sign in to Plane", - subHeader: "Get back to your projects and make progress", - }, - [EAuthSteps.OPTIONAL_SET_PASSWORD]: { - header: "", - subHeader: "", - }, - }, - [EAuthModes.SIGN_UP]: { - [EAuthSteps.EMAIL]: { - header: "Create your account", - subHeader: "Start tracking your projects with Plane", - }, - [EAuthSteps.PASSWORD]: { - header: "Create your account", - subHeader: "Progress, visualize, and measure work how it works best for you.", - }, - [EAuthSteps.UNIQUE_CODE]: { - header: "Create your account", - subHeader: "Progress, visualize, and measure work how it works best for you.", - }, - [EAuthSteps.OPTIONAL_SET_PASSWORD]: { - header: "", - subHeader: "", - }, - }, -}; - -const getHeaderSubHeader = ( - step: EAuthSteps, - mode: EAuthModes, - invitation?: IWorkspaceMemberInvitation | undefined, - email?: string -) => { - if (invitation && email && invitation.email === email && invitation.workspace) { - const workspace = invitation.workspace; - return { - header: ( - <> - Join {workspace.name} - - ), - subHeader: `${ - mode == EAuthModes.SIGN_UP ? "Create an account" : "Sign in" - } to start managing work with your team.`, - }; - } - - return Titles[mode][step]; -}; - -export const AuthRoot = observer((props: Props) => { - const { mode } = props; - //router - const router = useRouter(); - const { email: emailParam, invitation_id, slug } = router.query; - // states - const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); - const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); - const [invitation, setInvitation] = useState(undefined); - const [isLoading, setIsLoading] = useState(false); - // hooks - const { instance } = useInstance(); - // derived values - const isSmtpConfigured = instance?.config?.is_smtp_configured; - - const redirectToSignUp = (email: string) => { - if (isEmpty(email)) router.push({ pathname: "/", query: router.query }); - else router.push({ pathname: "/", query: { ...router.query, email: email } }); - }; - - const redirectToSignIn = (email: string) => { - if (isEmpty(email)) router.push({ pathname: "/accounts/sign-in", query: router.query }); - else router.push({ pathname: "/accounts/sign-in", query: { ...router.query, email: email } }); - }; - - useEffect(() => { - if (invitation_id && slug) { - setIsLoading(true); - workSpaceService - .getWorkspaceInvitation(slug.toString(), invitation_id.toString()) - .then((res) => { - setInvitation(res); - }) - .catch(() => { - setInvitation(undefined); - }) - .finally(() => setIsLoading(false)); - } else { - setInvitation(undefined); - } - }, [invitation_id, slug]); - - const { header, subHeader } = getHeaderSubHeader(authStep, mode, invitation, email); - - // step 1 submit handler- email verification - const handleEmailVerification = async (data: IEmailCheckData) => { - setEmail(data.email); - - const emailCheck = mode === EAuthModes.SIGN_UP ? authService.signUpEmailCheck : authService.signInEmailCheck; - - await emailCheck(data) - .then((res) => { - if (mode === EAuthModes.SIGN_IN && !res.is_password_autoset) { - setAuthStep(EAuthSteps.PASSWORD); - } else { - if (isSmtpConfigured) { - setAuthStep(EAuthSteps.UNIQUE_CODE); - } else { - if (mode === EAuthModes.SIGN_IN) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Unable to process request please contact Administrator to reset password", - }); - } else { - setAuthStep(EAuthSteps.PASSWORD); - } - } - } - }) - .catch((err) => { - if (err?.error_code === "USER_DOES_NOT_EXIST") { - redirectToSignUp(data.email); - return; - } else if (err?.error_code === "USER_ALREADY_EXIST") { - redirectToSignIn(data.email); - return; - } - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error_message ?? "Something went wrong. Please try again.", - }); - }); - }; - - const isOAuthEnabled = - instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled); - - if (isLoading) - return ( -
- -
- ); - - return ( - <> -
-
-

{header}

-

{subHeader}

-
- {authStep === EAuthSteps.EMAIL && } - {authStep === EAuthSteps.UNIQUE_CODE && ( - { - setEmail(""); - setAuthStep(EAuthSteps.EMAIL); - }} - submitButtonText="Continue" - mode={mode} - /> - )} - {authStep === EAuthSteps.PASSWORD && ( - { - setEmail(""); - setAuthStep(EAuthSteps.EMAIL); - }} - handleStepChange={(step) => setAuthStep(step)} - mode={mode} - /> - )} -
- {isOAuthEnabled && authStep !== EAuthSteps.OPTIONAL_SET_PASSWORD && } - - - - ); -}); diff --git a/web/components/account/auth-forms/sign-in-root.tsx b/web/components/account/auth-forms/sign-in-root.tsx new file mode 100644 index 000000000..883bf9674 --- /dev/null +++ b/web/components/account/auth-forms/sign-in-root.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +import { IEmailCheckData } from "@plane/types"; +import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { + AuthHeader, + AuthBanner, + AuthEmailForm, + AuthPasswordForm, + OAuthOptions, + TermsAndConditions, + UniqueCodeForm, +} from "@/components/account"; +// helpers +import { + EAuthModes, + EAuthSteps, + EAuthenticationErrorCodes, + EErrorAlertType, + TAuthErrorInfo, + authErrorHandler, +} from "@/helpers/authentication.helper"; +// hooks +import { useInstance } from "@/hooks/store"; +// services +import { AuthService } from "@/services/auth.service"; + +const authService = new AuthService(); + +export const SignInAuthRoot = observer(() => { + //router + const router = useRouter(); + const { email: emailParam, invitation_id, slug: workspaceSlug, error_code, error_message } = router.query; + // states + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [isLoading, setIsLoading] = useState(false); + const [errorInfo, setErrorInfo] = useState(undefined); + // hooks + const { instance } = useInstance(); + // derived values + const authMode = EAuthModes.SIGN_IN; + + useEffect(() => { + if (error_code && error_message) { + const errorhandler = authErrorHandler( + error_code?.toString() as EAuthenticationErrorCodes, + error_message?.toString() + ); + if (errorhandler) setErrorInfo(errorhandler); + } + }, [error_code, error_message]); + + // step 1 submit handler- email verification + const handleEmailVerification = async (data: IEmailCheckData) => { + setEmail(data.email); + + await authService + .signInEmailCheck(data) + .then(() => { + setAuthStep(EAuthSteps.PASSWORD); + }) + .catch((err) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.error_message ?? "Something went wrong. Please try again.", + }); + }); + }; + + const isOAuthEnabled = + instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled); + + if (isLoading) + return ( +
+ +
+ ); + + return ( + <> +
+ + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} + {authStep === EAuthSteps.EMAIL && } + {authStep === EAuthSteps.UNIQUE_CODE && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + submitButtonText="Continue" + mode={authMode} + /> + )} + {authStep === EAuthSteps.PASSWORD && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + handleStepChange={(step) => setAuthStep(step)} + mode={authMode} + /> + )} + {isOAuthEnabled && } + +
+ + ); +}); diff --git a/web/components/account/auth-forms/sign-up-root.tsx b/web/components/account/auth-forms/sign-up-root.tsx new file mode 100644 index 000000000..55f3ffc99 --- /dev/null +++ b/web/components/account/auth-forms/sign-up-root.tsx @@ -0,0 +1,133 @@ +import { FC, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +// types +import { IEmailCheckData } from "@plane/types"; +// ui +import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { + AuthHeader, + AuthBanner, + AuthEmailForm, + AuthUniqueCodeForm, + AuthPasswordForm, + OAuthOptions, + TermsAndConditions, +} from "@/components/account"; +// helpers +import { + EAuthModes, + EAuthSteps, + EAuthenticationErrorCodes, + EErrorAlertType, + TAuthErrorInfo, + authErrorHandler, +} from "@/helpers/authentication.helper"; +// hooks +import { useInstance } from "@/hooks/store"; +// services +import { AuthService } from "@/services/auth.service"; + +// service initialization +const authService = new AuthService(); + +export const SignUpAuthRoot: FC = observer(() => { + //router + const router = useRouter(); + const { email: emailParam, invitation_id, slug: workspaceSlug, error_code, error_message } = router.query; + // states + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [isLoading, setIsLoading] = useState(false); + const [errorInfo, setErrorInfo] = useState(undefined); + // hooks + const { instance } = useInstance(); + // derived values + const authMode = EAuthModes.SIGN_UP; + const isSmtpConfigured = instance?.config?.is_smtp_configured; + + useEffect(() => { + if (error_code && error_message) { + const errorhandler = authErrorHandler( + error_code?.toString() as EAuthenticationErrorCodes, + error_message?.toString() + ); + if (errorhandler) setErrorInfo(errorhandler); + } + }, [error_code, error_message]); + + // email verification + const handleEmailVerification = async (data: IEmailCheckData) => { + setEmail(data.email); + await authService + .signUpEmailCheck(data) + .then(() => { + if (isSmtpConfigured) setAuthStep(EAuthSteps.PASSWORD); + else setAuthStep(EAuthSteps.PASSWORD); + }) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code, error?.error_message); + if (errorhandler) { + setErrorInfo(errorhandler); + return; + } + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error?.error_message ?? "Something went wrong. Please try again.", + }); + }); + }; + + const isOAuthEnabled = + instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled); + + if (isLoading) + return ( +
+ +
+ ); + + return ( +
+ + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} + {authStep === EAuthSteps.EMAIL && } + {authStep === EAuthSteps.UNIQUE_CODE && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + submitButtonText="Continue" + mode={authMode} + /> + )} + {authStep === EAuthSteps.PASSWORD && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + handleStepChange={(step) => setAuthStep(step)} + mode={authMode} + /> + )} + {isOAuthEnabled && } + +
+ ); +}); diff --git a/web/components/account/auth-forms/unique-code.tsx b/web/components/account/auth-forms/unique-code.tsx index 7aa51d45b..b0f6e6373 100644 --- a/web/components/account/auth-forms/unique-code.tsx +++ b/web/components/account/auth-forms/unique-code.tsx @@ -1,17 +1,14 @@ import React, { useEffect, useState } from "react"; import { CircleCheck, XCircle } from "lucide-react"; -// types import { IEmailCheckData } from "@plane/types"; -// ui import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; -// constants // helpers +import { EAuthModes } from "@/helpers/authentication.helper"; import { API_BASE_URL } from "@/helpers/common.helper"; // hooks import useTimer from "@/hooks/use-timer"; // services import { AuthService } from "@/services/auth.service"; -import { EAuthModes } from "./root"; type Props = { email: string; @@ -33,7 +30,7 @@ const defaultValues: TUniqueCodeFormValues = { // services const authService = new AuthService(); -export const UniqueCodeForm: React.FC = (props) => { +export const AuthUniqueCodeForm: React.FC = (props) => { const { email, handleEmailClear, submitButtonText, mode } = props; // states const [uniqueCodeFormData, setUniqueCodeFormData] = useState({ ...defaultValues, email }); @@ -72,10 +69,10 @@ export const UniqueCodeForm: React.FC = (props) => { ); }; - const handleRequestNewCode = async () => { + const handleRequestNewCode = async (email: string) => { setIsRequestingNewCode(true); - await handleSendNewCode(uniqueCodeFormData.email) + await handleSendNewCode(email) .then(() => setResendCodeTimer(30)) .finally(() => setIsRequestingNewCode(false)); }; @@ -86,10 +83,7 @@ export const UniqueCodeForm: React.FC = (props) => { }, [csrfToken]); useEffect(() => { - setIsRequestingNewCode(true); - handleSendNewCode(email) - .then(() => setResendCodeTimer(30)) - .finally(() => setIsRequestingNewCode(false)); + handleRequestNewCode(email); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -149,7 +143,7 @@ export const UniqueCodeForm: React.FC = (props) => {

- diff --git a/web/components/core/list/index.ts b/web/components/core/list/index.ts new file mode 100644 index 000000000..d5489c45e --- /dev/null +++ b/web/components/core/list/index.ts @@ -0,0 +1,2 @@ +export * from "./list-item"; +export * from "./list-root"; diff --git a/web/components/core/list/list-item.tsx b/web/components/core/list/list-item.tsx new file mode 100644 index 000000000..ae32c9b31 --- /dev/null +++ b/web/components/core/list/list-item.tsx @@ -0,0 +1,56 @@ +import React, { FC } from "react"; +import Link from "next/link"; +// ui +import { Tooltip } from "@plane/ui"; + +interface IListItemProps { + title: string; + itemLink: string; + onItemClick?: (e: React.MouseEvent) => void; + prependTitleElement?: JSX.Element; + appendTitleElement?: JSX.Element; + actionableItems?: JSX.Element; + isMobile?: boolean; + parentRef: React.RefObject; +} + +export const ListItem: FC = (props) => { + const { + title, + prependTitleElement, + appendTitleElement, + actionableItems, + itemLink, + onItemClick, + isMobile = false, + parentRef, + } = props; + + return ( +
+ +
+
+
+
+ {prependTitleElement && {prependTitleElement}} + + {title} + +
+ {appendTitleElement && {appendTitleElement}} +
+
+ +
+ + {actionableItems && ( +
+
+ {actionableItems} +
+
+ )} +
+ ); +}; diff --git a/web/components/core/list/list-root.tsx b/web/components/core/list/list-root.tsx new file mode 100644 index 000000000..c9c465f27 --- /dev/null +++ b/web/components/core/list/list-root.tsx @@ -0,0 +1,10 @@ +import React, { FC } from "react"; + +interface IListContainer { + children: React.ReactNode; +} + +export const ListLayout: FC = (props) => { + const { children } = props; + return
{children}
; +}; diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx index d928b5fbb..9b63b0f6f 100644 --- a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx +++ b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -20,6 +21,8 @@ type Props = { export const UpcomingCycleListItem: React.FC = observer((props) => { const { cycleId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -90,6 +93,7 @@ export const UpcomingCycleListItem: React.FC = observer((props) => { return ( @@ -123,6 +127,7 @@ export const UpcomingCycleListItem: React.FC = observer((props) => { {workspaceSlug && projectId && ( = observer((props) => { const { cycleId, workspaceSlug, projectId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); // store @@ -150,7 +152,7 @@ export const CyclesBoardCard: FC = observer((props) => { return (
- +
@@ -246,7 +248,12 @@ export const CyclesBoardCard: FC = observer((props) => { /> )} - +
); diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx index 50cf6df97..97cf18cf9 100644 --- a/web/components/cycles/cycles-view-header.tsx +++ b/web/components/cycles/cycles-view-header.tsx @@ -76,7 +76,7 @@ export const CyclesViewHeader: React.FC = observer((props) => { }; return ( -
+
{CYCLE_TABS_LIST.map((tab) => ( ; +}; + +export const CycleListItemAction: FC = observer((props) => { + const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef } = props; + // hooks + const { isMobile } = usePlatformOS(); + // store hooks + const { addCycleToFavorites, removeCycleFromFavorites } = useCycle(); + const { captureEvent } = useEventTracker(); + const { + membership: { currentProjectRole }, + } = useUser(); + const { getUserDetails } = useMember(); + + // derived values + const endDate = getDate(cycleDetails.end_date); + const startDate = getDate(cycleDetails.start_date); + const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + const renderDate = cycleDetails.start_date || cycleDetails.end_date; + const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; + + // handlers + const handleAddToFavorites = (e: MouseEvent) => { + e.preventDefault(); + if (!workspaceSlug || !projectId) return; + + const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( + () => { + captureEvent(CYCLE_FAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + } + ); + + setPromiseToast(addToFavoritePromise, { + loading: "Adding cycle to favorites...", + success: { + title: "Success!", + message: () => "Cycle added to favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't add the cycle to favorites. Please try again.", + }, + }); + }; + + const handleRemoveFromFavorites = (e: MouseEvent) => { + e.preventDefault(); + if (!workspaceSlug || !projectId) return; + + const removeFromFavoritePromise = removeCycleFromFavorites( + workspaceSlug?.toString(), + projectId.toString(), + cycleId + ).then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + }); + + setPromiseToast(removeFromFavoritePromise, { + loading: "Removing cycle from favorites...", + success: { + title: "Success!", + message: () => "Cycle removed from favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't remove the cycle from favorites. Please try again.", + }, + }); + }; + + return ( + <> +
+ {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} +
+ + {currentCycle && ( +
+ {currentCycle.value === "current" + ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` + : `${currentCycle.label}`} +
+ )} + + +
+ {cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? ( + + {cycleDetails.assignee_ids?.map((assignee_id) => { + const member = getUserDetails(assignee_id); + return ; + })} + + ) : ( + + + + )} +
+
+ + {isEditingAllowed && !cycleDetails.archived_at && ( + { + if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); + else handleAddToFavorites(e); + }} + selected={!!cycleDetails.is_favorite} + /> + )} + + + ); +}); diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index a6262dfe7..1f70d79c2 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -1,24 +1,17 @@ -import { FC, MouseEvent } from "react"; +import { FC, MouseEvent, useRef } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; import { useRouter } from "next/router"; // icons -import { Check, Info, User2 } from "lucide-react"; +import { Check, Info } from "lucide-react"; // types import type { TCycleGroups } from "@plane/types"; // ui -import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; +import { CircularProgressIndicator } from "@plane/ui"; // components -import { FavoriteStar } from "@/components/core"; -import { CycleQuickActions } from "@/components/cycles"; -// constants -import { CYCLE_STATUS } from "@/constants/cycle"; -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; -import { EUserProjectRoles } from "@/constants/project"; -// helpers -import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper"; +import { ListItem } from "@/components/core/list"; +import { CycleListItemAction } from "@/components/cycles/list"; // hooks -import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store"; +import { useCycle } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; type TCyclesListItem = { @@ -29,79 +22,41 @@ type TCyclesListItem = { handleRemoveFromFavorites?: () => void; workspaceSlug: string; projectId: string; - isArchived?: boolean; }; export const CyclesListItem: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId, isArchived } = props; + const { cycleId, workspaceSlug, projectId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); // hooks const { isMobile } = usePlatformOS(); // store hooks - const { captureEvent } = useEventTracker(); - const { - membership: { currentProjectRole }, - } = useUser(); - const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); - const { getUserDetails } = useMember(); + const { getCycleById } = useCycle(); - const handleAddToFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; + // derived values + const cycleDetails = getCycleById(cycleId); - const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( - () => { - captureEvent(CYCLE_FAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - } - ); + if (!cycleDetails) return null; - setPromiseToast(addToFavoritePromise, { - loading: "Adding cycle to favorites...", - success: { - title: "Success!", - message: () => "Cycle added to favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't add the cycle to favorites. Please try again.", - }, - }); - }; + // computed + // TODO: change this logic once backend fix the response + const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const isCompleted = cycleStatus === "completed"; - const handleRemoveFromFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; + const cycleTotalIssues = + cycleDetails.backlog_issues + + cycleDetails.unstarted_issues + + cycleDetails.started_issues + + cycleDetails.completed_issues + + cycleDetails.cancelled_issues; - const removeFromFavoritePromise = removeCycleFromFavorites( - workspaceSlug?.toString(), - projectId.toString(), - cycleId - ).then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - }); + const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; - setPromiseToast(removeFromFavoritePromise, { - loading: "Removing cycle from favorites...", - success: { - title: "Success!", - message: () => "Cycle removed from favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't remove the cycle from favorites. Please try again.", - }, - }); - }; + const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); + // handlers const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); @@ -121,139 +76,47 @@ export const CyclesListItem: FC = observer((props) => { } }; - const cycleDetails = getCycleById(cycleId); - - if (!cycleDetails) return null; - - // computed - // TODO: change this logic once backend fix the response - const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; - const isCompleted = cycleStatus === "completed"; - const endDate = getDate(cycleDetails.end_date); - const startDate = getDate(cycleDetails.start_date); - - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - - const cycleTotalIssues = - cycleDetails.backlog_issues + - cycleDetails.unstarted_issues + - cycleDetails.started_issues + - cycleDetails.completed_issues + - cycleDetails.cancelled_issues; - - const renderDate = cycleDetails.start_date || cycleDetails.end_date; - - // const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); - - const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; - - const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); - - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); - - const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; - return ( -
- { - if (isArchived) { - openCycleOverview(e); - } - }} - > -
-
-
-
- - {isCompleted ? ( - progress === 100 ? ( - - ) : ( - {`!`} - ) - ) : progress === 100 ? ( - - ) : ( - {`${progress}%`} - )} - -
- -
- - - - {cycleDetails.name} - - -
- - -
-
- {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} -
-
- -
- -
-
- {currentCycle && ( -
- {currentCycle.value === "current" - ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` - : `${currentCycle.label}`} -
+ { + if (cycleDetails.archived_at) openCycleOverview(e); + }} + prependTitleElement={ + + {isCompleted ? ( + progress === 100 ? ( + + ) : ( + {`!`} + ) + ) : progress === 100 ? ( + + ) : ( + {`${progress}%`} )} - -
- -
- {cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? ( - - {cycleDetails.assignee_ids?.map((assignee_id) => { - const member = getUserDetails(assignee_id); - return ; - })} - - ) : ( - - - - )} -
-
- - {isEditingAllowed && !isArchived && ( - { - if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycleDetails.is_favorite} - /> - )} - -
-
-
-
+ + } + appendTitleElement={ + + } + actionableItems={ + + } + isMobile={isMobile} + parentRef={parentRef} + /> ); }); diff --git a/web/components/cycles/list/cycles-list-map.tsx b/web/components/cycles/list/cycles-list-map.tsx index 7a99f5ab7..004c66fca 100644 --- a/web/components/cycles/list/cycles-list-map.tsx +++ b/web/components/cycles/list/cycles-list-map.tsx @@ -5,22 +5,15 @@ type Props = { cycleIds: string[]; projectId: string; workspaceSlug: string; - isArchived?: boolean; }; export const CyclesListMap: React.FC = (props) => { - const { cycleIds, projectId, workspaceSlug, isArchived } = props; + const { cycleIds, projectId, workspaceSlug } = props; return ( <> {cycleIds.map((cycleId) => ( - + ))} ); diff --git a/web/components/cycles/list/index.ts b/web/components/cycles/list/index.ts index 46a3557d7..5eda32861 100644 --- a/web/components/cycles/list/index.ts +++ b/web/components/cycles/list/index.ts @@ -1,3 +1,4 @@ export * from "./cycles-list-item"; export * from "./cycles-list-map"; export * from "./root"; +export * from "./cycle-list-item-action"; diff --git a/web/components/cycles/list/root.tsx b/web/components/cycles/list/root.tsx index 622ca1ae0..34e34acf0 100644 --- a/web/components/cycles/list/root.tsx +++ b/web/components/cycles/list/root.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite"; import { ChevronRight } from "lucide-react"; import { Disclosure } from "@headlessui/react"; // components +import { ListLayout } from "@/components/core/list"; import { CyclePeekOverview, CyclesListMap } from "@/components/cycles"; // helpers import { cn } from "@/helpers/common.helper"; @@ -21,12 +22,11 @@ export const CyclesList: FC = observer((props) => { return (
-
+ {completedCycleIds.length !== 0 && ( @@ -43,16 +43,11 @@ export const CyclesList: FC = observer((props) => { )} - + )} -
+
diff --git a/web/components/cycles/quick-actions.tsx b/web/components/cycles/quick-actions.tsx index 9ba012cc4..194bdd068 100644 --- a/web/components/cycles/quick-actions.tsx +++ b/web/components/cycles/quick-actions.tsx @@ -2,27 +2,28 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // icons -import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react"; +import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react"; // ui -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles"; // constants import { EUserProjectRoles } from "@/constants/project"; // helpers +import { cn } from "@/helpers/common.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useCycle, useEventTracker, useUser } from "@/hooks/store"; type Props = { + parentRef: React.RefObject; cycleId: string; projectId: string; workspaceSlug: string; - isArchived?: boolean; }; export const CycleQuickActions: React.FC = observer((props) => { - const { cycleId, projectId, workspaceSlug, isArchived } = props; + const { parentRef, cycleId, projectId, workspaceSlug } = props; // router const router = useRouter(); // states @@ -37,40 +38,31 @@ export const CycleQuickActions: React.FC = observer((props) => { const { getCycleById, restoreCycle } = useCycle(); // derived values const cycleDetails = getCycleById(cycleId); + const isArchived = !!cycleDetails?.archived_at; const isCompleted = cycleDetails?.status?.toLowerCase() === "completed"; // auth const isEditingAllowed = !!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER; - const handleCopyText = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => { + const cycleLink = `${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`; + const handleCopyText = () => + copyUrlToClipboard(cycleLink).then(() => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Cycle link copied to clipboard.", }); }); - }; + const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank"); - const handleEditCycle = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleEditCycle = () => { setTrackElement("Cycles page list layout"); setUpdateModal(true); }; - const handleArchiveCycle = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setArchiveCycleModal(true); - }; + const handleArchiveCycle = () => setArchiveCycleModal(true); - const handleRestoreCycle = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleRestoreCycle = async () => await restoreCycle(workspaceSlug, projectId, cycleId) .then(() => { setToast({ @@ -87,15 +79,61 @@ export const CycleQuickActions: React.FC = observer((props) => { message: "Cycle could not be restored. Please try again.", }) ); - }; - const handleDeleteCycle = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleDeleteCycle = () => { setTrackElement("Cycles page list layout"); setDeleteModal(true); }; + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: handleEditCycle, + shouldRender: isEditingAllowed && !isCompleted && !isArchived, + }, + { + key: "open-new-tab", + action: handleOpenInNewTab, + title: "Open in new tab", + icon: ExternalLink, + shouldRender: !isArchived, + }, + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: LinkIcon, + shouldRender: !isArchived, + }, + { + key: "archive", + action: handleArchiveCycle, + title: "Archive", + description: isCompleted ? undefined : "Only completed cycle can\nbe archived.", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + shouldRender: isEditingAllowed && !isArchived, + disabled: !isCompleted, + }, + { + key: "restore", + action: handleRestoreCycle, + title: "Restore", + icon: ArchiveRestoreIcon, + shouldRender: isEditingAllowed && isArchived, + }, + { + key: "delete", + action: handleDeleteCycle, + title: "Delete", + icon: Trash2, + shouldRender: isEditingAllowed && !isCompleted && !isArchived, + }, + ]; + return ( <> {cycleDetails && ( @@ -123,60 +161,42 @@ export const CycleQuickActions: React.FC = observer((props) => { />
)} - - {!isCompleted && isEditingAllowed && !isArchived && ( - - - - Edit cycle - - - )} - {isEditingAllowed && !isArchived && ( - - {isCompleted ? ( -
- - Archive cycle -
- ) : ( -
- -
-

Archive cycle

-

- Only completed cycle
can be archived. + + + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isEditingAllowed && isArchived && ( - - - - Restore cycle - - - )} - {!isArchived && ( - - - - Copy cycle link - - - )} -
- {!isCompleted && isEditingAllowed && ( - - - - Delete cycle - - - )} + + ); + })} ); diff --git a/web/components/gantt-chart/blocks/block.tsx b/web/components/gantt-chart/blocks/block.tsx index e0e464cf0..0897b6b39 100644 --- a/web/components/gantt-chart/blocks/block.tsx +++ b/web/components/gantt-chart/blocks/block.tsx @@ -36,7 +36,7 @@ export const GanttChartBlock: React.FC = observer((props) => { } = props; // store hooks const { updateActiveBlockId, isBlockActive } = useGanttChart(); - const { peekIssue } = useIssueDetail(); + const { getIsIssuePeeked } = useIssueDetail(); const isBlockVisibleOnChart = block.start_date && block.target_date; @@ -81,8 +81,9 @@ export const GanttChartBlock: React.FC = observer((props) => {
updateActiveBlockId(block.id)} onMouseLeave={() => updateActiveBlockId(null)} diff --git a/web/components/gantt-chart/sidebar/issues/block.tsx b/web/components/gantt-chart/sidebar/issues/block.tsx index ae670941d..bd84af92c 100644 --- a/web/components/gantt-chart/sidebar/issues/block.tsx +++ b/web/components/gantt-chart/sidebar/issues/block.tsx @@ -25,7 +25,7 @@ export const IssuesSidebarBlock: React.FC = observer((props) => { const { block, enableReorder, provided, snapshot } = props; // store hooks const { updateActiveBlockId, isBlockActive } = useGanttChart(); - const { peekIssue } = useIssueDetail(); + const { getIsIssuePeeked } = useIssueDetail(); const duration = findTotalDaysInRange(block.start_date, block.target_date); @@ -33,8 +33,9 @@ export const IssuesSidebarBlock: React.FC = observer((props) => {
updateActiveBlockId(block.id)} onMouseLeave={() => updateActiveBlockId(null)} diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index 40af27b38..e5c88d3f5 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -1,15 +1,16 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; +// icons import { Plus } from "lucide-react"; -// hooks // ui import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; -// helpers // components import { BreadcrumbLink } from "@/components/common"; import { ProjectLogo } from "@/components/project"; +// constants import { EUserProjectRoles } from "@/constants/project"; +// hooks import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store"; export const CyclesHeader: FC = observer(() => { @@ -28,52 +29,48 @@ export const CyclesHeader: FC = observer(() => { currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); return ( -
-
-
-
- - - - - ) - } - /> - } - /> - } /> - } - /> - -
+
+
+
+ + + + + ) + } + /> + } + /> + } />} + /> +
- {canUserCreateCycle && ( -
- -
- )}
+ {canUserCreateCycle && ( +
+ +
+ )}
); }); diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index 1b76b4e99..bb27f1a90 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -1,32 +1,21 @@ -import { useCallback, useRef, useState } from "react"; -import { observer } from "mobx-react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -import { ListFilter, Plus, Search, X } from "lucide-react"; -// types -import { TModuleFilters } from "@plane/types"; +// icons +import { Plus } from "lucide-react"; // ui -import { Breadcrumbs, Button, Tooltip, DiceIcon } from "@plane/ui"; +import { Breadcrumbs, Button, DiceIcon } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common"; -import { FiltersDropdown } from "@/components/issues"; -import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules"; import { ProjectLogo } from "@/components/project"; // constants -import { MODULE_VIEW_LAYOUTS } from "@/constants/module"; import { EUserProjectRoles } from "@/constants/project"; -// helpers -import { cn } from "@/helpers/common.helper"; // hooks -import { useEventTracker, useMember, useModuleFilter, useProject, useUser, useCommandPalette } from "@/hooks/store"; -import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -import { usePlatformOS } from "@/hooks/use-platform-os"; +import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store"; export const ModulesListHeader: React.FC = observer(() => { - // refs - const inputRef = useRef(null); // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; // store hooks const { toggleCreateModuleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); @@ -34,54 +23,6 @@ export const ModulesListHeader: React.FC = observer(() => { membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); - const { isMobile } = usePlatformOS(); - const { - workspace: { workspaceMemberIds }, - } = useMember(); - const { - currentProjectDisplayFilters: displayFilters, - currentProjectFilters: filters, - searchQuery, - updateDisplayFilters, - updateFilters, - updateSearchQuery, - } = useModuleFilter(); - // states - const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false); - // outside click detector hook - useOutsideClickDetector(inputRef, () => { - if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); - }); - - const handleFilters = useCallback( - (key: keyof TModuleFilters, value: string | string[]) => { - if (!projectId) return; - const newValues = filters?.[key] ?? []; - - if (Array.isArray(value)) - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - else { - if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters(projectId.toString(), { [key]: newValues }); - }, - [filters, projectId, updateFilters] - ); - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); - else { - setIsSearchOpen(false); - inputRef.current?.blur(); - } - } - }; // auth const canUserCreateModule = @@ -116,97 +57,6 @@ export const ModulesListHeader: React.FC = observer(() => {
-
- {!isSearchOpen && ( - - )} -
- - updateSearchQuery(e.target.value)} - onKeyDown={handleInputKeyDown} - /> - {isSearchOpen && ( - - )} -
-
-
- {MODULE_VIEW_LAYOUTS.map((layout) => ( - - - - ))} -
- { - if (!projectId || val === displayFilters?.order_by) return; - updateDisplayFilters(projectId.toString(), { - order_by: val, - }); - }} - /> - } title="Filters" placement="bottom-end"> - { - if (!projectId) return; - updateDisplayFilters(projectId.toString(), val); - }} - handleFiltersUpdate={handleFilters} - memberIds={workspaceMemberIds ?? undefined} - /> - {canUserCreateModule && ( - - + + {issueCount} + + + ) : null} +
+ {currentProjectDetails?.is_deployed && deployUrl && ( + + + Public + + )}
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> + + + + + + +
+ + {canUserCreateIssue && ( + <> + + + + )}
); diff --git a/web/components/inbox/content/issue-root.tsx b/web/components/inbox/content/issue-root.tsx index b9d581ee5..ce1625141 100644 --- a/web/components/inbox/content/issue-root.tsx +++ b/web/components/inbox/content/issue-root.tsx @@ -26,13 +26,11 @@ type Props = { isEditable: boolean; isSubmitting: "submitting" | "submitted" | "saved"; setIsSubmitting: Dispatch>; - swrIssueDescription: string | undefined; }; export const InboxIssueMainContent: React.FC = observer((props) => { const router = useRouter(); - const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting, swrIssueDescription } = - props; + const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props; // hooks const { data: currentUser } = useUser(); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); @@ -137,7 +135,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} - swrIssueDescription={swrIssueDescription} + swrIssueDescription={null} initialValue={issue.description_html ?? "

"} disabled={!isEditable} issueOperations={issueOperations} diff --git a/web/components/inbox/content/root.tsx b/web/components/inbox/content/root.tsx index 1ffe35a46..719b1c19f 100644 --- a/web/components/inbox/content/root.tsx +++ b/web/components/inbox/content/root.tsx @@ -24,14 +24,13 @@ export const InboxContentRoot: FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); - const { data: swrIssueDetails } = useSWR( + useSWR( workspaceSlug && projectId && inboxIssueId ? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}` : null, workspaceSlug && projectId && inboxIssueId ? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId) - : null, - { revalidateOnFocus: true } + : null ); const isEditable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -61,7 +60,6 @@ export const InboxContentRoot: FC = observer((props) => { isEditable={isEditable && !isIssueDisabled} isSubmitting={isSubmitting} setIsSubmitting={setIsSubmitting} - swrIssueDescription={swrIssueDetails?.issue.description_html} />
diff --git a/web/components/inbox/modals/create-edit-modal/create-root.tsx b/web/components/inbox/modals/create-edit-modal/create-root.tsx index de8e0d3ce..6c5ee3ce2 100644 --- a/web/components/inbox/modals/create-edit-modal/create-root.tsx +++ b/web/components/inbox/modals/create-edit-modal/create-root.tsx @@ -136,9 +136,12 @@ export const InboxIssueCreateRoot: FC = observer((props) />
-
setCreateMore((prevData) => !prevData)}> +
setCreateMore((prevData) => !prevData)} + > + {}} size="sm" /> Create more - {}} size="md" />
); diff --git a/web/components/inbox/sidebar/inbox-list-item.tsx b/web/components/inbox/sidebar/inbox-list-item.tsx index 21ac42d1e..593d114d1 100644 --- a/web/components/inbox/sidebar/inbox-list-item.tsx +++ b/web/components/inbox/sidebar/inbox-list-item.tsx @@ -50,7 +50,7 @@ export const InboxIssueListItem: FC = observer((props)
diff --git a/web/components/inbox/sidebar/root.tsx b/web/components/inbox/sidebar/root.tsx index 9521e6571..f33cb3c2f 100644 --- a/web/components/inbox/sidebar/root.tsx +++ b/web/components/inbox/sidebar/root.tsx @@ -65,14 +65,14 @@ export const InboxSidebar: FC = observer((props) => { }); return ( -
+
-
+
{tabNavigationOptions.map((option) => (
{ diff --git a/web/components/instance/not-ready-view.tsx b/web/components/instance/not-ready-view.tsx index 6718f939a..466bcf964 100644 --- a/web/components/instance/not-ready-view.tsx +++ b/web/components/instance/not-ready-view.tsx @@ -1,44 +1,42 @@ import { FC } from "react"; import Image from "next/image"; import { useTheme } from "next-themes"; -// icons -import { UserCog2 } from "lucide-react"; -// ui -import { getButtonStyling } from "@plane/ui"; +import { Button } from "@plane/ui"; // images -import instanceNotReady from "public/instance/plane-instance-not-ready.webp"; -import PlaneBlackLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg"; -import PlaneWhiteLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg"; - -type TInstanceNotReady = { - isGodModeEnabled: boolean; - handleGodModeStateChange?: (state: boolean) => void; -}; - -export const InstanceNotReady: FC = () => { - // const { isGodModeEnabled, handleGodModeStateChange } = props; +import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.svg"; +import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.svg"; +import PlaneTakeOffImage from "@/public/plane-takeoff.png"; +export const InstanceNotReady: FC = () => { const { resolvedTheme } = useTheme(); - const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo; + const planeGodModeUrl = `${process.env.NEXT_PUBLIC_GOD_MODE_URL}/god-mode/setup/?auth_enabled=0`; + return ( -
-
-
-
-
- Plane logo +
+
+
+
+ Plane logo +
+
+
+
+
+
+
+

Welcome aboard Plane!

+ Plane Logo +

+ Get started by setting up your instance and workspace +

-
- Instance not ready -
-
-

Your Plane instance isn{"'"}t ready yet

-

Ask your Instance Admin to complete set-up first.

- - - Get started +
diff --git a/web/components/issues/description-input.tsx b/web/components/issues/description-input.tsx index aededa28f..b8cfbd582 100644 --- a/web/components/issues/description-input.tsx +++ b/web/components/issues/description-input.tsx @@ -9,6 +9,8 @@ import { Loader } from "@plane/ui"; // components import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor"; import { TIssueOperations } from "@/components/issues/issue-detail"; +// helpers +import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks import { useWorkspace } from "@/hooks/store"; @@ -19,7 +21,7 @@ export type IssueDescriptionInputProps = { initialValue: string | undefined; disabled?: boolean; issueOperations: TIssueOperations; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void; swrIssueDescription: string | null | undefined; }; @@ -106,12 +108,7 @@ export const IssueDescriptionInput: FC = observer((p debouncedFormSave(); }} placeholder={ - placeholder - ? placeholder - : (isFocused) => { - if (isFocused) return "Press '/' for commands..."; - else return "Click to add description"; - } + placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value) } /> ) : ( 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 29e0512ba..9e003977f 100644 --- a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -89,8 +89,9 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { groupedIssueIds={groupedIssueIds} layout={displayFilters?.calendar?.layout} showWeekends={displayFilters?.calendar?.show_weekends ?? false} - quickActions={(issue, customActionButton, placement) => ( + quickActions={({ issue, parentRef, customActionButton, placement }) => ( removeIssue(issue.project_id, issue.id)} diff --git a/web/components/issues/issue-layouts/calendar/calendar.tsx b/web/components/issues/issue-layouts/calendar/calendar.tsx index 0805dfb61..c4110bc13 100644 --- a/web/components/issues/issue-layouts/calendar/calendar.tsx +++ b/web/components/issues/issue-layouts/calendar/calendar.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; // types import type { @@ -31,7 +30,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle"; import { IModuleIssuesFilter } from "@/store/issue/module"; import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; -// types +import { TRenderQuickActions } from "../list/list-view-types"; import type { ICalendarWeek } from "./types"; type Props = { @@ -40,7 +39,7 @@ type Props = { groupedIssueIds: TGroupedIssues; layout: "month" | "week" | undefined; showWeekends: boolean; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; quickAddCallback?: ( workspaceSlug: string, projectId: string, diff --git a/web/components/issues/issue-layouts/calendar/day-tile.tsx b/web/components/issues/issue-layouts/calendar/day-tile.tsx index 3fd96d00e..8f162869a 100644 --- a/web/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/web/components/issues/issue-layouts/calendar/day-tile.tsx @@ -1,5 +1,4 @@ import { Droppable } from "@hello-pangea/dnd"; -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; // types import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; @@ -15,13 +14,14 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle"; import { IModuleIssuesFilter } from "@/store/issue/module"; import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { TRenderQuickActions } from "../list/list-view-types"; type Props = { issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; date: ICalendarDate; issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; quickAddCallback?: ( diff --git a/web/components/issues/issue-layouts/calendar/issue-block-root.tsx b/web/components/issues/issue-layouts/calendar/issue-block-root.tsx index f7fd7ab52..efedcf50b 100644 --- a/web/components/issues/issue-layouts/calendar/issue-block-root.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-block-root.tsx @@ -1,14 +1,14 @@ import React from "react"; -import { Placement } from "@popperjs/core"; // components -import { TIssue, TIssueMap } from "@plane/types"; +import { TIssueMap } from "@plane/types"; import { CalendarIssueBlock } from "@/components/issues"; +import { TRenderQuickActions } from "../list/list-view-types"; // types type Props = { issues: TIssueMap | undefined; issueId: string; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; isDragging?: boolean; }; diff --git a/web/components/issues/issue-layouts/calendar/issue-block.tsx b/web/components/issues/issue-layouts/calendar/issue-block.tsx index 26081dadc..01d7615f3 100644 --- a/web/components/issues/issue-layouts/calendar/issue-block.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-block.tsx @@ -1,5 +1,4 @@ import { useState, useRef } from "react"; -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; import { MoreHorizontal } from "lucide-react"; import { TIssue } from "@plane/types"; @@ -12,25 +11,27 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; // helpers // types import { usePlatformOS } from "@/hooks/use-platform-os"; +import { TRenderQuickActions } from "../list/list-view-types"; type Props = { issue: TIssue; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; isDragging?: boolean; }; export const CalendarIssueBlock: React.FC = observer((props) => { const { issue, quickActions, isDragging = false } = props; + // states + const [isMenuActive, setIsMenuActive] = useState(false); + // refs + const blockRef = useRef(null); + const menuActionRef = useRef(null); // hooks const { workspaceSlug, projectId } = useAppRouter(); const { getProjectIdentifierById } = useProject(); const { getProjectStates } = useProjectState(); - const { peekIssue, setPeekIssue } = useIssueDetail(); + const { getIsIssuePeeked, setPeekIssue } = useIssueDetail(); const { isMobile } = usePlatformOS(); - // states - const [isMenuActive, setIsMenuActive] = useState(false); - - const menuActionRef = useRef(null); const stateColor = getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || ""; @@ -39,7 +40,7 @@ export const CalendarIssueBlock: React.FC = observer((props) => { issue && issue.project_id && issue.id && - peekIssue?.issueId !== issue.id && + !getIsIssuePeeked(issue.id) && setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); @@ -76,14 +77,13 @@ export const CalendarIssueBlock: React.FC = observer((props) => { )}
@@ -110,7 +110,12 @@ export const CalendarIssueBlock: React.FC = observer((props) => { e.stopPropagation(); }} > - {quickActions(issue, customActionButton, placement)} + {quickActions({ + issue, + parentRef: blockRef, + customActionButton, + placement, + })}
diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index ced7991b8..86cecdf20 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -1,6 +1,5 @@ import { useState } from "react"; import { Draggable } from "@hello-pangea/dnd"; -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; // types import { TIssue, TIssueMap } from "@plane/types"; @@ -8,12 +7,14 @@ import { TIssue, TIssueMap } from "@plane/types"; import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues"; // helpers import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { TRenderQuickActions } from "../list/list-view-types"; +// types type Props = { date: Date; issues: TIssueMap | undefined; issueIdList: string[] | null; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; isDragDisabled?: boolean; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; diff --git a/web/components/issues/issue-layouts/calendar/week-days.tsx b/web/components/issues/issue-layouts/calendar/week-days.tsx index 0614df240..0ac4d30b0 100644 --- a/web/components/issues/issue-layouts/calendar/week-days.tsx +++ b/web/components/issues/issue-layouts/calendar/week-days.tsx @@ -1,4 +1,3 @@ -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; // components @@ -10,6 +9,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle"; import { IModuleIssuesFilter } from "@/store/issue/module"; import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { TRenderQuickActions } from "../list/list-view-types"; import { ICalendarDate, ICalendarWeek } from "./types"; type Props = { @@ -17,7 +17,7 @@ type Props = { issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; week: ICalendarWeek | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; quickAddCallback?: ( diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index daa446032..b4b10d71b 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -18,7 +18,7 @@ export const IssueGanttBlock: React.FC = observer((props) => { const { getProjectStates } = useProjectState(); const { issue: { getIssueById }, - peekIssue, + getIsIssuePeeked, setPeekIssue, } = useIssueDetail(); // derived values @@ -30,7 +30,7 @@ export const IssueGanttBlock: React.FC = observer((props) => { workspaceSlug && issueDetails && !issueDetails.tempId && - peekIssue?.issueId !== issueDetails.id && + !getIsIssuePeeked(issueDetails.id) && setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); const { isMobile } = usePlatformOS(); 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 8c6cf900b..0d394cbaa 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -4,7 +4,6 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -import { TIssue } from "@plane/types"; // hooks import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; import { DeleteIssueModal } from "@/components/issues"; @@ -15,7 +14,7 @@ import { useEventTracker, useIssueDetail, useIssues, useKanbanView, useUser } fr import { useIssuesActions } from "@/hooks/use-issues-actions"; // ui // types -import { IQuickActionProps } from "../list/list-view-types"; +import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types"; //components import { KanBan } from "./default"; import { KanBanSwimLanes } from "./swimlanes"; @@ -168,9 +167,10 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas }); }; - const renderQuickActions = useCallback( - (issue: TIssue, customActionButton?: React.ReactElement) => ( + const renderQuickActions: TRenderQuickActions = useCallback( + ({ issue, parentRef, customActionButton }) => ( removeIssue(issue.project_id, issue.id)} diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 191863e38..5c1e12378 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -1,4 +1,4 @@ -import { MutableRefObject, memo, useEffect, useRef, useState } from "react"; +import { MutableRefObject, 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 { observer } from "mobx-react-lite"; @@ -11,6 +11,7 @@ import { useAppRouter, useIssueDetail, useProject, useKanbanView } from "@/hooks import { usePlatformOS } from "@/hooks/use-platform-os"; // components +import { TRenderQuickActions } from "../list/list-view-types"; import { IssueProperties } from "../properties/all-properties"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; // ui @@ -18,29 +19,29 @@ import { WithDisplayPropertiesHOC } from "../properties/with-display-properties- // helper interface IssueBlockProps { - peekIssueId?: string; issueId: string; issuesMap: IIssueMap; displayProperties: IIssueDisplayProperties | undefined; isDragDisabled: boolean; draggableId: string; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization } interface IssueDetailsBlockProps { + cardRef: React.RefObject; issue: TIssue; displayProperties: IIssueDisplayProperties | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; isReadOnly: boolean; } -const KanbanIssueDetailsBlock: React.FC = observer((props: IssueDetailsBlockProps) => { - const { issue, updateIssue, quickActions, isReadOnly, displayProperties } = props; +const KanbanIssueDetailsBlock: React.FC = observer((props) => { + const { cardRef, issue, updateIssue, quickActions, isReadOnly, displayProperties } = props; // hooks const { isMobile } = usePlatformOS(); const { getProjectIdentifierById } = useProject(); @@ -61,7 +62,10 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop className="absolute -top-1 right-0 hidden group-hover/kanban-block:block" onClick={handleEventPropagation} > - {quickActions(issue)} + {quickActions({ + issue, + parentRef: cardRef, + })}
@@ -90,9 +94,8 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop ); }); -export const KanbanIssueBlock: React.FC = memo((props) => { +export const KanbanIssueBlock: React.FC = observer((props) => { const { - peekIssueId, issueId, issuesMap, displayProperties, @@ -104,17 +107,17 @@ export const KanbanIssueBlock: React.FC = memo((props) => { issueIds, } = props; + const cardRef = useRef(null); + // hooks const { workspaceSlug } = useAppRouter(); - const { peekIssue, setPeekIssue } = useIssueDetail(); - - const cardRef = useRef(null); + const { getIsIssuePeeked, setPeekIssue } = useIssueDetail(); const handleIssuePeekOverview = (issue: TIssue) => workspaceSlug && issue && issue.project_id && issue.id && - peekIssue?.issueId !== issue.id && + !getIsIssuePeeked(issue.id) && setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); const issue = issuesMap[issueId]; @@ -184,9 +187,11 @@ export const KanbanIssueBlock: React.FC = memo((props) => { ref={cardRef} className={cn( "block rounded border-[0.5px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400", - { "hover:cursor-pointer": isDragAllowed }, - { "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id }, - { "bg-custom-background-80 z-[100]": isCurrentBlockDragging } + { + "hover:cursor-pointer": isDragAllowed, + "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id), + "bg-custom-background-80 z-[100]": isCurrentBlockDragging, + } )} target="_blank" onClick={() => handleIssuePeekOverview(issue)} @@ -200,6 +205,7 @@ export const KanbanIssueBlock: React.FC = memo((props) => { changingReference={issueIds} > ) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; } @@ -23,7 +23,6 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { sub_group_id, columnId, issuesMap, - peekIssueId, issueIds, displayProperties, isDragDisabled, @@ -47,7 +46,6 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { return ( ) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: any; enableQuickIssueCreate?: boolean; @@ -95,7 +87,6 @@ const GroupByKanBan: React.FC = observer((props) => { const cycle = useCycle(); const moduleInfo = useModule(); const projectState = useProjectState(); - const { peekIssue } = useIssueDetail(); const list = getGroupByColumns( group_by as GroupByColumnTypes, @@ -176,7 +167,6 @@ const GroupByKanBan: React.FC = observer((props) => { groupId={subList.id} issuesMap={issuesMap} issueIds={issueIds} - peekIssueId={peekIssue?.issueId ?? ""} displayProperties={displayProperties} sub_group_by={sub_group_by} group_by={group_by} @@ -208,7 +198,7 @@ export interface IKanBan { group_by: TIssueGroupByOptions | undefined; sub_group_id?: string; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; showEmptyGroup: boolean; diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index d1161be2e..85f24d90b 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -17,13 +17,13 @@ import { cn } from "@/helpers/common.helper"; // hooks import { useProjectState } from "@/hooks/store"; //components +import { TRenderQuickActions } from "../list/list-view-types"; import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils"; import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; interface IKanbanGroup { groupId: string; issuesMap: IIssueMap; - peekIssueId?: string; issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; displayProperties: IIssueDisplayProperties | undefined; sub_group_by: TIssueGroupByOptions | undefined; @@ -31,7 +31,7 @@ interface IKanbanGroup { sub_group_id: string; isDragDisabled: boolean; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; enableQuickIssueCreate?: boolean; quickAddCallback?: ( workspaceSlug: string, @@ -56,7 +56,6 @@ export const KanbanGroup = (props: IKanbanGroup) => { issuesMap, displayProperties, issueIds, - peekIssueId, isDragDisabled, updateIssue, quickActions, @@ -176,7 +175,6 @@ export const KanbanGroup = (props: IKanbanGroup) => { sub_group_id={sub_group_id} columnId={groupId} issuesMap={issuesMap} - peekIssueId={peekIssueId} issueIds={(issueIds as TGroupedIssues)?.[groupId] || []} displayProperties={displayProperties} isDragDisabled={isDragDisabled} diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 99ac692f7..ae881b9ed 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -14,6 +14,7 @@ import { } from "@plane/types"; // components import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; +import { TRenderQuickActions } from "../list/list-view-types"; import { getGroupByColumns, isWorkspaceLevel } from "../utils"; import { KanbanStoreType } from "./base-kanban-root"; import { KanBan } from "./default"; @@ -106,7 +107,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { showEmptyGroup: boolean; displayProperties: IIssueDisplayProperties | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise; @@ -235,7 +236,7 @@ export interface IKanBanSwimLanes { sub_group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; showEmptyGroup: boolean; 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 ac9427101..8a5fcf849 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -1,6 +1,5 @@ import { FC, useCallback } from "react"; import { observer } from "mobx-react-lite"; -import { TIssue } from "@plane/types"; // types import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; @@ -9,7 +8,7 @@ import { useIssues, useUser } from "@/hooks/store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; // components import { List } from "./default"; -import { IQuickActionProps } from "./list-view-types"; +import { IQuickActionProps, TRenderQuickActions } from "./list-view-types"; // constants // hooks @@ -69,9 +68,10 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { const group_by = displayFilters?.group_by || null; const showEmptyGroup = displayFilters?.show_empty_groups ?? false; - const renderQuickActions = useCallback( - (issue: TIssue) => ( + const renderQuickActions: TRenderQuickActions = useCallback( + ({ issue, parentRef }) => ( removeIssue(issue.project_id, issue.id)} handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)} diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 1d9c3a74c..923f6cac4 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -1,38 +1,41 @@ +import { useRef } from "react"; import { observer } from "mobx-react-lite"; import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; -// components -// hooks // ui import { Spinner, Tooltip, ControlLink } from "@plane/ui"; -// helper +// helpers import { cn } from "@/helpers/common.helper"; +// hooks import { useAppRouter, useIssueDetail, useProject } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types import { IssueProperties } from "../properties/all-properties"; +import { TRenderQuickActions } from "./list-view-types"; interface IssueBlockProps { issueId: string; issuesMap: TIssueMap; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; canEditProperties: (projectId: string | undefined) => boolean; } export const IssueBlock: React.FC = observer((props: IssueBlockProps) => { const { issuesMap, issueId, updateIssue, quickActions, displayProperties, canEditProperties } = props; + // refs + const parentRef = useRef(null); // hooks const { workspaceSlug } = useAppRouter(); const { getProjectIdentifierById } = useProject(); - const { peekIssue, setPeekIssue } = useIssueDetail(); + const { getIsIssuePeeked, setPeekIssue } = useIssueDetail(); const handleIssuePeekOverview = (issue: TIssue) => workspaceSlug && issue && issue.project_id && issue.id && - peekIssue?.issueId !== issue.id && + !getIsIssuePeeked(issue.id) && setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); const issue = issuesMap[issueId]; @@ -44,11 +47,12 @@ export const IssueBlock: React.FC = observer((props: IssueBlock return (
@@ -86,7 +90,12 @@ export const IssueBlock: React.FC = observer((props: IssueBlock )}
{!issue?.tempId && ( -
{quickActions(issue)}
+
+ {quickActions({ + issue, + parentRef, + })} +
)}
@@ -100,7 +109,12 @@ export const IssueBlock: React.FC = observer((props: IssueBlock displayProperties={displayProperties} activeLayout="List" /> -
{quickActions(issue)}
+
+ {quickActions({ + issue, + parentRef, + })} +
) : (
diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index f5ddda6b5..1e1751b76 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -3,6 +3,7 @@ import { FC, MutableRefObject } from "react"; import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import RenderIfVisible from "@/components/core/render-if-visible-HOC"; import { IssueBlock } from "@/components/issues"; +import { TRenderQuickActions } from "./list-view-types"; // types interface Props { @@ -10,7 +11,7 @@ interface Props { issuesMap: TIssueMap; canEditProperties: (projectId: string | undefined) => boolean; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; containerRef: MutableRefObject; } diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 2fcadaa13..d34908896 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -16,13 +16,14 @@ import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } // utils import { getGroupByColumns, isWorkspaceLevel } from "../utils"; import { HeaderGroupByCard } from "./headers/group-by-card"; +import { TRenderQuickActions } from "./list-view-types"; export interface IGroupByList { issueIds: TGroupedIssues | TUnGroupedIssues | any; issuesMap: TIssueMap; group_by: string | null; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; enableIssueQuickAdd: boolean; showEmptyGroup?: boolean; @@ -177,7 +178,7 @@ export interface IList { issuesMap: TIssueMap; group_by: string | null; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; showEmptyGroup: boolean; enableIssueQuickAdd: boolean; diff --git a/web/components/issues/issue-layouts/list/list-view-types.d.ts b/web/components/issues/issue-layouts/list/list-view-types.d.ts index f38f52b9c..6597855f6 100644 --- a/web/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/components/issues/issue-layouts/list/list-view-types.d.ts @@ -2,6 +2,7 @@ import { Placement } from "@popperjs/core"; import { TIssue } from "@plane/types"; export interface IQuickActionProps { + parentRef: React.RefObject; issue: TIssue; handleDelete: () => Promise; handleUpdate?: (data: TIssue) => Promise; @@ -13,3 +14,17 @@ export interface IQuickActionProps { readOnly?: boolean; placements?: Placement; } + +export type TRenderQuickActions = ({ + issue, + parentRef, + customActionButton, + placement, + portalElement, +}: { + issue: TIssue; + parentRef: React.RefObject; + customActionButton?: React.ReactElement; + placement?: Placement; + portalElement?: HTMLDivElement | null; +}) => React.ReactNode; diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index 86dbe760b..7a73a25f9 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -3,21 +3,22 @@ import omit from "lodash/omit"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; +// types import { TIssue } from "@plane/types"; -// hooks -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; -import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; // ui +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components +import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; +// constants import { EIssuesStoreType } from "@/constants/issue"; import { STATE_GROUPS } from "@/constants/state"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; -import { useEventTracker, useProjectState } from "@/hooks/store"; -// components // helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useEventTracker, useProjectState } from "@/hooks/store"; // types import { IQuickActionProps } from "../list/list-view-types"; -// constants export const AllIssueQuickActions: React.FC = observer((props) => { const { @@ -28,6 +29,8 @@ export const AllIssueQuickActions: React.FC = observer((props customActionButton, portalElement, readOnly = false, + placements = "bottom-start", + parentRef, } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); @@ -68,6 +71,63 @@ export const AllIssueQuickActions: React.FC = observer((props ["id"] ); + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setTrackElement("Global issues"); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "make-a-copy", + title: "Make a copy", + icon: Copy, + action: () => { + setTrackElement("Global issues"); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "open-in-new-tab", + title: "Open in new tab", + icon: ExternalLink, + action: handleOpenInNewTab, + }, + { + key: "copy-link", + title: "Copy link", + icon: Link, + action: handleCopyIssueLink, + }, + { + key: "archive", + title: "Archive", + description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + action: () => setArchiveIssueModal(true), + disabled: !isInArchivableGroup, + shouldRender: isArchivingAllowed, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement("Global issues"); + setDeleteIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + ]; + return ( <> = observer((props }} storeType={EIssuesStoreType.PROJECT} /> + - {isEditingAllowed && ( - { - setTrackElement("Global issues"); - setIssueToEdit(issue); - setCreateUpdateIssueModal(true); - }} - > -
- - Edit -
-
- )} - -
- - Open in new tab -
-
- -
- - Copy link -
-
- {isEditingAllowed && ( - { - setTrackElement("Global issues"); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- )} - {isArchivingAllowed && ( - setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> - {isInArchivableGroup ? ( -
- - Archive -
- ) : ( -
- -
-

Archive

-

- Only completed or canceled -
- issues can be archived + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isEditingAllowed && ( - { - setTrackElement("Global issues"); - setDeleteIssueModal(true); - }} - > -
- - Delete -
-
- )} + + ); + })} ); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index 8a49ec9b4..0327755a9 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -4,13 +4,14 @@ import { useRouter } from "next/router"; // icons import { ArchiveRestoreIcon, ExternalLink, Link, Trash2 } from "lucide-react"; // ui -import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { DeleteIssueModal } from "@/components/issues"; // constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers +import { cn } from "@/helpers/common.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useEventTracker, useIssues, useUser } from "@/hooks/store"; @@ -18,7 +19,16 @@ import { useEventTracker, useIssues, useUser } from "@/hooks/store"; import { IQuickActionProps } from "../list/list-view-types"; export const ArchivedIssueQuickActions: React.FC = observer((props) => { - const { issue, handleDelete, handleRestore, customActionButton, portalElement, readOnly = false } = props; + const { + issue, + handleDelete, + handleRestore, + customActionButton, + portalElement, + readOnly = false, + placements = "bottom-end", + parentRef, + } = props; // states const [deleteIssueModal, setDeleteIssueModal] = useState(false); // router @@ -66,6 +76,38 @@ export const ArchivedIssueQuickActions: React.FC = observer(( }); }; + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "restore", + title: "Restore", + icon: ArchiveRestoreIcon, + action: handleIssueRestore, + shouldRender: isRestoringAllowed, + }, + { + key: "open-in-new-tab", + title: "Open in new tab", + icon: ExternalLink, + action: handleOpenInNewTab, + }, + { + key: "copy-link", + title: "Copy link", + icon: Link, + action: handleCopyIssueLink, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + ]; + return ( <> = observer(( handleClose={() => setDeleteIssueModal(false)} onSubmit={handleDelete} /> + - {isRestoringAllowed && ( - -
- - Restore -
-
- )} - -
- - Open in new tab -
-
- -
- - Copy link -
-
- {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete issue -
-
- )} + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); + })}
); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index eed0e0dc6..503d8258e 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -2,24 +2,24 @@ import { useState } from "react"; import omit from "lodash/omit"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// hooks -// ui import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react"; +// types import { TIssue } from "@plane/types"; -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; -// icons +// ui +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; +// constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; import { STATE_GROUPS } from "@/constants/state"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; -import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store"; -// components // helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store"; // types import { IQuickActionProps } from "../list/list-view-types"; -// constants export const CycleIssueQuickActions: React.FC = observer((props) => { const { @@ -32,6 +32,7 @@ export const CycleIssueQuickActions: React.FC = observer((pro portalElement, readOnly = false, placements = "bottom-start", + parentRef, } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); @@ -80,6 +81,73 @@ export const CycleIssueQuickActions: React.FC = observer((pro ["id"] ); + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setIssueToEdit({ + ...issue, + cycle_id: cycleId?.toString() ?? null, + }); + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "make-a-copy", + title: "Make a copy", + icon: Copy, + action: () => { + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "open-in-new-tab", + title: "Open in new tab", + icon: ExternalLink, + action: handleOpenInNewTab, + }, + { + key: "copy-link", + title: "Copy link", + icon: Link, + action: handleCopyIssueLink, + }, + { + key: "remove-from-cycle", + title: "Remove from cycle", + icon: XCircle, + action: () => handleRemoveFromView?.(), + shouldRender: isEditingAllowed, + }, + { + key: "archive", + title: "Archive", + description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + action: () => setArchiveIssueModal(true), + disabled: !isInArchivableGroup, + shouldRender: isArchivingAllowed, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }, + shouldRender: isDeletingAllowed, + }, + ]; + return ( <> = observer((pro }} storeType={EIssuesStoreType.CYCLE} /> + - {isEditingAllowed && ( - { - setIssueToEdit({ - ...issue, - cycle_id: cycleId?.toString() ?? null, - }); - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Edit -
-
- )} - -
- - Open in new tab -
-
- -
- - Copy link -
-
- {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- )} - {isEditingAllowed && ( - { - handleRemoveFromView && handleRemoveFromView(); - }} - > -
- - Remove from cycle -
-
- )} - {isArchivingAllowed && ( - setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> - {isInArchivableGroup ? ( -
- - Archive -
- ) : ( -
- -
-

Archive

-

- Only completed or canceled -
- issues can be archived + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isDeletingAllowed && ( - { - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete -
-
- )} + + ); + })} ); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx index 9502e7623..18c259107 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx @@ -6,19 +6,30 @@ import { Pencil, Trash2 } from "lucide-react"; // types import { TIssue } from "@plane/types"; // ui -import { CustomMenu } from "@plane/ui"; +import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui"; // components import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; // constant import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; +// helpers +import { cn } from "@/helpers/common.helper"; // hooks import { useEventTracker, useIssues, useUser } from "@/hooks/store"; // types import { IQuickActionProps } from "../list/list-view-types"; export const DraftIssueQuickActions: React.FC = observer((props) => { - const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; + const { + issue, + handleDelete, + handleUpdate, + customActionButton, + portalElement, + readOnly = false, + placements = "bottom-end", + parentRef, + } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -44,6 +55,30 @@ export const DraftIssueQuickActions: React.FC = observer((pro ["id"] ); + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setTrackElement(activeLayout); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }, + shouldRender: isDeletingAllowed, + }, + ]; + return ( <> = observer((pro handleClose={() => setDeleteIssueModal(false)} onSubmit={handleDelete} /> - { @@ -66,43 +100,49 @@ export const DraftIssueQuickActions: React.FC = observer((pro storeType={EIssuesStoreType.PROJECT} isDraft /> - + - {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setIssueToEdit(issue); - setCreateUpdateIssueModal(true); - }} - > -
- - Edit -
-
- )} - {isDeletingAllowed && ( - { - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete -
-
- )} + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); + })}
); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 1f155d066..3cc3343b6 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -2,23 +2,24 @@ import { useState } from "react"; import omit from "lodash/omit"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// hooks -// ui import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react"; +// types import { TIssue } from "@plane/types"; -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// ui +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; +// constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; import { STATE_GROUPS } from "@/constants/state"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; -import { useIssues, useEventTracker, useUser, useProjectState } from "@/hooks/store"; -// components // helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useIssues, useEventTracker, useUser, useProjectState } from "@/hooks/store"; // types import { IQuickActionProps } from "../list/list-view-types"; -// constants export const ModuleIssueQuickActions: React.FC = observer((props) => { const { @@ -31,6 +32,7 @@ export const ModuleIssueQuickActions: React.FC = observer((pr portalElement, readOnly = false, placements = "bottom-start", + parentRef, } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); @@ -79,6 +81,70 @@ export const ModuleIssueQuickActions: React.FC = observer((pr ["id"] ); + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] }); + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "make-a-copy", + title: "Make a copy", + icon: Copy, + action: () => { + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "open-in-new-tab", + title: "Open in new tab", + icon: ExternalLink, + action: handleOpenInNewTab, + }, + { + key: "copy-link", + title: "Copy link", + icon: Link, + action: handleCopyIssueLink, + }, + { + key: "remove-from-module", + title: "Remove from module", + icon: XCircle, + action: () => handleRemoveFromView?.(), + shouldRender: isEditingAllowed, + }, + { + key: "archive", + title: "Archive", + description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + action: () => setArchiveIssueModal(true), + disabled: !isInArchivableGroup, + shouldRender: isArchivingAllowed, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }, + shouldRender: isDeletingAllowed, + }, + ]; + return ( <> = observer((pr }} storeType={EIssuesStoreType.MODULE} /> + - {isEditingAllowed && ( - { - setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] }); - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Edit -
-
- )} - -
- - Open in new tab -
-
- -
- - Copy link -
-
- {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- )} - {isEditingAllowed && ( - { - handleRemoveFromView && handleRemoveFromView(); - }} - > -
- - Remove from module -
-
- )} - {isArchivingAllowed && ( - setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> - {isInArchivableGroup ? ( -
- - Archive -
- ) : ( -
- -
-

Archive

-

- Only completed or canceled -
- issues can be archived + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isDeletingAllowed && ( - { - e.preventDefault(); - e.stopPropagation(); - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete -
-
- )} + + ); + })} ); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 577a3ce99..0fbe10da9 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -2,22 +2,24 @@ import { useState } from "react"; import omit from "lodash/omit"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// hooks import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; +// types import { TIssue } from "@plane/types"; -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// ui +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; +// components import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; +// constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; import { STATE_GROUPS } from "@/constants/state"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; -import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store"; -// ui -// components // helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store"; // types import { IQuickActionProps } from "../list/list-view-types"; -// constant export const ProjectIssueQuickActions: React.FC = observer((props) => { const { @@ -28,7 +30,8 @@ export const ProjectIssueQuickActions: React.FC = observer((p customActionButton, portalElement, readOnly = false, - placements = "bottom-start", + placements = "bottom-end", + parentRef, } = props; // router const router = useRouter(); @@ -56,9 +59,6 @@ export const ProjectIssueQuickActions: React.FC = observer((p const isDeletingAllowed = isEditingAllowed; const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`; - - const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank"); - const handleCopyIssueLink = () => copyUrlToClipboard(issueLink).then(() => setToast({ @@ -67,6 +67,7 @@ export const ProjectIssueQuickActions: React.FC = observer((p message: "Issue link copied to clipboard", }) ); + const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank"); const isDraftIssue = router?.asPath?.includes("draft-issues") || false; @@ -79,6 +80,63 @@ export const ProjectIssueQuickActions: React.FC = observer((p ["id"] ); + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setTrackElement(activeLayout); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "make-a-copy", + title: "Make a copy", + icon: Copy, + action: () => { + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "open-in-new-tab", + title: "Open in new tab", + icon: ExternalLink, + action: handleOpenInNewTab, + }, + { + key: "copy-link", + title: "Copy link", + icon: Link, + action: handleCopyIssueLink, + }, + { + key: "archive", + title: "Archive", + description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + action: () => setArchiveIssueModal(true), + disabled: !isInArchivableGroup, + shouldRender: isArchivingAllowed, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }, + shouldRender: isDeletingAllowed, + }, + ]; + return ( <> = observer((p storeType={EIssuesStoreType.PROJECT} isDraft={isDraftIssue} /> + - {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setIssueToEdit(issue); - setCreateUpdateIssueModal(true); - }} - > -
- - Edit -
-
- )} - -
- - Open in new tab -
-
- -
- - Copy link -
-
- {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- )} - {isArchivingAllowed && ( - setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> - {isInArchivableGroup ? ( -
- - Archive -
- ) : ( -
- -
-

Archive

-

- Only completed or canceled -
- issues can be archived + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isDeletingAllowed && ( - { - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete -
-
- )} + + ); + })} ); diff --git a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index abc0e56be..51188fe72 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -3,7 +3,7 @@ import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import useSWR from "swr"; -import { TIssue, IIssueDisplayFilterOptions } from "@plane/types"; +import { IIssueDisplayFilterOptions } from "@plane/types"; // hooks // components import { EmptyState } from "@/components/empty-state"; @@ -19,6 +19,7 @@ import { EUserProjectRoles } from "@/constants/project"; import { useCommandPalette, useEventTracker, useGlobalView, useIssues, useProject, useUser } from "@/hooks/store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties"; +import { TRenderQuickActions } from "../list/list-view-types"; export const AllIssueLayoutRoot: React.FC = observer(() => { // router @@ -127,9 +128,10 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { [updateFilters, workspaceSlug, globalViewId] ); - const renderQuickActions = useCallback( - (issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => ( + const renderQuickActions: TRenderQuickActions = useCallback( + ({ issue, parentRef, customActionButton, placement, portalElement }) => ( removeIssue(issue.project_id, issue.id)} @@ -137,6 +139,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)} portalElement={portalElement} readOnly={!canEditProperties(issue.project_id)} + placements={placement} /> ), [canEditProperties, removeIssue, updateIssue, archiveIssue] diff --git a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index 01bc49d29..cf57c7f7e 100644 --- a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -1,7 +1,7 @@ import { FC, useCallback } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -import { TIssue, IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types"; +import { IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types"; // hooks import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; @@ -10,7 +10,7 @@ import { useIssuesActions } from "@/hooks/use-issues-actions"; // views // types // constants -import { IQuickActionProps } from "../list/list-view-types"; +import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types"; import { SpreadsheetView } from "./spreadsheet-view"; export type SpreadsheetStoreType = @@ -66,9 +66,10 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { [projectId, updateFilters] ); - const renderQuickActions = useCallback( - (issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => ( + const renderQuickActions: TRenderQuickActions = useCallback( + ({ issue, parentRef, customActionButton, placement, portalElement }) => ( removeIssue(issue.project_id, issue.id)} @@ -78,6 +79,7 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)} portalElement={portalElement} readOnly={!isEditingAllowed || isCompletedCycle} + placements={placement} /> ), [isEditingAllowed, isCompletedCycle, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue] diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index d02452155..58f73bf83 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -16,17 +16,14 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types // local components +import { TRenderQuickActions } from "../list/list-view-types"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; import { IssueColumn } from "./issue-column"; interface Props { displayProperties: IIssueDisplayProperties; isEstimateEnabled: boolean; - quickActions: ( - issue: TIssue, - customActionButton?: React.ReactElement, - portalElement?: HTMLDivElement | null - ) => React.ReactNode; + quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; portalElement: React.MutableRefObject; @@ -112,11 +109,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => { interface IssueRowDetailsProps { displayProperties: IIssueDisplayProperties; isEstimateEnabled: boolean; - quickActions: ( - issue: TIssue, - customActionButton?: React.ReactElement, - portalElement?: HTMLDivElement | null - ) => React.ReactNode; + quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; portalElement: React.MutableRefObject; @@ -143,23 +136,25 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { setExpanded, spreadsheetColumnsList, } = props; + // states + const [isMenuActive, setIsMenuActive] = useState(false); + // refs + const cellRef = useRef(null); + const menuActionRef = useRef(null); // router const router = useRouter(); const { workspaceSlug } = router.query; - //hooks + // hooks const { getProjectIdentifierById } = useProject(); - const { peekIssue, setPeekIssue } = useIssueDetail(); + const { getIsIssuePeeked, setPeekIssue } = useIssueDetail(); const { isMobile } = usePlatformOS(); - // states - const [isMenuActive, setIsMenuActive] = useState(false); - const menuActionRef = useRef(null); const handleIssuePeekOverview = (issue: TIssue) => workspaceSlug && issue && issue.project_id && issue.id && - peekIssue?.issueId !== issue.id && + !getIsIssuePeeked(issue.id) && setPeekIssue({ workspaceSlug: workspaceSlug.toString(), projectId: issue.project_id, issueId: issue.id }); const { subIssues: subIssuesStore, issue } = useIssueDetail(); @@ -196,16 +191,13 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { return ( <> { >
@@ -226,7 +218,12 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index 834a65fd7..f548c69a5 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -4,6 +4,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@pl //types import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigation"; //components +import { TRenderQuickActions } from "../list/list-view-types"; import { SpreadsheetIssueRow } from "./issue-row"; import { SpreadsheetHeader } from "./spreadsheet-header"; @@ -13,11 +14,7 @@ type Props = { handleDisplayFilterUpdate: (data: Partial) => void; issueIds: string[]; isEstimateEnabled: boolean; - quickActions: ( - issue: TIssue, - customActionButton?: React.ReactElement, - portalElement?: HTMLDivElement | null - ) => React.ReactNode; + quickActions: TRenderQuickActions; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; canEditProperties: (projectId: string | undefined) => boolean; portalElement: React.MutableRefObject; diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index 334a0eb6a..4c688a52b 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -6,6 +6,7 @@ import { Spinner } from "@plane/ui"; import { SpreadsheetQuickAddIssueForm } from "@/components/issues"; import { SPREADSHEET_PROPERTY_LIST } from "@/constants/spreadsheet"; import { useProject } from "@/hooks/store"; +import { TRenderQuickActions } from "../list/list-view-types"; import { SpreadsheetTable } from "./spreadsheet-table"; // types //hooks @@ -15,11 +16,7 @@ type Props = { displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; issueIds: string[] | undefined; - quickActions: ( - issue: TIssue, - customActionButton?: React.ReactElement, - portalElement?: HTMLDivElement | null - ) => React.ReactNode; + quickActions: TRenderQuickActions; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; openIssuesListModal?: (() => void) | null; quickAddCallback?: ( diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index 4d0d64ee1..5e36f6ed5 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -25,7 +25,7 @@ import { IssueLabelSelect } from "@/components/issues/select"; import { CreateLabelModal } from "@/components/labels"; // helpers import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper"; -import { getChangedIssuefields } from "@/helpers/issue.helper"; +import { getChangedIssuefields, getDescriptionPlaceholder } from "@/helpers/issue.helper"; import { shouldRenderProject } from "@/helpers/project.helper"; // hooks import { useAppRouter, useEstimate, useInstance, useIssueDetail, useProject, useWorkspace } from "@/hooks/store"; @@ -468,17 +468,13 @@ export const IssueFormRoot: FC = observer((props) => { workspaceSlug={workspaceSlug?.toString() as string} workspaceId={workspaceId} projectId={projectId} - // dragDropEnabled={false} onChange={(_description: object, description_html: string) => { onChange(description_html); handleFormChange(); }} ref={editorRef} tabIndex={getTabIndex("description_html")} - placeholder={(isFocused) => { - if (isFocused) return "Press '/' for commands..."; - else return "Click to add description"; - }} + placeholder={getDescriptionPlaceholder} /> )} /> diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index 215ced6c7..2672e8584 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -109,7 +109,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop // clearing up the description state when we leave the component return () => setDescription(undefined); - }, [data, projectId, workspaceProjectIds, isOpen, activeProjectId]); + }, [data, projectId, isOpen, activeProjectId]); const addIssueToCycle = async (issue: TIssue, cycleId: string) => { if (!workspaceSlug || !activeProjectId) return; diff --git a/web/components/issues/sub-issues/issue-list-item.tsx b/web/components/issues/sub-issues/issue-list-item.tsx index ffd9f76d7..23ba330a9 100644 --- a/web/components/issues/sub-issues/issue-list-item.tsx +++ b/web/components/issues/sub-issues/issue-list-item.tsx @@ -42,7 +42,7 @@ export const IssueListItem: React.FC = observer((props) => { } = props; const { - peekIssue, + getIsIssuePeeked, setPeekIssue, issue: { getIssueById }, subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers }, @@ -65,7 +65,7 @@ export const IssueListItem: React.FC = observer((props) => { issue && issue.project_id && issue.id && - peekIssue?.issueId !== issue.id && + !getIsIssuePeeked(issue.id) && setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); if (!issue) return <>; diff --git a/web/components/labels/label-block/label-item-block.tsx b/web/components/labels/label-block/label-item-block.tsx index e3236c91d..b3d41abcc 100644 --- a/web/components/labels/label-block/label-item-block.tsx +++ b/web/components/labels/label-block/label-item-block.tsx @@ -52,7 +52,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => { : "opacity-0 group-hover:pointer-events-auto group-hover:opacity-100" } ${isLabelGroup && "-top-0.5"}`} > - + {customMenuItems.map( ({ isVisible, onClick, CustomIcon, text, key }) => isVisible && ( diff --git a/web/components/modules/archived-modules/view.tsx b/web/components/modules/archived-modules/view.tsx index 56dbd0135..f12bc412a 100644 --- a/web/components/modules/archived-modules/view.tsx +++ b/web/components/modules/archived-modules/view.tsx @@ -50,7 +50,7 @@ export const ArchivedModulesView: FC = observer((props) =>
{filteredArchivedModuleIds.map((moduleId) => ( - + ))}
= observer((props) => { const { moduleId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -145,7 +147,7 @@ export const ModuleCardItem: React.FC = observer((props) => { return (
- +
@@ -239,6 +241,7 @@ export const ModuleCardItem: React.FC = observer((props) => { )} {workspaceSlug && projectId && ( ; +}; + +export const ModuleListItemAction: FC = observer((props) => { + const { moduleId, moduleDetails, parentRef } = props; + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + const { addModuleToFavorites, removeModuleFromFavorites } = useModule(); + const { getUserDetails } = useMember(); + const { captureEvent } = useEventTracker(); + const { isMobile } = usePlatformOS(); + + // derived values + const endDate = getDate(moduleDetails.target_date); + const startDate = getDate(moduleDetails.start_date); + + const renderDate = moduleDetails.start_date || moduleDetails.target_date; + + const moduleStatus = MODULE_STATUS.find((status) => status.value === moduleDetails.status); + + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + // handlers + const handleAddToFavorites = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (!workspaceSlug || !projectId) return; + + const addToFavoritePromise = addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).then( + () => { + captureEvent(MODULE_FAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + } + ); + + setPromiseToast(addToFavoritePromise, { + loading: "Adding module to favorites...", + success: { + title: "Success!", + message: () => "Module added to favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't add the module to favorites. Please try again.", + }, + }); + }; + + const handleRemoveFromFavorites = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (!workspaceSlug || !projectId) return; + + const removeFromFavoritePromise = removeModuleFromFavorites( + workspaceSlug.toString(), + projectId.toString(), + moduleId + ).then(() => { + captureEvent(MODULE_UNFAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }); + + setPromiseToast(removeFromFavoritePromise, { + loading: "Removing module from favorites...", + success: { + title: "Success!", + message: () => "Module removed from favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't remove the module from favorites. Please try again.", + }, + }); + }; + + return ( + <> + {moduleStatus && ( + + {moduleStatus.label} + + )} + + {renderDate && ( + + {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} + + )} + + +
+ {moduleDetails.member_ids.length > 0 ? ( + + {moduleDetails.member_ids.map((member_id) => { + const member = getUserDetails(member_id); + return ; + })} + + ) : ( + + + + )} +
+
+ + {isEditingAllowed && !moduleDetails.archived_at && ( + { + if (moduleDetails.is_favorite) handleRemoveFromFavorites(e); + else handleAddToFavorites(e); + }} + selected={moduleDetails.is_favorite} + /> + )} + {workspaceSlug && projectId && ( + + )} + + ); +}); diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 6b2c8c2ba..9ad7d2225 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -1,102 +1,45 @@ -import React from "react"; +import React, { useRef } from "react"; import { observer } from "mobx-react-lite"; -import Link from "next/link"; import { useRouter } from "next/router"; -import { Check, Info, User2 } from "lucide-react"; +// icons +import { Check, Info } from "lucide-react"; // ui -import { Avatar, AvatarGroup, CircularProgressIndicator, Tooltip, setPromiseToast } from "@plane/ui"; +import { CircularProgressIndicator } from "@plane/ui"; // components -import { FavoriteStar } from "@/components/core"; -import { ModuleQuickActions } from "@/components/modules"; -// constants -import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker"; -import { MODULE_STATUS } from "@/constants/module"; -import { EUserProjectRoles } from "@/constants/project"; -// helpers -import { getDate, renderFormattedDate } from "@/helpers/date-time.helper"; +import { ListItem } from "@/components/core/list"; +import { ModuleListItemAction } from "@/components/modules"; // hooks -import { useModule, useUser, useEventTracker, useMember } from "@/hooks/store"; +import { useModule } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { moduleId: string; - isArchived?: boolean; }; export const ModuleListItem: React.FC = observer((props) => { - const { moduleId, isArchived = false } = props; + const { moduleId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; // store hooks - const { - membership: { currentProjectRole }, - } = useUser(); - const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); - const { getUserDetails } = useMember(); - const { captureEvent } = useEventTracker(); + const { getModuleById } = useModule(); + const { isMobile } = usePlatformOS(); + // derived values const moduleDetails = getModuleById(moduleId); - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - const { isMobile } = usePlatformOS(); - const handleAddToFavorites = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - const addToFavoritePromise = addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).then( - () => { - captureEvent(MODULE_FAVORITED, { - module_id: moduleId, - element: "Grid layout", - state: "SUCCESS", - }); - } - ); + if (!moduleDetails) return null; - setPromiseToast(addToFavoritePromise, { - loading: "Adding module to favorites...", - success: { - title: "Success!", - message: () => "Module added to favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't add the module to favorites. Please try again.", - }, - }); - }; + const completionPercentage = + ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100; - const handleRemoveFromFavorites = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - if (!workspaceSlug || !projectId) return; + const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); - const removeFromFavoritePromise = removeModuleFromFavorites( - workspaceSlug.toString(), - projectId.toString(), - moduleId - ).then(() => { - captureEvent(MODULE_UNFAVORITED, { - module_id: moduleId, - element: "Grid layout", - state: "SUCCESS", - }); - }); - - setPromiseToast(removeFromFavoritePromise, { - loading: "Removing module from favorites...", - success: { - title: "Success!", - message: () => "Module removed from favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't remove the module from favorites. Please try again.", - }, - }); - }; + const completedModuleCheck = moduleDetails.status === "completed"; + // handlers const openModuleOverview = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); @@ -116,126 +59,41 @@ export const ModuleListItem: React.FC = observer((props) => { } }; - if (!moduleDetails) return null; - - const completionPercentage = - ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100; - - const endDate = getDate(moduleDetails.target_date); - const startDate = getDate(moduleDetails.start_date); - - const renderDate = moduleDetails.start_date || moduleDetails.target_date; - - // const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); - - const moduleStatus = MODULE_STATUS.find((status) => status.value === moduleDetails.status); - - const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); - - const completedModuleCheck = moduleDetails.status === "completed"; - return ( -
- { - if (isArchived) { - openModuleOverview(e); - } - }} - > -
-
-
-
- - - {completedModuleCheck ? ( - progress === 100 ? ( - - ) : ( - {`!`} - ) - ) : progress === 100 ? ( - - ) : ( - {`${progress}%`} - )} - - - - {moduleDetails.name} - -
- -
-
- -
- -
-
- {moduleStatus && ( - - {moduleStatus.label} - + { + if (moduleDetails.archived_at) openModuleOverview(e); + }} + prependTitleElement={ + + {completedModuleCheck ? ( + progress === 100 ? ( + + ) : ( + {`!`} + ) + ) : progress === 100 ? ( + + ) : ( + {`${progress}%`} )} -
-
-
- {renderDate && ( - - {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} - - )} -
- -
- -
- {moduleDetails.member_ids.length > 0 ? ( - - {moduleDetails.member_ids.map((member_id) => { - const member = getUserDetails(member_id); - return ; - })} - - ) : ( - - - - )} -
-
- - {isEditingAllowed && !isArchived && ( - { - if (moduleDetails.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={moduleDetails.is_favorite} - /> - )} - {workspaceSlug && projectId && ( - - )} -
-
-
-
+ + } + appendTitleElement={ + + } + actionableItems={ + + } + isMobile={isMobile} + parentRef={parentRef} + /> ); }); diff --git a/web/components/modules/module-view-header.tsx b/web/components/modules/module-view-header.tsx new file mode 100644 index 000000000..9367aad33 --- /dev/null +++ b/web/components/modules/module-view-header.tsx @@ -0,0 +1,178 @@ +import React, { FC, useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +import { ListFilter, Search, X } from "lucide-react"; +import { cn } from "@plane/editor-core"; +// types +import { TModuleFilters } from "@plane/types"; +// ui +import { Tooltip } from "@plane/ui"; +// components +import { FiltersDropdown } from "@/components/issues"; +import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules/dropdowns"; +// constants +import { MODULE_VIEW_LAYOUTS } from "@/constants/module"; +// hooks +import { useMember, useModuleFilter } from "@/hooks/store"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; +import { usePlatformOS } from "@/hooks/use-platform-os"; + +export const ModuleViewHeader: FC = observer(() => { + // refs + const inputRef = useRef(null); + + // router + const router = useRouter(); + const { projectId } = router.query; + + // hooks + const { isMobile } = usePlatformOS(); + + // store hooks + const { + workspace: { workspaceMemberIds }, + } = useMember(); + const { + currentProjectDisplayFilters: displayFilters, + currentProjectFilters: filters, + searchQuery, + updateDisplayFilters, + updateFilters, + updateSearchQuery, + } = useModuleFilter(); + + // states + const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false); + + // handlers + const handleFilters = useCallback( + (key: keyof TModuleFilters, value: string | string[]) => { + if (!projectId) return; + const newValues = filters?.[key] ?? []; + + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + else { + if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(projectId.toString(), { [key]: newValues }); + }, + [filters, projectId, updateFilters] + ); + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } + } + }; + + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); + return ( +
+
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+
+ + { + if (!projectId || val === displayFilters?.order_by) return; + updateDisplayFilters(projectId.toString(), { + order_by: val, + }); + }} + /> + } title="Filters" placement="bottom-end"> + { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), val); + }} + handleFiltersUpdate={handleFilters} + memberIds={workspaceMemberIds ?? undefined} + /> + +
+ {MODULE_VIEW_LAYOUTS.map((layout) => ( + + + + ))} +
+
+ ); +}); diff --git a/web/components/modules/modules-list-view.tsx b/web/components/modules/modules-list-view.tsx index 5e91a4a52..d211c6021 100644 --- a/web/components/modules/modules-list-view.tsx +++ b/web/components/modules/modules-list-view.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react-lite"; import Image from "next/image"; import { useRouter } from "next/router"; // components +import { ListLayout } from "@/components/core/list"; import { EmptyState } from "@/components/empty-state"; import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "@/components/modules"; import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "@/components/ui"; @@ -69,11 +70,11 @@ export const ModulesListView: React.FC = observer(() => { {displayFilters?.layout === "list" && (
-
+ {filteredModuleIds.map((moduleId) => ( ))} -
+ ; moduleId: string; projectId: string; workspaceSlug: string; - isArchived?: boolean; }; export const ModuleQuickActions: React.FC = observer((props) => { - const { moduleId, projectId, workspaceSlug, isArchived } = props; + const { parentRef, moduleId, projectId, workspaceSlug } = props; // router const router = useRouter(); // states @@ -37,6 +38,7 @@ export const ModuleQuickActions: React.FC = observer((props) => { const { getModuleById, restoreModule } = useModule(); // derived values const moduleDetails = getModuleById(moduleId); + const isArchived = !!moduleDetails?.archived_at; // auth const isEditingAllowed = !!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER; @@ -44,34 +46,25 @@ export const ModuleQuickActions: React.FC = observer((props) => { const moduleState = moduleDetails?.status?.toLocaleLowerCase(); const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState); - const handleCopyText = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => { + const moduleLink = `${workspaceSlug}/projects/${projectId}/modules/${moduleId}`; + const handleCopyText = () => + copyUrlToClipboard(moduleLink).then(() => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Module link copied to clipboard.", }); }); - }; + const handleOpenInNewTab = () => window.open(`/${moduleLink}`, "_blank"); - const handleEditModule = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleEditModule = () => { setTrackElement("Modules page list layout"); setEditModal(true); }; - const handleArchiveModule = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setArchiveModuleModal(true); - }; + const handleArchiveModule = () => setArchiveModuleModal(true); - const handleRestoreModule = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleRestoreModule = async () => await restoreModule(workspaceSlug, projectId, moduleId) .then(() => { setToast({ @@ -88,15 +81,61 @@ export const ModuleQuickActions: React.FC = observer((props) => { message: "Module could not be restored. Please try again.", }) ); - }; - const handleDeleteModule = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleDeleteModule = () => { setTrackElement("Modules page list layout"); setDeleteModal(true); }; + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: handleEditModule, + shouldRender: isEditingAllowed && !isArchived, + }, + { + key: "open-new-tab", + action: handleOpenInNewTab, + title: "Open in new tab", + icon: ExternalLink, + shouldRender: !isArchived, + }, + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: LinkIcon, + shouldRender: !isArchived, + }, + { + key: "archive", + action: handleArchiveModule, + title: "Archive", + description: isInArchivableGroup ? undefined : "Only completed or canceled\nmodule can be archived.", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + shouldRender: isEditingAllowed && !isArchived, + disabled: !isInArchivableGroup, + }, + { + key: "restore", + action: handleRestoreModule, + title: "Restore", + icon: ArchiveRestoreIcon, + shouldRender: isEditingAllowed && isArchived, + }, + { + key: "delete", + action: handleDeleteModule, + title: "Delete", + icon: Trash2, + shouldRender: isEditingAllowed, + }, + ]; + return ( <> {moduleDetails && ( @@ -118,60 +157,42 @@ export const ModuleQuickActions: React.FC = observer((props) => { setDeleteModal(false)} />
)} - - {isEditingAllowed && !isArchived && ( - - - - Edit module - - - )} - {isEditingAllowed && !isArchived && ( - - {isInArchivableGroup ? ( -
- - Archive module -
- ) : ( -
- -
-

Archive module

-

- Only completed or cancelled
module can be archived. + + + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isEditingAllowed && isArchived && ( - - - - Restore module - - - )} - {!isArchived && ( - - - - Copy module link - - - )} -
- {isEditingAllowed && ( - - - - Delete module - - - )} + + ); + })} ); diff --git a/web/components/onboarding/create-or-join-workspaces.tsx b/web/components/onboarding/create-or-join-workspaces.tsx index b8b8257b0..4ddf6dd02 100644 --- a/web/components/onboarding/create-or-join-workspaces.tsx +++ b/web/components/onboarding/create-or-join-workspaces.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; // icons -import { Sparkles } from "lucide-react"; +import { useTheme } from "next-themes"; // types import { IWorkspaceMemberInvitation, TOnboardingSteps } from "@plane/types"; // ui @@ -12,7 +12,8 @@ import { Invitations, OnboardingHeader, SwitchOrDeleteAccountDropdown, CreateWor // hooks import { useUser } from "@/hooks/store"; // assets -import createJoinWorkspace from "public/onboarding/create-join-workspace.png"; +import CreateJoinWorkspaceDark from "public/onboarding/create-join-workspace-dark.svg"; +import CreateJoinWorkspace from "public/onboarding/create-join-workspace.svg"; export enum ECreateOrJoinWorkspaceViews { WORKSPACE_CREATE = "WORKSPACE_CREATE", @@ -23,14 +24,17 @@ type Props = { invitations: IWorkspaceMemberInvitation[]; totalSteps: number; stepChange: (steps: Partial) => Promise; + finishOnboarding: () => Promise; }; export const CreateOrJoinWorkspaces: React.FC = observer((props) => { - const { invitations, totalSteps, stepChange } = props; + const { invitations, totalSteps, stepChange, finishOnboarding } = props; // states const [currentView, setCurrentView] = useState(null); // store hooks const { data: user } = useUser(); + // hooks + const { resolvedTheme } = useTheme(); useEffect(() => { if (invitations.length > 0) { @@ -42,14 +46,15 @@ export const CreateOrJoinWorkspaces: React.FC = observer((props) => { const handleNextStep = async () => { if (!user) return; - await stepChange({ workspace_join: true, workspace_create: true }); + + await finishOnboarding(); }; return (
-
+
- +
@@ -74,14 +79,14 @@ export const CreateOrJoinWorkspaces: React.FC = observer((props) => { )}
-
+
-
-
- - Workspace is the hub for all work happening in your company. -
- create-join-workspace +
+ Profile setup
diff --git a/web/components/onboarding/create-workspace.tsx b/web/components/onboarding/create-workspace.tsx index 3f8d92d89..5f6f60892 100644 --- a/web/components/onboarding/create-workspace.tsx +++ b/web/components/onboarding/create-workspace.tsx @@ -36,7 +36,7 @@ export const CreateWorkspace: React.FC = (props) => { handleSubmit, control, setValue, - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, isValid }, } = useForm({ defaultValues: { name: "", @@ -141,7 +141,7 @@ export const CreateWorkspace: React.FC = (props) => {

Create a workspace

- To start using plane, you need to create or join a workspace + To start using Plane, you need to create or join a workspace.

@@ -162,7 +162,7 @@ export const CreateWorkspace: React.FC = (props) => { }, }} render={({ field: { value, ref, onChange } }) => ( -
+
= (props) => { placeholder="Enter workspace name..." ref={ref} hasError={Boolean(errors.name)} - className="w-full border-onboarding-border-100 text-base placeholder:text-base placeholder:text-custom-text-400/50" + className="w-full border-onboarding-border-100 placeholder:text-custom-text-400" + autoFocus />
)} @@ -185,16 +186,16 @@ export const CreateWorkspace: React.FC = (props) => {
(
{window && window.location.host}/ = (props) => {
)} /> -

You can only edit the slug of the url

+

You can only edit the slug of the URL

{slugError && Workspace URL is already taken!} {invalidSlug && ( {`URL can only contain ( - ), ( _ ) & alphanumeric characters.`} @@ -238,7 +239,7 @@ export const CreateWorkspace: React.FC = (props) => { Select organization size ) } - buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" + buttonClassName="!border-[0.5px] !border-onboarding-border-100 !shadow-none !rounded-md" input optionsClassName="w-full" > @@ -255,7 +256,7 @@ export const CreateWorkspace: React.FC = (props) => { )}
- diff --git a/web/components/onboarding/index.ts b/web/components/onboarding/index.ts index db79162be..8b94c8094 100644 --- a/web/components/onboarding/index.ts +++ b/web/components/onboarding/index.ts @@ -4,7 +4,6 @@ export * from "./create-or-join-workspaces"; export * from "./profile-setup"; export * from "./create-workspace"; export * from "./invitations"; -export * from "./onboarding-sidebar"; export * from "./step-indicator"; export * from "./switch-or-delete-account-dropdown"; export * from "./switch-delete-account-modal"; diff --git a/web/components/onboarding/invitations.tsx b/web/components/onboarding/invitations.tsx index f34cf42ca..a5fd5632f 100644 --- a/web/components/onboarding/invitations.tsx +++ b/web/components/onboarding/invitations.tsx @@ -1,25 +1,23 @@ import React, { useState } from "react"; -import useSWR, { mutate } from "swr"; -// icons -import { CheckCircle2 } from "lucide-react"; +import useSWR from "swr";; // types import { IWorkspaceMemberInvitation } from "@plane/types"; // ui -import { Button } from "@plane/ui"; +import { Button, Checkbox } from "@plane/ui"; // constants import { MEMBER_ACCEPTED } from "@/constants/event-tracker"; -import { USER_WORKSPACES, USER_WORKSPACE_INVITATIONS } from "@/constants/fetch-keys"; +import { USER_WORKSPACE_INVITATIONS } from "@/constants/fetch-keys"; import { ROLE } from "@/constants/workspace"; // helpers import { truncateText } from "@/helpers/string.helper"; import { getUserRole } from "@/helpers/user.helper"; // hooks -import { useEventTracker, useUser, useWorkspace } from "@/hooks/store"; +import { useEventTracker, useWorkspace } from "@/hooks/store"; // services import { WorkspaceService } from "@/services/workspace.service"; type Props = { - handleNextStep: () => void; + handleNextStep: () => Promise; handleCurrentViewChange: () => void; }; const workspaceService = new WorkspaceService(); @@ -31,14 +29,9 @@ export const Invitations: React.FC = (props) => { const [invitationsRespond, setInvitationsRespond] = useState([]); // store hooks const { captureEvent } = useEventTracker(); - const { updateCurrentUser } = useUser(); - const { workspaces, fetchWorkspaces } = useWorkspace(); + const { fetchWorkspaces } = useWorkspace(); - const workspacesList = Object.values(workspaces); - - const { data: invitations, mutate: mutateInvitations } = useSWR(USER_WORKSPACE_INVITATIONS, () => - workspaceService.userWorkspaceInvitations() - ); + const { data: invitations } = useSWR(USER_WORKSPACE_INVITATIONS, () => workspaceService.userWorkspaceInvitations()); const handleInvitation = (workspace_invitation: IWorkspaceMemberInvitation, action: "accepted" | "withdraw") => { if (action === "accepted") { @@ -48,13 +41,6 @@ export const Invitations: React.FC = (props) => { } }; - const updateLastWorkspace = async () => { - if (!workspacesList) return; - await updateCurrentUser({ - last_workspace_id: workspacesList[0]?.id, - }); - }; - const submitInvitations = async () => { const invitation = invitations?.find((invitation) => invitation.id === invitationsRespond[0]); @@ -62,42 +48,37 @@ export const Invitations: React.FC = (props) => { setIsJoiningWorkspaces(true); - await workspaceService - .joinWorkspaces({ invitations: invitationsRespond }) - .then(async () => { - captureEvent(MEMBER_ACCEPTED, { - member_id: invitation?.id, - role: getUserRole(invitation?.role as any), - project_id: undefined, - accepted_from: "App", - state: "SUCCESS", - element: "Workspace invitations page", - }); - await fetchWorkspaces(); - await mutate(USER_WORKSPACES); - await updateLastWorkspace(); - await handleNextStep(); - await mutateInvitations(); - }) - .catch((error) => { - console.error(error); - captureEvent(MEMBER_ACCEPTED, { - member_id: invitation?.id, - role: getUserRole(invitation?.role as any), - project_id: undefined, - accepted_from: "App", - state: "FAILED", - element: "Workspace invitations page", - }); - }) - .finally(() => setIsJoiningWorkspaces(false)); + try { + await workspaceService.joinWorkspaces({ invitations: invitationsRespond }); + captureEvent(MEMBER_ACCEPTED, { + member_id: invitation?.id, + role: getUserRole(invitation?.role as any), + project_id: undefined, + accepted_from: "App", + state: "SUCCESS", + element: "Workspace invitations page", + }); + await fetchWorkspaces(); + await handleNextStep(); + } catch (error) { + console.error(error); + captureEvent(MEMBER_ACCEPTED, { + member_id: invitation?.id, + role: getUserRole(invitation?.role as any), + project_id: undefined, + accepted_from: "App", + state: "FAILED", + element: "Workspace invitations page", + }); + setIsJoiningWorkspaces(false); + } }; return invitations && invitations.length > 0 ? (

You are invited!

-

Accept the invites to collaborate with your team!

+

Accept the invites to collaborate with your team.

{invitations && @@ -108,11 +89,7 @@ export const Invitations: React.FC = (props) => { return (
handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} >
@@ -136,23 +113,35 @@ export const Invitations: React.FC = (props) => {
{truncateText(invitedWorkspace?.name, 30)}

{ROLE[invitation.role]}

- - + +
); })}
-

or


-
) : ( diff --git a/web/components/onboarding/invite-members.tsx b/web/components/onboarding/invite-members.tsx index 212b162f2..aa0a710ff 100644 --- a/web/components/onboarding/invite-members.tsx +++ b/web/components/onboarding/invite-members.tsx @@ -16,12 +16,12 @@ import { import { Check, ChevronDown, Plus, XCircle } from "lucide-react"; import { Listbox, Transition } from "@headlessui/react"; // types -import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types"; +import { IUser, IWorkspace } from "@plane/types"; // ui import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; // constants import { MEMBER_INVITED } from "@/constants/event-tracker"; -import { EUserWorkspaceRoles, ROLE } from "@/constants/workspace"; +import { EUserWorkspaceRoles, ROLE, ROLE_DETAILS } from "@/constants/workspace"; // helpers import { getUserRole } from "@/helpers/user.helper"; // hooks @@ -30,10 +30,8 @@ import useDynamicDropdownPosition from "@/hooks/use-dynamic-dropdown"; // services import { WorkspaceService } from "@/services/workspace.service"; // assets -import userDark from "public/onboarding/user-dark.svg"; -import userLight from "public/onboarding/user-light.svg"; -import user1 from "public/users/user-1.png"; -import user2 from "public/users/user-2.png"; +import InviteMembersDark from "public/onboarding/invite-members-dark.svg"; +import InviteMembersLight from "public/onboarding/invite-members-light.svg"; // components import { OnboardingHeader } from "./header"; import { SwitchOrDeleteAccountDropdown } from "./switch-or-delete-account-dropdown"; @@ -41,7 +39,6 @@ import { SwitchOrDeleteAccountDropdown } from "./switch-or-delete-account-dropdo type Props = { finishOnboarding: () => Promise; totalSteps: number; - stepChange: (steps: Partial) => Promise; user: IUser | undefined; workspace: IWorkspace | undefined; }; @@ -136,7 +133,7 @@ const InviteMemberInput: React.FC = (props) => { return (
-
+
= (props) => { )} />
-
+
= (props) => { type="button" ref={buttonRef} onClick={() => setIsDropdownOpen((prev) => !prev)} - className="flex w-full items-center justify-between gap-1 rounded-md px-2.5 py-2 text-xs duration-300" + className="flex w-full items-center justify-between gap-1 rounded-md px-2.5 py-2 text-sm border-[0.5px] border-onboarding-border-100" > = (props) => { = (props) => { >
- {Object.entries(ROLE).map(([key, value]) => ( + {Object.entries(ROLE_DETAILS).map(([key, value]) => ( = (props) => { } > {({ selected }) => ( -
-
{value}
- {selected && } +
+
+
{value.title}
+
{value.description}
+
+ {selected && }
)} @@ -266,7 +266,7 @@ const InviteMemberInput: React.FC = (props) => { }; export const InviteMembers: React.FC = (props) => { - const { finishOnboarding, totalSteps, stepChange, workspace } = props; + const { finishOnboarding, totalSteps, workspace } = props; const [isInvitationDisabled, setIsInvitationDisabled] = useState(true); @@ -289,11 +289,6 @@ export const InviteMembers: React.FC = (props) => { }); const nextStep = async () => { - const payload: Partial = { - workspace_invite: true, - }; - - await stepChange(payload); await finishOnboarding(); }; @@ -365,7 +360,7 @@ export const InviteMembers: React.FC = (props) => { return (
-
+
{/* Since this will always be the last step */} @@ -373,11 +368,11 @@ export const InviteMembers: React.FC = (props) => {
-
+

Invite your teammates

- Work in plane happens best with your team. Invite them now to use Plane to it’s potential. + Work in plane happens best with your team. Invite them now to use Plane to its potential.

= (props) => {
-
+
-
+
-