Merge branch 'develop' of gurusainath:makeplane/plane into chore-admin-file-structure

This commit is contained in:
gurusainath 2024-05-01 11:32:04 +05:30
commit 0e1ae5ce96
101 changed files with 2731 additions and 2011 deletions

View File

@ -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

View File

@ -1,28 +1,53 @@
name: Create Sync Action name: Create PR on Sync
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: branches:
- preview - "sync/**"
env: 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: jobs:
sync_changes: Check_Branch:
runs-on: ubuntu-20.04 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: permissions:
pull-requests: write pull-requests: write
contents: read contents: write
steps: steps:
- name: Checkout Code - name: Checkout code
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
with: with:
persist-credentials: false fetch-depth: 0 # Fetch all history for all branches and tags
fetch-depth: 0
- 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: | run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) 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 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 update
sudo apt install gh -y sudo apt install gh -y
- name: Push Changes to Target Repo A - name: Create PR to Target Branch
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: | run: |
TARGET_REPO="${{ secrets.TARGET_REPO_A }}" # get all pull requests and check if there is already a PR
TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}" PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number')
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" if [ -n "$PR_EXISTS" ]; then
echo "Pull Request already exists: $PR_EXISTS"
git checkout $SOURCE_BRANCH else
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git" echo "Creating new pull request"
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH 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"
- name: Push Changes to Target Repo B fi
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

44
.github/workflows/repo-sync.yml vendored Normal file
View File

@ -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

View File

@ -34,7 +34,7 @@ interface CustomEditorProps {
suggestions?: () => Promise<IMentionSuggestion[]>; suggestions?: () => Promise<IMentionSuggestion[]>;
}; };
handleEditorReady?: (value: boolean) => void; handleEditorReady?: (value: boolean) => void;
placeholder?: string | ((isFocused: boolean) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number; tabIndex?: number;
} }

View File

@ -43,7 +43,7 @@ type TArguments = {
cancelUploadImage?: () => void; cancelUploadImage?: () => void;
uploadFile: UploadImage; uploadFile: UploadImage;
}; };
placeholder?: string | ((isFocused: boolean) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number; tabIndex?: number;
}; };
@ -147,7 +147,7 @@ export const CoreEditorExtensions = ({
if (placeholder) { if (placeholder) {
if (typeof placeholder === "string") return placeholder; if (typeof placeholder === "string") return placeholder;
else return placeholder(editor.isFocused); else return placeholder(editor.isFocused, editor.getHTML());
} }
return "Press '/' for commands..."; return "Press '/' for commands...";

View File

@ -31,7 +31,7 @@ interface IDocumentEditor {
suggestions: () => Promise<IMentionSuggestion[]>; suggestions: () => Promise<IMentionSuggestion[]>;
}; };
tabIndex?: number; tabIndex?: number;
placeholder?: string | ((isFocused: boolean) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
} }
const DocumentEditor = (props: IDocumentEditor) => { const DocumentEditor = (props: IDocumentEditor) => {

View File

@ -32,7 +32,7 @@ export interface ILiteTextEditor {
suggestions?: () => Promise<IMentionSuggestion[]>; suggestions?: () => Promise<IMentionSuggestion[]>;
}; };
tabIndex?: number; tabIndex?: number;
placeholder?: string | ((isFocused: boolean) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
} }
const LiteTextEditor = (props: ILiteTextEditor) => { const LiteTextEditor = (props: ILiteTextEditor) => {

View File

@ -35,7 +35,7 @@ export type IRichTextEditor = {
highlights: () => Promise<IMentionHighlight[]>; highlights: () => Promise<IMentionHighlight[]>;
suggestions: () => Promise<IMentionSuggestion[]>; suggestions: () => Promise<IMentionSuggestion[]>;
}; };
placeholder?: string | ((isFocused: boolean) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number; tabIndex?: number;
}; };

View File

@ -0,0 +1,2 @@
export * from "./item";
export * from "./root";

View File

@ -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<ContextMenuItemProps> = (props) => {
const { handleActiveItem, handleClose, isActive, item } = props;
if (item.shouldRender === false) return null;
return (
<button
type="button"
className={cn(
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
{
"bg-custom-background-90": isActive,
"text-custom-text-400": item.disabled,
},
item.className
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
if (item.closeOnClick !== false) handleClose();
}}
onMouseEnter={handleActiveItem}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</button>
);
};

View File

@ -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<any>;
action: () => void;
shouldRender?: boolean;
closeOnClick?: boolean;
disabled?: boolean;
className?: string;
iconClassName?: string;
};
type ContextMenuProps = {
parentRef: React.RefObject<HTMLElement>;
items: TContextMenuItem[];
};
const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
const { parentRef, items } = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({
x: 0,
y: 0,
});
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
// refs
const contextMenuRef = useRef<HTMLDivElement>(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 (
<div
className={cn(
"fixed h-screen w-screen top-0 left-0 cursor-default z-20 opacity-0 pointer-events-none transition-opacity",
{
"opacity-100 pointer-events-auto": isOpen,
}
)}
>
<div
ref={contextMenuRef}
className="fixed border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg rounded-md px-2 py-2.5 max-h-72 min-w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
style={{
top: position.y,
left: position.x,
}}
>
{renderedItems.map((item, index) => (
<ContextMenuItem
key={item.key}
handleActiveItem={() => setActiveItemIndex(index)}
handleClose={handleClose}
isActive={index === activeItemIndex}
item={item}
/>
))}
</div>
</div>
);
};
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
let contextMenu = <ContextMenuWithoutPortal {...props} />;
const portal = document.querySelector("#context-menu-portal");
if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal);
return contextMenu;
};

View File

@ -1,3 +1,4 @@
export * from "./context-menu";
export * from "./custom-menu"; export * from "./custom-menu";
export * from "./custom-select"; export * from "./custom-select";
export * from "./custom-search-select"; export * from "./custom-search-select";

View File

@ -6,6 +6,7 @@ class MyDocument extends Document {
<Html> <Html>
<Head /> <Head />
<body className="w-100 bg-custom-background-100 antialiased"> <body className="w-100 bg-custom-background-100 antialiased">
<div id="context-menu-portal" />
<Main /> <Main />
<NextScript /> <NextScript />
</body> </body>

View File

@ -0,0 +1,2 @@
export * from "./list-item";
export * from "./list-root";

View File

@ -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<HTMLAnchorElement>) => void;
prependTitleElement?: JSX.Element;
appendTitleElement?: JSX.Element;
actionableItems?: JSX.Element;
isMobile?: boolean;
parentRef: React.RefObject<HTMLDivElement>;
}
export const ListItem: FC<IListItemProps> = (props) => {
const {
title,
prependTitleElement,
appendTitleElement,
actionableItems,
itemLink,
onItemClick,
isMobile = false,
parentRef,
} = props;
return (
<div ref={parentRef} className="relative">
<Link href={itemLink} onClick={onItemClick}>
<div className="group h-24 sm:h-[52px] flex w-full flex-col items-center justify-between gap-3 sm:gap-5 px-6 py-4 sm:py-0 text-sm border-b border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90 sm:flex-row">
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
<div className="flex items-center gap-4 truncate">
{prependTitleElement && <span className="flex items-center flex-shrink-0">{prependTitleElement}</span>}
<Tooltip tooltipContent={title} position="top" isMobile={isMobile}>
<span className="truncate text-sm">{title}</span>
</Tooltip>
</div>
{appendTitleElement && <span className="flex items-center flex-shrink-0">{appendTitleElement}</span>}
</div>
</div>
<span className="h-6 w-96 flex-shrink-0" />
</div>
</Link>
{actionableItems && (
<div className="absolute right-5 bottom-4 flex items-center gap-1.5">
<div className="relative flex items-center gap-4 sm:w-auto sm:flex-shrink-0 sm:justify-end">
{actionableItems}
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,10 @@
import React, { FC } from "react";
interface IListContainer {
children: React.ReactNode;
}
export const ListLayout: FC<IListContainer> = (props) => {
const { children } = props;
return <div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">{children}</div>;
};

View File

@ -1,3 +1,4 @@
import { useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -20,6 +21,8 @@ type Props = {
export const UpcomingCycleListItem: React.FC<Props> = observer((props) => { export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
const { cycleId } = props; const { cycleId } = props;
// refs
const parentRef = useRef(null);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -90,6 +93,7 @@ export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
return ( return (
<Link <Link
ref={parentRef}
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`} href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`}
className="py-5 px-2 flex items-center justify-between gap-2 hover:bg-custom-background-90" className="py-5 px-2 flex items-center justify-between gap-2 hover:bg-custom-background-90"
> >
@ -123,6 +127,7 @@ export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
{workspaceSlug && projectId && ( {workspaceSlug && projectId && (
<CycleQuickActions <CycleQuickActions
parentRef={parentRef}
cycleId={cycleId} cycleId={cycleId}
projectId={projectId.toString()} projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}

View File

@ -1,4 +1,4 @@
import { FC, MouseEvent } from "react"; import { FC, MouseEvent, useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -28,6 +28,8 @@ export interface ICyclesBoardCard {
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => { export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props; const { cycleId, workspaceSlug, projectId } = props;
// refs
const parentRef = useRef(null);
// router // router
const router = useRouter(); const router = useRouter();
// store // store
@ -150,7 +152,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
return ( return (
<div className="relative"> <div className="relative">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}> <Link ref={parentRef} href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md"> <div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 truncate"> <div className="flex items-center gap-3 truncate">
@ -246,7 +248,12 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
/> />
)} )}
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} /> <CycleQuickActions
parentRef={parentRef}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div> </div>
</div> </div>
); );

View File

@ -76,7 +76,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
}; };
return ( return (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-custom-border-200 px-4 sm:px-5 sm:pb-0"> <div className="h-[50px] flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-custom-border-200 px-6 sm:pb-0">
<Tab.List as="div" className="flex items-center overflow-x-scroll"> <Tab.List as="div" className="flex items-center overflow-x-scroll">
{CYCLE_TABS_LIST.map((tab) => ( {CYCLE_TABS_LIST.map((tab) => (
<Tab <Tab

View File

@ -0,0 +1,156 @@
import React, { FC, MouseEvent } from "react";
import { observer } from "mobx-react";
import { User2 } from "lucide-react";
// types
import { ICycle, TCycleGroups } from "@plane/types";
// ui
import { Avatar, AvatarGroup, Tooltip, setPromiseToast } 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";
// hooks
import { useCycle, useEventTracker, useMember, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
workspaceSlug: string;
projectId: string;
cycleId: string;
cycleDetails: ICycle;
parentRef: React.RefObject<HTMLDivElement>;
};
export const CycleListItemAction: FC<Props> = 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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 (
<>
<div className="text-xs text-custom-text-300 flex-shrink-0">
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</div>
{currentCycle && (
<div
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`}
</div>
)}
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{isEditingAllowed && !cycleDetails.archived_at && (
<FavoriteStar
onClick={(e) => {
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
selected={!!cycleDetails.is_favorite}
/>
)}
<CycleQuickActions parentRef={parentRef} cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
</>
);
});

View File

@ -1,24 +1,17 @@
import { FC, MouseEvent } from "react"; import { FC, MouseEvent, useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// icons // icons
import { Check, Info, User2 } from "lucide-react"; import { Check, Info } from "lucide-react";
// types // types
import type { TCycleGroups } from "@plane/types"; import type { TCycleGroups } from "@plane/types";
// ui // ui
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; import { CircularProgressIndicator } from "@plane/ui";
// components // components
import { FavoriteStar } from "@/components/core"; import { ListItem } from "@/components/core/list";
import { CycleQuickActions } from "@/components/cycles"; import { CycleListItemAction } from "@/components/cycles/list";
// 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";
// hooks // hooks
import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store"; import { useCycle } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
type TCyclesListItem = { type TCyclesListItem = {
@ -29,79 +22,41 @@ type TCyclesListItem = {
handleRemoveFromFavorites?: () => void; handleRemoveFromFavorites?: () => void;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
isArchived?: boolean;
}; };
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => { export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId, isArchived } = props; const { cycleId, workspaceSlug, projectId } = props;
// refs
const parentRef = useRef(null);
// router // router
const router = useRouter(); const router = useRouter();
// hooks // hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
// store hooks // store hooks
const { captureEvent } = useEventTracker(); const { getCycleById } = useCycle();
const {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember();
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { // derived values
e.preventDefault(); const cycleDetails = getCycleById(cycleId);
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( if (!cycleDetails) return null;
() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
}
);
setPromiseToast(addToFavoritePromise, { // computed
loading: "Adding cycle to favorites...", // TODO: change this logic once backend fix the response
success: { const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
title: "Success!", const isCompleted = cycleStatus === "completed";
message: () => "Cycle added to favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't add the cycle to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => { const cycleTotalIssues =
e.preventDefault(); cycleDetails.backlog_issues +
if (!workspaceSlug || !projectId) return; cycleDetails.unstarted_issues +
cycleDetails.started_issues +
cycleDetails.completed_issues +
cycleDetails.cancelled_issues;
const removeFromFavoritePromise = removeCycleFromFavorites( const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100;
workspaceSlug?.toString(),
projectId.toString(),
cycleId
).then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
});
setPromiseToast(removeFromFavoritePromise, { const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
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.",
},
});
};
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => { const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
const { query } = router; const { query } = router;
e.preventDefault(); e.preventDefault();
@ -121,53 +76,15 @@ export const CyclesListItem: FC<TCyclesListItem> = 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 ( return (
<div className="relative"> <ListItem
<Link title={cycleDetails?.name ?? ""}
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`} itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
onClick={(e) => { onItemClick={(e) => {
if (isArchived) { if (cycleDetails.archived_at) openCycleOverview(e);
openCycleOverview(e);
}
}} }}
> prependTitleElement={
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row"> <CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
<div className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}>
{isCompleted ? ( {isCompleted ? (
progress === 100 ? ( progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" /> <Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
@ -180,80 +97,26 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
<span className="text-xs text-custom-text-300">{`${progress}%`}</span> <span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)} )}
</CircularProgressIndicator> </CircularProgressIndicator>
</div> }
appendTitleElement={
<div className="relative flex items-center gap-2.5 overflow-hidden"> <button
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" /> onClick={openCycleOverview}
<Tooltip tooltipContent={cycleDetails.name} position="top" isMobile={isMobile}> className={`z-[5] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
<span className="line-clamp-1 inline-block overflow-hidden truncate text-base font-medium"> >
{cycleDetails.name}
</span>
</Tooltip>
</div>
<button onClick={openCycleOverview} className="invisible z-[5] flex-shrink-0 group-hover:visible">
<Info className="h-4 w-4 text-custom-text-400" /> <Info className="h-4 w-4 text-custom-text-400" />
</button> </button>
</div> }
<div className="text-xs text-custom-text-300 flex-shrink-0"> actionableItems={
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} <CycleListItemAction
</div>
</div>
<span className="h-6 w-52 flex-shrink-0" />
</div>
</Link>
<div className="absolute right-5 bottom-8 flex items-center gap-1.5">
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 md:w-auto md:flex-shrink-0 md:justify-end">
{currentCycle && (
<div
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`}
</div>
)}
<div className="relative flex flex-shrink-0 items-center gap-3">
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{isEditingAllowed && !isArchived && (
<FavoriteStar
onClick={(e) => {
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
selected={!!cycleDetails.is_favorite}
/>
)}
<CycleQuickActions
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
isArchived={isArchived} projectId={projectId}
cycleId={cycleId}
cycleDetails={cycleDetails}
parentRef={parentRef}
/>
}
isMobile={isMobile}
parentRef={parentRef}
/> />
</div>
</div>
</div>
</div>
); );
}); });

View File

@ -5,22 +5,15 @@ type Props = {
cycleIds: string[]; cycleIds: string[];
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;
isArchived?: boolean;
}; };
export const CyclesListMap: React.FC<Props> = (props) => { export const CyclesListMap: React.FC<Props> = (props) => {
const { cycleIds, projectId, workspaceSlug, isArchived } = props; const { cycleIds, projectId, workspaceSlug } = props;
return ( return (
<> <>
{cycleIds.map((cycleId) => ( {cycleIds.map((cycleId) => (
<CyclesListItem <CyclesListItem key={cycleId} cycleId={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} />
key={cycleId}
cycleId={cycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
isArchived={isArchived}
/>
))} ))}
</> </>
); );

View File

@ -1,3 +1,4 @@
export * from "./cycles-list-item"; export * from "./cycles-list-item";
export * from "./cycles-list-map"; export * from "./cycles-list-map";
export * from "./root"; export * from "./root";
export * from "./cycle-list-item-action";

View File

@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Disclosure } from "@headlessui/react"; import { Disclosure } from "@headlessui/react";
// components // components
import { ListLayout } from "@/components/core/list";
import { CyclePeekOverview, CyclesListMap } from "@/components/cycles"; import { CyclePeekOverview, CyclesListMap } from "@/components/cycles";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
@ -21,12 +22,11 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
return ( return (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between"> <div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg"> <ListLayout>
<CyclesListMap <CyclesListMap
cycleIds={cycleIds} cycleIds={cycleIds}
projectId={projectId} projectId={projectId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
isArchived={isArchived}
/> />
{completedCycleIds.length !== 0 && ( {completedCycleIds.length !== 0 && (
<Disclosure as="div" className="py-8 pl-3 space-y-4"> <Disclosure as="div" className="py-8 pl-3 space-y-4">
@ -43,16 +43,11 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
)} )}
</Disclosure.Button> </Disclosure.Button>
<Disclosure.Panel> <Disclosure.Panel>
<CyclesListMap <CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
cycleIds={completedCycleIds}
projectId={projectId}
workspaceSlug={workspaceSlug}
isArchived={isArchived}
/>
</Disclosure.Panel> </Disclosure.Panel>
</Disclosure> </Disclosure>
)} )}
</div> </ListLayout>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} /> <CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} />
</div> </div>
</div> </div>

View File

@ -2,27 +2,28 @@ import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// icons // icons
import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react"; import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
// ui // ui
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles"; import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
// constants // constants
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers // helpers
import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks // hooks
import { useCycle, useEventTracker, useUser } from "@/hooks/store"; import { useCycle, useEventTracker, useUser } from "@/hooks/store";
type Props = { type Props = {
parentRef: React.RefObject<HTMLElement>;
cycleId: string; cycleId: string;
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;
isArchived?: boolean;
}; };
export const CycleQuickActions: React.FC<Props> = observer((props) => { export const CycleQuickActions: React.FC<Props> = observer((props) => {
const { cycleId, projectId, workspaceSlug, isArchived } = props; const { parentRef, cycleId, projectId, workspaceSlug } = props;
// router // router
const router = useRouter(); const router = useRouter();
// states // states
@ -37,40 +38,31 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
const { getCycleById, restoreCycle } = useCycle(); const { getCycleById, restoreCycle } = useCycle();
// derived values // derived values
const cycleDetails = getCycleById(cycleId); const cycleDetails = getCycleById(cycleId);
const isArchived = !!cycleDetails?.archived_at;
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed"; const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
// auth // auth
const isEditingAllowed = const isEditingAllowed =
!!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER; !!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER;
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => { const cycleLink = `${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`;
e.preventDefault(); const handleCopyText = () =>
e.stopPropagation(); copyUrlToClipboard(cycleLink).then(() => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Link Copied!", title: "Link Copied!",
message: "Cycle link copied to clipboard.", message: "Cycle link copied to clipboard.",
}); });
}); });
}; const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank");
const handleEditCycle = (e: React.MouseEvent<HTMLButtonElement>) => { const handleEditCycle = () => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout"); setTrackElement("Cycles page list layout");
setUpdateModal(true); setUpdateModal(true);
}; };
const handleArchiveCycle = (e: React.MouseEvent<HTMLButtonElement>) => { const handleArchiveCycle = () => setArchiveCycleModal(true);
e.preventDefault();
e.stopPropagation();
setArchiveCycleModal(true);
};
const handleRestoreCycle = async (e: React.MouseEvent<HTMLButtonElement>) => { const handleRestoreCycle = async () =>
e.preventDefault();
e.stopPropagation();
await restoreCycle(workspaceSlug, projectId, cycleId) await restoreCycle(workspaceSlug, projectId, cycleId)
.then(() => { .then(() => {
setToast({ setToast({
@ -87,15 +79,61 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
message: "Cycle could not be restored. Please try again.", message: "Cycle could not be restored. Please try again.",
}) })
); );
};
const handleDeleteCycle = (e: React.MouseEvent<HTMLButtonElement>) => { const handleDeleteCycle = () => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout"); setTrackElement("Cycles page list layout");
setDeleteModal(true); 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 ( return (
<> <>
{cycleDetails && ( {cycleDetails && (
@ -123,60 +161,42 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
/> />
</div> </div>
)} )}
<CustomMenu ellipsis placement="bottom-end"> <ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
{!isCompleted && isEditingAllowed && !isArchived && ( <CustomMenu ellipsis placement="bottom-end" closeOnSelect>
<CustomMenu.MenuItem onClick={handleEditCycle}> {MENU_ITEMS.map((item) => {
<span className="flex items-center justify-start gap-2"> if (item.shouldRender === false) return null;
<Pencil className="h-3 w-3" /> return (
<span>Edit cycle</span> <CustomMenu.MenuItem
</span> key={item.key}
</CustomMenu.MenuItem> onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)} )}
{isEditingAllowed && !isArchived && ( >
<CustomMenu.MenuItem onClick={handleArchiveCycle} disabled={!isCompleted}> {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
{isCompleted ? ( <div>
<div className="flex items-center gap-2"> <h5>{item.title}</h5>
<ArchiveIcon className="h-3 w-3" /> {item.description && (
Archive cycle <p
</div> className={cn("text-custom-text-300 whitespace-pre-line", {
) : ( "text-custom-text-400": item.disabled,
<div className="flex items-start gap-2"> })}
<ArchiveIcon className="h-3 w-3" /> >
<div className="-mt-1"> {item.description}
<p>Archive cycle</p>
<p className="text-xs text-custom-text-400">
Only completed cycle <br /> can be archived.
</p> </p>
)}
</div> </div>
</div>
)}
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} );
{isEditingAllowed && isArchived && ( })}
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isArchived && (
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
)}
<hr className="my-2 border-custom-border-200" />
{!isCompleted && isEditingAllowed && (
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
</CustomMenu> </CustomMenu>
</> </>
); );

View File

@ -36,7 +36,7 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
} = props; } = props;
// store hooks // store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart(); const { updateActiveBlockId, isBlockActive } = useGanttChart();
const { peekIssue } = useIssueDetail(); const { getIsIssuePeeked } = useIssueDetail();
const isBlockVisibleOnChart = block.start_date && block.target_date; const isBlockVisibleOnChart = block.start_date && block.target_date;
@ -81,8 +81,9 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
<div <div
className={cn("relative h-full", { className={cn("relative h-full", {
"bg-custom-background-80": isBlockActive(block.id), "bg-custom-background-80": isBlockActive(block.id),
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70": "rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(
peekIssue?.issueId === block.data.id, block.data.id
),
})} })}
onMouseEnter={() => updateActiveBlockId(block.id)} onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)} onMouseLeave={() => updateActiveBlockId(null)}

View File

@ -25,7 +25,7 @@ export const IssuesSidebarBlock: React.FC<Props> = observer((props) => {
const { block, enableReorder, provided, snapshot } = props; const { block, enableReorder, provided, snapshot } = props;
// store hooks // store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart(); const { updateActiveBlockId, isBlockActive } = useGanttChart();
const { peekIssue } = useIssueDetail(); const { getIsIssuePeeked } = useIssueDetail();
const duration = findTotalDaysInRange(block.start_date, block.target_date); const duration = findTotalDaysInRange(block.start_date, block.target_date);
@ -33,8 +33,9 @@ export const IssuesSidebarBlock: React.FC<Props> = observer((props) => {
<div <div
className={cn({ className={cn({
"rounded bg-custom-background-80": snapshot.isDragging, "rounded bg-custom-background-80": snapshot.isDragging,
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70": "rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(
peekIssue?.issueId === block.data.id, block.data.id
),
})} })}
onMouseEnter={() => updateActiveBlockId(block.id)} onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)} onMouseLeave={() => updateActiveBlockId(null)}

View File

@ -1,15 +1,16 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// icons
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
// hooks
// ui // ui
import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui";
// helpers
// components // components
import { BreadcrumbLink } from "@/components/common"; import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project"; import { ProjectLogo } from "@/components/project";
// constants
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store"; import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
export const CyclesHeader: FC = observer(() => { export const CyclesHeader: FC = observer(() => {
@ -28,8 +29,7 @@ export const CyclesHeader: FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
return ( return (
<div className="relative z-10 items-center justify-between gap-x-2 gap-y-4"> <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div> <div>
<Breadcrumbs onBack={router.back}> <Breadcrumbs onBack={router.back}>
@ -41,7 +41,7 @@ export const CyclesHeader: FC = observer(() => {
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
icon={ icon={
currentProjectDetails && ( currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center"> <span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" /> <ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
</span> </span>
) )
@ -51,9 +51,7 @@ export const CyclesHeader: FC = observer(() => {
/> />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={ link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />}
<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />
}
/> />
</Breadcrumbs> </Breadcrumbs>
</div> </div>
@ -74,6 +72,5 @@ export const CyclesHeader: FC = observer(() => {
</div> </div>
)} )}
</div> </div>
</div>
); );
}); });

View File

@ -1,32 +1,21 @@
import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react-lite";
import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ListFilter, Plus, Search, X } from "lucide-react"; // icons
// types import { Plus } from "lucide-react";
import { TModuleFilters } from "@plane/types";
// ui // ui
import { Breadcrumbs, Button, Tooltip, DiceIcon } from "@plane/ui"; import { Breadcrumbs, Button, DiceIcon } from "@plane/ui";
// components // components
import { BreadcrumbLink } from "@/components/common"; import { BreadcrumbLink } from "@/components/common";
import { FiltersDropdown } from "@/components/issues";
import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules";
import { ProjectLogo } from "@/components/project"; import { ProjectLogo } from "@/components/project";
// constants // constants
import { MODULE_VIEW_LAYOUTS } from "@/constants/module";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useEventTracker, useMember, useModuleFilter, useProject, useUser, useCommandPalette } from "@/hooks/store"; import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
export const ModulesListHeader: React.FC = observer(() => { export const ModulesListHeader: React.FC = observer(() => {
// refs
const inputRef = useRef<HTMLInputElement>(null);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug } = router.query;
// store hooks // store hooks
const { toggleCreateModuleModal } = useCommandPalette(); const { toggleCreateModuleModal } = useCommandPalette();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
@ -34,54 +23,6 @@ export const ModulesListHeader: React.FC = observer(() => {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { currentProjectDetails } = useProject(); 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<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
// auth // auth
const canUserCreateModule = const canUserCreateModule =
@ -116,97 +57,6 @@ export const ModulesListHeader: React.FC = observer(() => {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center">
{!isSearchOpen && (
<button
type="button"
className="-mr-1 grid place-items-center rounded p-2 text-custom-text-400 hover:bg-custom-background-80"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 opacity-0 transition-[width] ease-linear",
{
"w-64 border-custom-border-200 px-2.5 py-1.5 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
// updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
<div className="hidden items-center gap-1 rounded bg-custom-background-80 p-1 md:flex">
{MODULE_VIEW_LAYOUTS.map((layout) => (
<Tooltip key={layout.key} tooltipContent={layout.title} isMobile={isMobile}>
<button
type="button"
className={cn(
"group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100",
{
"bg-custom-background-100 shadow-custom-shadow-2xs": displayFilters?.layout === layout.key,
}
)}
onClick={() => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), { layout: layout.key });
}}
>
<layout.icon
strokeWidth={2}
className={cn("h-3.5 w-3.5 text-custom-text-200", {
"text-custom-text-100": displayFilters?.layout === layout.key,
})}
/>
</button>
</Tooltip>
))}
</div>
<ModuleOrderByDropdown
value={displayFilters?.order_by}
onChange={(val) => {
if (!projectId || val === displayFilters?.order_by) return;
updateDisplayFilters(projectId.toString(), {
order_by: val,
});
}}
/>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<ModuleFiltersSelection
displayFilters={displayFilters ?? {}}
filters={filters ?? {}}
handleDisplayFiltersUpdate={(val) => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), val);
}}
handleFiltersUpdate={handleFilters}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
{canUserCreateModule && ( {canUserCreateModule && (
<Button <Button
variant="primary" variant="primary"

View File

@ -114,8 +114,8 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)} onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined} projectDetails={currentProjectDetails ?? undefined}
/> />
<div className="relative z-[15] items-center gap-x-2 gap-y-4">
<div className="flex items-center gap-2 bg-custom-sidebar-background-100 p-4"> <div className="relative z-[15] flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()}> <Breadcrumbs onBack={() => router.back()}>
@ -128,7 +128,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
icon={ icon={
currentProjectDetails ? ( currentProjectDetails ? (
currentProjectDetails && ( currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center"> <span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" /> <ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
</span> </span>
) )
@ -144,9 +144,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={ link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
}
/> />
</Breadcrumbs> </Breadcrumbs>
{issueCount && issueCount > 0 ? ( {issueCount && issueCount > 0 ? (
@ -155,7 +153,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in this project`} tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in this project`}
position="bottom" position="bottom"
> >
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2.5 py-0.5 text-center text-xs font-semibold text-custom-primary-100"> <span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount} {issueCount}
</span> </span>
</Tooltip> </Tooltip>
@ -174,7 +172,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
</a> </a>
)} )}
</div> </div>
<div className="hidden items-center gap-2 md:flex"> <div className="items-center gap-2 hidden md:flex">
<LayoutSelection <LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]} layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)} onChange={(layout) => handleLayoutChange(layout)}
@ -232,7 +230,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
</> </>
)} )}
</div> </div>
</div>
</> </>
); );
}); });

View File

@ -26,13 +26,11 @@ type Props = {
isEditable: boolean; isEditable: boolean;
isSubmitting: "submitting" | "submitted" | "saved"; isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: Dispatch<SetStateAction<"submitting" | "submitted" | "saved">>; setIsSubmitting: Dispatch<SetStateAction<"submitting" | "submitted" | "saved">>;
swrIssueDescription: string | undefined;
}; };
export const InboxIssueMainContent: React.FC<Props> = observer((props) => { export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting, swrIssueDescription } = const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
props;
// hooks // hooks
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
@ -137,7 +135,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={issue.project_id} projectId={issue.project_id}
issueId={issue.id} issueId={issue.id}
swrIssueDescription={swrIssueDescription} swrIssueDescription={null}
initialValue={issue.description_html ?? "<p></p>"} initialValue={issue.description_html ?? "<p></p>"}
disabled={!isEditable} disabled={!isEditable}
issueOperations={issueOperations} issueOperations={issueOperations}

View File

@ -24,14 +24,13 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { data: swrIssueDetails } = useSWR( useSWR(
workspaceSlug && projectId && inboxIssueId workspaceSlug && projectId && inboxIssueId
? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}` ? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
: null, : null,
workspaceSlug && projectId && inboxIssueId workspaceSlug && projectId && inboxIssueId
? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId) ? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId)
: null, : null
{ revalidateOnFocus: true }
); );
const isEditable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const isEditable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -61,7 +60,6 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
isEditable={isEditable && !isIssueDisabled} isEditable={isEditable && !isIssueDisabled}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting} setIsSubmitting={setIsSubmitting}
swrIssueDescription={swrIssueDetails?.issue.description_html}
/> />
</div> </div>
</div> </div>

View File

@ -136,9 +136,12 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
/> />
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} /> <InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} />
<div className="relative flex justify-between items-center gap-3"> <div className="relative flex justify-between items-center gap-3">
<div className="flex cursor-pointer items-center gap-1" onClick={() => setCreateMore((prevData) => !prevData)}> <div
className="flex cursor-pointer items-center gap-1.5"
onClick={() => setCreateMore((prevData) => !prevData)}
>
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
<span className="text-xs">Create more</span> <span className="text-xs">Create more</span>
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
</div> </div>
<div className="relative flex items-center gap-3"> <div className="relative flex items-center gap-3">
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}> <Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}>

View File

@ -5,6 +5,8 @@ import { TIssue } from "@plane/types";
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
// components // components
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor"; import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
// helpers
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
// hooks // hooks
import { useProjectInbox } from "@/hooks/store"; import { useProjectInbox } from "@/hooks/store";
@ -39,10 +41,7 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
projectId={projectId} projectId={projectId}
dragDropEnabled={false} dragDropEnabled={false}
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)} onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
placeholder={(isFocused) => { placeholder={getDescriptionPlaceholder}
if (isFocused) return "Press '/' for commands...";
else return "Click to add description";
}}
/> />
</div> </div>
); );

View File

@ -50,7 +50,7 @@ export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props)
<div <div
className={cn( className={cn(
`flex flex-col gap-2 relative border border-t-transparent border-l-transparent border-r-transparent border-b-custom-border-200 p-4 hover:bg-custom-primary/5 cursor-pointer transition-all`, `flex flex-col gap-2 relative border border-t-transparent border-l-transparent border-r-transparent border-b-custom-border-200 p-4 hover:bg-custom-primary/5 cursor-pointer transition-all`,
{ "bg-custom-primary/5 border-custom-primary-100 border": inboxIssueId === issue.id } { "border-custom-primary-100 border": inboxIssueId === issue.id }
)} )}
> >
<div className="space-y-1"> <div className="space-y-1">

View File

@ -65,14 +65,14 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
}); });
return ( return (
<div className="bg-custom-background-100 flex-shrink-0 w-full h-full border-r border-custom-border-300"> <div className="bg-custom-background-100 flex-shrink-0 w-full h-full border-r border-custom-border-300 ">
<div className="relative w-full h-full flex flex-col overflow-hidden"> <div className="relative w-full h-full flex flex-col overflow-hidden">
<div className="border-b border-custom-border-300 flex-shrink-0 w-full h-[50px] relative flex items-center gap-2 pr-3 whitespace-nowrap"> <div className="border-b border-custom-border-300 flex-shrink-0 w-full h-[50px] relative flex items-center gap-2 whitespace-nowrap px-3">
{tabNavigationOptions.map((option) => ( {tabNavigationOptions.map((option) => (
<div <div
key={option?.key} key={option?.key}
className={cn( className={cn(
`text-sm relative flex items-center gap-1 h-[50px] px-2 cursor-pointer transition-all font-medium`, `text-sm relative flex items-center gap-1 h-[50px] px-3 cursor-pointer transition-all font-medium`,
currentTab === option?.key ? `text-custom-primary-100` : `hover:text-custom-text-200` currentTab === option?.key ? `text-custom-primary-100` : `hover:text-custom-text-200`
)} )}
onClick={() => { onClick={() => {

View File

@ -9,6 +9,8 @@ import { Loader } from "@plane/ui";
// components // components
import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor"; import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor";
import { TIssueOperations } from "@/components/issues/issue-detail"; import { TIssueOperations } from "@/components/issues/issue-detail";
// helpers
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
// hooks // hooks
import { useWorkspace } from "@/hooks/store"; import { useWorkspace } from "@/hooks/store";
@ -19,7 +21,7 @@ export type IssueDescriptionInputProps = {
initialValue: string | undefined; initialValue: string | undefined;
disabled?: boolean; disabled?: boolean;
issueOperations: TIssueOperations; issueOperations: TIssueOperations;
placeholder?: string | ((isFocused: boolean) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void; setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void;
swrIssueDescription: string | null | undefined; swrIssueDescription: string | null | undefined;
}; };
@ -106,12 +108,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
debouncedFormSave(); debouncedFormSave();
}} }}
placeholder={ placeholder={
placeholder placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)
? placeholder
: (isFocused) => {
if (isFocused) return "Press '/' for commands...";
else return "Click to add description";
}
} }
/> />
) : ( ) : (

View File

@ -89,8 +89,9 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
groupedIssueIds={groupedIssueIds} groupedIssueIds={groupedIssueIds}
layout={displayFilters?.calendar?.layout} layout={displayFilters?.calendar?.layout}
showWeekends={displayFilters?.calendar?.show_weekends ?? false} showWeekends={displayFilters?.calendar?.show_weekends ?? false}
quickActions={(issue, customActionButton, placement) => ( quickActions={({ issue, parentRef, customActionButton, placement }) => (
<QuickActions <QuickActions
parentRef={parentRef}
customActionButton={customActionButton} customActionButton={customActionButton}
issue={issue} issue={issue}
handleDelete={async () => removeIssue(issue.project_id, issue.id)} handleDelete={async () => removeIssue(issue.project_id, issue.id)}

View File

@ -1,5 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// types // types
import type { import type {
@ -31,7 +30,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle";
import { IModuleIssuesFilter } from "@/store/issue/module"; import { IModuleIssuesFilter } from "@/store/issue/module";
import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectIssuesFilter } from "@/store/issue/project";
import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
// types import { TRenderQuickActions } from "../list/list-view-types";
import type { ICalendarWeek } from "./types"; import type { ICalendarWeek } from "./types";
type Props = { type Props = {
@ -40,7 +39,7 @@ type Props = {
groupedIssueIds: TGroupedIssues; groupedIssueIds: TGroupedIssues;
layout: "month" | "week" | undefined; layout: "month" | "week" | undefined;
showWeekends: boolean; showWeekends: boolean;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; quickActions: TRenderQuickActions;
quickAddCallback?: ( quickAddCallback?: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,

View File

@ -1,5 +1,4 @@
import { Droppable } from "@hello-pangea/dnd"; import { Droppable } from "@hello-pangea/dnd";
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// types // types
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/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 { IModuleIssuesFilter } from "@/store/issue/module";
import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectIssuesFilter } from "@/store/issue/project";
import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
import { TRenderQuickActions } from "../list/list-view-types";
type Props = { type Props = {
issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter;
date: ICalendarDate; date: ICalendarDate;
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
groupedIssueIds: TGroupedIssues; groupedIssueIds: TGroupedIssues;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; quickActions: TRenderQuickActions;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean; disableIssueCreation?: boolean;
quickAddCallback?: ( quickAddCallback?: (

View File

@ -1,14 +1,14 @@
import React from "react"; import React from "react";
import { Placement } from "@popperjs/core";
// components // components
import { TIssue, TIssueMap } from "@plane/types"; import { TIssueMap } from "@plane/types";
import { CalendarIssueBlock } from "@/components/issues"; import { CalendarIssueBlock } from "@/components/issues";
import { TRenderQuickActions } from "../list/list-view-types";
// types // types
type Props = { type Props = {
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
issueId: string; issueId: string;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; quickActions: TRenderQuickActions;
isDragging?: boolean; isDragging?: boolean;
}; };

View File

@ -1,5 +1,4 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { MoreHorizontal } from "lucide-react"; import { MoreHorizontal } from "lucide-react";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
@ -12,25 +11,27 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// helpers // helpers
// types // types
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
import { TRenderQuickActions } from "../list/list-view-types";
type Props = { type Props = {
issue: TIssue; issue: TIssue;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; quickActions: TRenderQuickActions;
isDragging?: boolean; isDragging?: boolean;
}; };
export const CalendarIssueBlock: React.FC<Props> = observer((props) => { export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
const { issue, quickActions, isDragging = false } = props; const { issue, quickActions, isDragging = false } = props;
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// refs
const blockRef = useRef(null);
const menuActionRef = useRef<HTMLDivElement | null>(null);
// hooks // hooks
const { workspaceSlug, projectId } = useAppRouter(); const { workspaceSlug, projectId } = useAppRouter();
const { getProjectIdentifierById } = useProject(); const { getProjectIdentifierById } = useProject();
const { getProjectStates } = useProjectState(); const { getProjectStates } = useProjectState();
const { peekIssue, setPeekIssue } = useIssueDetail(); const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const menuActionRef = useRef<HTMLDivElement | null>(null);
const stateColor = getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || ""; const stateColor = getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || "";
@ -39,7 +40,7 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
issue && issue &&
issue.project_id && issue.project_id &&
issue.id && issue.id &&
peekIssue?.issueId !== issue.id && !getIsIssuePeeked(issue.id) &&
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
@ -76,14 +77,13 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
)} )}
<div <div
ref={blockRef}
className={cn( className={cn(
"group/calendar-block flex h-10 w-full items-center justify-between gap-1.5 rounded border-b border-custom-border-200 px-4 py-1.5 hover:border-custom-border-400 md:h-8 md:border-[0.5px] md:px-1 ", "group/calendar-block flex h-10 w-full items-center justify-between gap-1.5 rounded border-b border-custom-border-200 px-4 py-1.5 hover:border-custom-border-400 md:h-8 md:border-[0.5px] md:px-1 ",
{ {
"border-custom-primary-100 bg-custom-background-90 shadow-custom-shadow-rg": isDragging, "bg-custom-background-90 shadow-custom-shadow-rg border-custom-primary-100": isDragging,
}, "bg-custom-background-100 hover:bg-custom-background-90": !isDragging,
{ "bg-custom-background-100 hover:bg-custom-background-90": !isDragging }, "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
{
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issue.id,
} }
)} )}
> >
@ -110,7 +110,12 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
e.stopPropagation(); e.stopPropagation();
}} }}
> >
{quickActions(issue, customActionButton, placement)} {quickActions({
issue,
parentRef: blockRef,
customActionButton,
placement,
})}
</div> </div>
</div> </div>
</> </>

View File

@ -1,6 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Draggable } from "@hello-pangea/dnd"; import { Draggable } from "@hello-pangea/dnd";
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// types // types
import { TIssue, TIssueMap } from "@plane/types"; import { TIssue, TIssueMap } from "@plane/types";
@ -8,12 +7,14 @@ import { TIssue, TIssueMap } from "@plane/types";
import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues"; import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues";
// helpers // helpers
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { TRenderQuickActions } from "../list/list-view-types";
// types
type Props = { type Props = {
date: Date; date: Date;
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
issueIdList: string[] | null; issueIdList: string[] | null;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; quickActions: TRenderQuickActions;
isDragDisabled?: boolean; isDragDisabled?: boolean;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean; disableIssueCreation?: boolean;

View File

@ -1,4 +1,3 @@
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
// components // components
@ -10,6 +9,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle";
import { IModuleIssuesFilter } from "@/store/issue/module"; import { IModuleIssuesFilter } from "@/store/issue/module";
import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectIssuesFilter } from "@/store/issue/project";
import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
import { TRenderQuickActions } from "../list/list-view-types";
import { ICalendarDate, ICalendarWeek } from "./types"; import { ICalendarDate, ICalendarWeek } from "./types";
type Props = { type Props = {
@ -17,7 +17,7 @@ type Props = {
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
groupedIssueIds: TGroupedIssues; groupedIssueIds: TGroupedIssues;
week: ICalendarWeek | undefined; week: ICalendarWeek | undefined;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; quickActions: TRenderQuickActions;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean; disableIssueCreation?: boolean;
quickAddCallback?: ( quickAddCallback?: (

View File

@ -18,7 +18,7 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => {
const { getProjectStates } = useProjectState(); const { getProjectStates } = useProjectState();
const { const {
issue: { getIssueById }, issue: { getIssueById },
peekIssue, getIsIssuePeeked,
setPeekIssue, setPeekIssue,
} = useIssueDetail(); } = useIssueDetail();
// derived values // derived values
@ -30,7 +30,7 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => {
workspaceSlug && workspaceSlug &&
issueDetails && issueDetails &&
!issueDetails.tempId && !issueDetails.tempId &&
peekIssue?.issueId !== issueDetails.id && !getIsIssuePeeked(issueDetails.id) &&
setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id });
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();

View File

@ -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 { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { TIssue } from "@plane/types";
// hooks // hooks
import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; import { Spinner, TOAST_TYPE, setToast } from "@plane/ui";
import { DeleteIssueModal } from "@/components/issues"; import { DeleteIssueModal } from "@/components/issues";
@ -15,7 +14,7 @@ import { useEventTracker, useIssueDetail, useIssues, useKanbanView, useUser } fr
import { useIssuesActions } from "@/hooks/use-issues-actions"; import { useIssuesActions } from "@/hooks/use-issues-actions";
// ui // ui
// types // types
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types";
//components //components
import { KanBan } from "./default"; import { KanBan } from "./default";
import { KanBanSwimLanes } from "./swimlanes"; import { KanBanSwimLanes } from "./swimlanes";
@ -168,9 +167,10 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
}); });
}; };
const renderQuickActions = useCallback( const renderQuickActions: TRenderQuickActions = useCallback(
(issue: TIssue, customActionButton?: React.ReactElement) => ( ({ issue, parentRef, customActionButton }) => (
<QuickActions <QuickActions
parentRef={parentRef}
customActionButton={customActionButton} customActionButton={customActionButton}
issue={issue} issue={issue}
handleDelete={async () => removeIssue(issue.project_id, issue.id)} handleDelete={async () => removeIssue(issue.project_id, issue.id)}

View File

@ -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 { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@ -11,6 +11,7 @@ import { useAppRouter, useIssueDetail, useProject, useKanbanView } from "@/hooks
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// components // components
import { TRenderQuickActions } from "../list/list-view-types";
import { IssueProperties } from "../properties/all-properties"; import { IssueProperties } from "../properties/all-properties";
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
// ui // ui
@ -18,29 +19,29 @@ import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-
// helper // helper
interface IssueBlockProps { interface IssueBlockProps {
peekIssueId?: string;
issueId: string; issueId: string;
issuesMap: IIssueMap; issuesMap: IIssueMap;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
isDragDisabled: boolean; isDragDisabled: boolean;
draggableId: string; draggableId: string;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue) => React.ReactNode; quickActions: TRenderQuickActions;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>; scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization
} }
interface IssueDetailsBlockProps { interface IssueDetailsBlockProps {
cardRef: React.RefObject<HTMLElement>;
issue: TIssue; issue: TIssue;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue) => React.ReactNode; quickActions: TRenderQuickActions;
isReadOnly: boolean; isReadOnly: boolean;
} }
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props: IssueDetailsBlockProps) => { const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props) => {
const { issue, updateIssue, quickActions, isReadOnly, displayProperties } = props; const { cardRef, issue, updateIssue, quickActions, isReadOnly, displayProperties } = props;
// hooks // hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { getProjectIdentifierById } = useProject(); const { getProjectIdentifierById } = useProject();
@ -61,7 +62,10 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
className="absolute -top-1 right-0 hidden group-hover/kanban-block:block" className="absolute -top-1 right-0 hidden group-hover/kanban-block:block"
onClick={handleEventPropagation} onClick={handleEventPropagation}
> >
{quickActions(issue)} {quickActions({
issue,
parentRef: cardRef,
})}
</div> </div>
</div> </div>
</WithDisplayPropertiesHOC> </WithDisplayPropertiesHOC>
@ -90,9 +94,8 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
); );
}); });
export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => { export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
const { const {
peekIssueId,
issueId, issueId,
issuesMap, issuesMap,
displayProperties, displayProperties,
@ -104,17 +107,17 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
issueIds, issueIds,
} = props; } = props;
const cardRef = useRef<HTMLAnchorElement | null>(null);
// hooks
const { workspaceSlug } = useAppRouter(); const { workspaceSlug } = useAppRouter();
const { peekIssue, setPeekIssue } = useIssueDetail(); const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
const cardRef = useRef<HTMLDivElement | null>(null);
const handleIssuePeekOverview = (issue: TIssue) => const handleIssuePeekOverview = (issue: TIssue) =>
workspaceSlug && workspaceSlug &&
issue && issue &&
issue.project_id && issue.project_id &&
issue.id && issue.id &&
peekIssue?.issueId !== issue.id && !getIsIssuePeeked(issue.id) &&
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
const issue = issuesMap[issueId]; const issue = issuesMap[issueId];
@ -184,9 +187,11 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
ref={cardRef} ref={cardRef}
className={cn( 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", "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 }, "hover:cursor-pointer": isDragAllowed,
{ "bg-custom-background-80 z-[100]": isCurrentBlockDragging } "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
"bg-custom-background-80 z-[100]": isCurrentBlockDragging,
}
)} )}
target="_blank" target="_blank"
onClick={() => handleIssuePeekOverview(issue)} onClick={() => handleIssuePeekOverview(issue)}
@ -200,6 +205,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
changingReference={issueIds} changingReference={issueIds}
> >
<KanbanIssueDetailsBlock <KanbanIssueDetailsBlock
cardRef={cardRef}
issue={issue} issue={issue}
displayProperties={displayProperties} displayProperties={displayProperties}
updateIssue={updateIssue} updateIssue={updateIssue}

View File

@ -2,18 +2,18 @@ import { MutableRefObject, memo } from "react";
//types //types
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
import { KanbanIssueBlock } from "@/components/issues"; import { KanbanIssueBlock } from "@/components/issues";
import { TRenderQuickActions } from "../list/list-view-types";
// components // components
interface IssueBlocksListProps { interface IssueBlocksListProps {
sub_group_id: string; sub_group_id: string;
columnId: string; columnId: string;
issuesMap: IIssueMap; issuesMap: IIssueMap;
peekIssueId?: string;
issueIds: string[]; issueIds: string[];
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
isDragDisabled: boolean; isDragDisabled: boolean;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: TRenderQuickActions;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>; scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
} }
@ -23,7 +23,6 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
sub_group_id, sub_group_id,
columnId, columnId,
issuesMap, issuesMap,
peekIssueId,
issueIds, issueIds,
displayProperties, displayProperties,
isDragDisabled, isDragDisabled,
@ -47,7 +46,6 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
return ( return (
<KanbanIssueBlock <KanbanIssueBlock
key={draggableId} key={draggableId}
peekIssueId={peekIssueId}
issueId={issueId} issueId={issueId}
issuesMap={issuesMap} issuesMap={issuesMap}
displayProperties={displayProperties} displayProperties={displayProperties}

View File

@ -14,18 +14,10 @@ import {
} from "@plane/types"; } from "@plane/types";
// constants // constants
// hooks // hooks
import { import { useCycle, useKanbanView, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
useCycle,
useIssueDetail,
useKanbanView,
useLabel,
useMember,
useModule,
useProject,
useProjectState,
} from "@/hooks/store";
// types // types
// parent components // parent components
import { TRenderQuickActions } from "../list/list-view-types";
import { getGroupByColumns, isWorkspaceLevel } from "../utils"; import { getGroupByColumns, isWorkspaceLevel } from "../utils";
// components // components
import { KanbanStoreType } from "./base-kanban-root"; import { KanbanStoreType } from "./base-kanban-root";
@ -42,7 +34,7 @@ export interface IGroupByKanBan {
sub_group_id: string; sub_group_id: string;
isDragDisabled: boolean; isDragDisabled: boolean;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: TRenderQuickActions;
kanbanFilters: TIssueKanbanFilters; kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: any; handleKanbanFilters: any;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
@ -95,7 +87,6 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
const cycle = useCycle(); const cycle = useCycle();
const moduleInfo = useModule(); const moduleInfo = useModule();
const projectState = useProjectState(); const projectState = useProjectState();
const { peekIssue } = useIssueDetail();
const list = getGroupByColumns( const list = getGroupByColumns(
group_by as GroupByColumnTypes, group_by as GroupByColumnTypes,
@ -176,7 +167,6 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
groupId={subList.id} groupId={subList.id}
issuesMap={issuesMap} issuesMap={issuesMap}
issueIds={issueIds} issueIds={issueIds}
peekIssueId={peekIssue?.issueId ?? ""}
displayProperties={displayProperties} displayProperties={displayProperties}
sub_group_by={sub_group_by} sub_group_by={sub_group_by}
group_by={group_by} group_by={group_by}
@ -208,7 +198,7 @@ export interface IKanBan {
group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined;
sub_group_id?: string; sub_group_id?: string;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: TRenderQuickActions;
kanbanFilters: TIssueKanbanFilters; kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
showEmptyGroup: boolean; showEmptyGroup: boolean;

View File

@ -17,13 +17,13 @@ import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useProjectState } from "@/hooks/store"; import { useProjectState } from "@/hooks/store";
//components //components
import { TRenderQuickActions } from "../list/list-view-types";
import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils"; import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils";
import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from ".";
interface IKanbanGroup { interface IKanbanGroup {
groupId: string; groupId: string;
issuesMap: IIssueMap; issuesMap: IIssueMap;
peekIssueId?: string;
issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
sub_group_by: TIssueGroupByOptions | undefined; sub_group_by: TIssueGroupByOptions | undefined;
@ -31,7 +31,7 @@ interface IKanbanGroup {
sub_group_id: string; sub_group_id: string;
isDragDisabled: boolean; isDragDisabled: boolean;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: TRenderQuickActions;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
quickAddCallback?: ( quickAddCallback?: (
workspaceSlug: string, workspaceSlug: string,
@ -56,7 +56,6 @@ export const KanbanGroup = (props: IKanbanGroup) => {
issuesMap, issuesMap,
displayProperties, displayProperties,
issueIds, issueIds,
peekIssueId,
isDragDisabled, isDragDisabled,
updateIssue, updateIssue,
quickActions, quickActions,
@ -176,7 +175,6 @@ export const KanbanGroup = (props: IKanbanGroup) => {
sub_group_id={sub_group_id} sub_group_id={sub_group_id}
columnId={groupId} columnId={groupId}
issuesMap={issuesMap} issuesMap={issuesMap}
peekIssueId={peekIssueId}
issueIds={(issueIds as TGroupedIssues)?.[groupId] || []} issueIds={(issueIds as TGroupedIssues)?.[groupId] || []}
displayProperties={displayProperties} displayProperties={displayProperties}
isDragDisabled={isDragDisabled} isDragDisabled={isDragDisabled}

View File

@ -14,6 +14,7 @@ import {
} from "@plane/types"; } from "@plane/types";
// components // components
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
import { TRenderQuickActions } from "../list/list-view-types";
import { getGroupByColumns, isWorkspaceLevel } from "../utils"; import { getGroupByColumns, isWorkspaceLevel } from "../utils";
import { KanbanStoreType } from "./base-kanban-root"; import { KanbanStoreType } from "./base-kanban-root";
import { KanBan } from "./default"; import { KanBan } from "./default";
@ -106,7 +107,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
showEmptyGroup: boolean; showEmptyGroup: boolean;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: TRenderQuickActions;
kanbanFilters: TIssueKanbanFilters; kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>; handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>;
@ -235,7 +236,7 @@ export interface IKanBanSwimLanes {
sub_group_by: TIssueGroupByOptions | undefined; sub_group_by: TIssueGroupByOptions | undefined;
group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: TRenderQuickActions;
kanbanFilters: TIssueKanbanFilters; kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
showEmptyGroup: boolean; showEmptyGroup: boolean;

View File

@ -1,6 +1,5 @@
import { FC, useCallback } from "react"; import { FC, useCallback } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { TIssue } from "@plane/types";
// types // types
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
@ -9,7 +8,7 @@ import { useIssues, useUser } from "@/hooks/store";
import { useIssuesActions } from "@/hooks/use-issues-actions"; import { useIssuesActions } from "@/hooks/use-issues-actions";
// components // components
import { List } from "./default"; import { List } from "./default";
import { IQuickActionProps } from "./list-view-types"; import { IQuickActionProps, TRenderQuickActions } from "./list-view-types";
// constants // constants
// hooks // hooks
@ -69,9 +68,10 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
const group_by = displayFilters?.group_by || null; const group_by = displayFilters?.group_by || null;
const showEmptyGroup = displayFilters?.show_empty_groups ?? false; const showEmptyGroup = displayFilters?.show_empty_groups ?? false;
const renderQuickActions = useCallback( const renderQuickActions: TRenderQuickActions = useCallback(
(issue: TIssue) => ( ({ issue, parentRef }) => (
<QuickActions <QuickActions
parentRef={parentRef}
issue={issue} issue={issue}
handleDelete={async () => removeIssue(issue.project_id, issue.id)} handleDelete={async () => removeIssue(issue.project_id, issue.id)}
handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)} handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)}

View File

@ -1,38 +1,41 @@
import { useRef } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types";
// components
// hooks
// ui // ui
import { Spinner, Tooltip, ControlLink } from "@plane/ui"; import { Spinner, Tooltip, ControlLink } from "@plane/ui";
// helper // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks
import { useAppRouter, useIssueDetail, useProject } from "@/hooks/store"; import { useAppRouter, useIssueDetail, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// types // types
import { IssueProperties } from "../properties/all-properties"; import { IssueProperties } from "../properties/all-properties";
import { TRenderQuickActions } from "./list-view-types";
interface IssueBlockProps { interface IssueBlockProps {
issueId: string; issueId: string;
issuesMap: TIssueMap; issuesMap: TIssueMap;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue) => React.ReactNode; quickActions: TRenderQuickActions;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
} }
export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlockProps) => { export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlockProps) => {
const { issuesMap, issueId, updateIssue, quickActions, displayProperties, canEditProperties } = props; const { issuesMap, issueId, updateIssue, quickActions, displayProperties, canEditProperties } = props;
// refs
const parentRef = useRef(null);
// hooks // hooks
const { workspaceSlug } = useAppRouter(); const { workspaceSlug } = useAppRouter();
const { getProjectIdentifierById } = useProject(); const { getProjectIdentifierById } = useProject();
const { peekIssue, setPeekIssue } = useIssueDetail(); const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
const handleIssuePeekOverview = (issue: TIssue) => const handleIssuePeekOverview = (issue: TIssue) =>
workspaceSlug && workspaceSlug &&
issue && issue &&
issue.project_id && issue.project_id &&
issue.id && issue.id &&
peekIssue?.issueId !== issue.id && !getIsIssuePeeked(issue.id) &&
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
const issue = issuesMap[issueId]; const issue = issuesMap[issueId];
@ -44,11 +47,12 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
return ( return (
<div <div
ref={parentRef}
className={cn( className={cn(
"relative flex min-h-12 flex-col gap-3 bg-custom-background-100 p-3 text-sm md:flex-row md:items-center", "min-h-[52px] relative flex flex-col md:flex-row md:items-center gap-3 bg-custom-background-100 p-3 text-sm",
{ {
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue && peekIssue.issueId === issue.id, "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
"last:border-b-transparent": peekIssue?.issueId !== issue.id, "last:border-b-transparent": !getIsIssuePeeked(issue.id),
} }
)} )}
> >
@ -86,7 +90,12 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
)} )}
</div> </div>
{!issue?.tempId && ( {!issue?.tempId && (
<div className="block rounded border border-custom-border-300 md:hidden ">{quickActions(issue)}</div> <div className="block md:hidden border border-custom-border-300 rounded ">
{quickActions({
issue,
parentRef,
})}
</div>
)} )}
</div> </div>
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
@ -100,7 +109,12 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
displayProperties={displayProperties} displayProperties={displayProperties}
activeLayout="List" activeLayout="List"
/> />
<div className="hidden md:block">{quickActions(issue)}</div> <div className="hidden md:block">
{quickActions({
issue,
parentRef,
})}
</div>
</> </>
) : ( ) : (
<div className="h-4 w-4"> <div className="h-4 w-4">

View File

@ -3,6 +3,7 @@ import { FC, MutableRefObject } from "react";
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
import RenderIfVisible from "@/components/core/render-if-visible-HOC"; import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { IssueBlock } from "@/components/issues"; import { IssueBlock } from "@/components/issues";
import { TRenderQuickActions } from "./list-view-types";
// types // types
interface Props { interface Props {
@ -10,7 +11,7 @@ interface Props {
issuesMap: TIssueMap; issuesMap: TIssueMap;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue) => React.ReactNode; quickActions: TRenderQuickActions;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
containerRef: MutableRefObject<HTMLDivElement | null>; containerRef: MutableRefObject<HTMLDivElement | null>;
} }

View File

@ -16,13 +16,14 @@ import { useCycle, useLabel, useMember, useModule, useProject, useProjectState }
// utils // utils
import { getGroupByColumns, isWorkspaceLevel } from "../utils"; import { getGroupByColumns, isWorkspaceLevel } from "../utils";
import { HeaderGroupByCard } from "./headers/group-by-card"; import { HeaderGroupByCard } from "./headers/group-by-card";
import { TRenderQuickActions } from "./list-view-types";
export interface IGroupByList { export interface IGroupByList {
issueIds: TGroupedIssues | TUnGroupedIssues | any; issueIds: TGroupedIssues | TUnGroupedIssues | any;
issuesMap: TIssueMap; issuesMap: TIssueMap;
group_by: string | null; group_by: string | null;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue) => React.ReactNode; quickActions: TRenderQuickActions;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
enableIssueQuickAdd: boolean; enableIssueQuickAdd: boolean;
showEmptyGroup?: boolean; showEmptyGroup?: boolean;
@ -177,7 +178,7 @@ export interface IList {
issuesMap: TIssueMap; issuesMap: TIssueMap;
group_by: string | null; group_by: string | null;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: (issue: TIssue) => React.ReactNode; quickActions: TRenderQuickActions;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
showEmptyGroup: boolean; showEmptyGroup: boolean;
enableIssueQuickAdd: boolean; enableIssueQuickAdd: boolean;

View File

@ -2,6 +2,7 @@ import { Placement } from "@popperjs/core";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
export interface IQuickActionProps { export interface IQuickActionProps {
parentRef: React.RefObject<HTMLElement>;
issue: TIssue; issue: TIssue;
handleDelete: () => Promise<void>; handleDelete: () => Promise<void>;
handleUpdate?: (data: TIssue) => Promise<void>; handleUpdate?: (data: TIssue) => Promise<void>;
@ -13,3 +14,17 @@ export interface IQuickActionProps {
readOnly?: boolean; readOnly?: boolean;
placements?: Placement; placements?: Placement;
} }
export type TRenderQuickActions = ({
issue,
parentRef,
customActionButton,
placement,
portalElement,
}: {
issue: TIssue;
parentRef: React.RefObject<HTMLElement>;
customActionButton?: React.ReactElement;
placement?: Placement;
portalElement?: HTMLDivElement | null;
}) => React.ReactNode;

View File

@ -3,21 +3,22 @@ import omit from "lodash/omit";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
// types
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// hooks
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// ui // ui
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
import { STATE_GROUPS } from "@/constants/state"; import { STATE_GROUPS } from "@/constants/state";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { useEventTracker, useProjectState } from "@/hooks/store";
// components
// helpers // helpers
import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useProjectState } from "@/hooks/store";
// types // types
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps } from "../list/list-view-types";
// constants
export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => { export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { const {
@ -28,6 +29,8 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
customActionButton, customActionButton,
portalElement, portalElement,
readOnly = false, readOnly = false,
placements = "bottom-start",
parentRef,
} = props; } = props;
// states // states
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
@ -68,6 +71,63 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
["id"] ["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 ( return (
<> <>
<ArchiveIssueModal <ArchiveIssueModal
@ -94,89 +154,49 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
}} }}
storeType={EIssuesStoreType.PROJECT} storeType={EIssuesStoreType.PROJECT}
/> />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu <CustomMenu
menuItemsClassName="z-[14]" ellipsis
placement="bottom-start"
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg" placement={placements}
menuItemsClassName="z-[14]"
closeOnSelect closeOnSelect
ellipsis
> >
{isEditingAllowed && ( {MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { key={item.key}
setTrackElement("Global issues"); onClick={(e) => {
setIssueToEdit(issue); e.preventDefault();
setCreateUpdateIssueModal(true); e.stopPropagation();
item.action();
}} }}
> className={cn(
<div className="flex items-center gap-2"> "flex items-center gap-2",
<Pencil className="h-3 w-3" /> {
Edit "text-custom-text-400": item.disabled,
</div> },
</CustomMenu.MenuItem> item.className
)} )}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<Link className="h-3 w-3" />
Copy link
</div>
</CustomMenu.MenuItem>
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Global issues");
setCreateUpdateIssueModal(true);
}}
> >
<div className="flex items-center gap-2"> {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<Copy className="h-3 w-3" /> <div>
Make a copy <h5>{item.title}</h5>
</div> {item.description && (
</CustomMenu.MenuItem> <p
)} className={cn("text-custom-text-300 whitespace-pre-line", {
{isArchivingAllowed && ( "text-custom-text-400": item.disabled,
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> })}
{isInArchivableGroup ? ( >
<div className="flex items-center gap-2"> {item.description}
<ArchiveIcon className="h-3 w-3" />
Archive
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive</p>
<p className="text-xs text-custom-text-400">
Only completed or canceled
<br />
issues can be archived
</p> </p>
</div>
</div>
)} )}
</CustomMenu.MenuItem>
)}
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Global issues");
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Trash2 className="h-3 w-3" />
Delete
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} );
})}
</CustomMenu> </CustomMenu>
</> </>
); );

View File

@ -4,13 +4,14 @@ import { useRouter } from "next/router";
// icons // icons
import { ArchiveRestoreIcon, ExternalLink, Link, Trash2 } from "lucide-react"; import { ArchiveRestoreIcon, ExternalLink, Link, Trash2 } from "lucide-react";
// ui // ui
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { DeleteIssueModal } from "@/components/issues"; import { DeleteIssueModal } from "@/components/issues";
// constants // constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers // helpers
import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks // hooks
import { useEventTracker, useIssues, useUser } from "@/hooks/store"; 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"; import { IQuickActionProps } from "../list/list-view-types";
export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => { export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = 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 // states
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// router // router
@ -66,6 +76,38 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = 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 ( return (
<> <>
<DeleteIssueModal <DeleteIssueModal
@ -74,48 +116,49 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
handleClose={() => setDeleteIssueModal(false)} handleClose={() => setDeleteIssueModal(false)}
onSubmit={handleDelete} onSubmit={handleDelete}
/> />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu <CustomMenu
menuItemsClassName="z-[14]" ellipsis
placement="bottom-start"
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg" placement={placements}
menuItemsClassName="z-[14]"
closeOnSelect closeOnSelect
ellipsis
> >
{isRestoringAllowed && ( {MENU_ITEMS.map((item) => {
<CustomMenu.MenuItem onClick={handleIssueRestore}> if (item.shouldRender === false) return null;
<div className="flex items-center gap-2"> return (
<ArchiveRestoreIcon className="h-3 w-3" />
Restore
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<Link className="h-3 w-3" />
Copy link
</div>
</CustomMenu.MenuItem>
{isEditingAllowed && (
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { key={item.key}
setTrackElement(activeLayout); onClick={(e) => {
setDeleteIssueModal(true); e.preventDefault();
e.stopPropagation();
item.action();
}} }}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
> >
<div className="flex items-center gap-2"> {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<Trash2 className="h-3 w-3" /> <div>
Delete issue <h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} );
})}
</CustomMenu> </CustomMenu>
</> </>
); );

View File

@ -2,24 +2,24 @@ import { useState } from "react";
import omit from "lodash/omit"; import omit from "lodash/omit";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks
// ui
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react"; import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
// types
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; // ui
// icons import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
import { STATE_GROUPS } from "@/constants/state"; import { STATE_GROUPS } from "@/constants/state";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
// components
// helpers // helpers
import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
// types // types
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps } from "../list/list-view-types";
// constants
export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => { export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { const {
@ -32,6 +32,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
portalElement, portalElement,
readOnly = false, readOnly = false,
placements = "bottom-start", placements = "bottom-start",
parentRef,
} = props; } = props;
// states // states
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
@ -80,6 +81,73 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
["id"] ["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 ( return (
<> <>
<ArchiveIssueModal <ArchiveIssueModal
@ -106,104 +174,49 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
}} }}
storeType={EIssuesStoreType.CYCLE} storeType={EIssuesStoreType.CYCLE}
/> />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu <CustomMenu
menuItemsClassName="z-[14]" ellipsis
placement={placements} placement={placements}
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg" menuItemsClassName="z-[14]"
closeOnSelect closeOnSelect
ellipsis
> >
{isEditingAllowed && ( {MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { key={item.key}
setIssueToEdit({ onClick={(e) => {
...issue, e.preventDefault();
cycle_id: cycleId?.toString() ?? null, e.stopPropagation();
}); item.action();
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
}} }}
> className={cn(
<div className="flex items-center gap-2"> "flex items-center gap-2",
<Pencil className="h-3 w-3" /> {
Edit "text-custom-text-400": item.disabled,
</div> },
</CustomMenu.MenuItem> item.className
)} )}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<Link className="h-3 w-3" />
Copy link
</div>
</CustomMenu.MenuItem>
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
}}
> >
<div className="flex items-center gap-2"> {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<Copy className="h-3 w-3" /> <div>
Make a copy <h5>{item.title}</h5>
</div> {item.description && (
</CustomMenu.MenuItem> <p
)} className={cn("text-custom-text-300 whitespace-pre-line", {
{isEditingAllowed && ( "text-custom-text-400": item.disabled,
<CustomMenu.MenuItem })}
onClick={() => {
handleRemoveFromView && handleRemoveFromView();
}}
> >
<div className="flex items-center gap-2"> {item.description}
<XCircle className="h-3 w-3" />
Remove from cycle
</div>
</CustomMenu.MenuItem>
)}
{isArchivingAllowed && (
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive</p>
<p className="text-xs text-custom-text-400">
Only completed or canceled
<br />
issues can be archived
</p> </p>
</div>
</div>
)} )}
</CustomMenu.MenuItem>
)}
{isDeletingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Trash2 className="h-3 w-3" />
Delete
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} );
})}
</CustomMenu> </CustomMenu>
</> </>
); );

View File

@ -6,19 +6,30 @@ import { Pencil, Trash2 } from "lucide-react";
// types // types
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// ui // ui
import { CustomMenu } from "@plane/ui"; import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui";
// components // components
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// constant // constant
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useEventTracker, useIssues, useUser } from "@/hooks/store"; import { useEventTracker, useIssues, useUser } from "@/hooks/store";
// types // types
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps } from "../list/list-view-types";
export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => { export const DraftIssueQuickActions: React.FC<IQuickActionProps> = 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 // states
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined); const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
@ -44,6 +55,30 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
["id"] ["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 ( return (
<> <>
<DeleteIssueModal <DeleteIssueModal
@ -52,7 +87,6 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
handleClose={() => setDeleteIssueModal(false)} handleClose={() => setDeleteIssueModal(false)}
onSubmit={handleDelete} onSubmit={handleDelete}
/> />
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={createUpdateIssueModal} isOpen={createUpdateIssueModal}
onClose={() => { onClose={() => {
@ -66,43 +100,49 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
storeType={EIssuesStoreType.PROJECT} storeType={EIssuesStoreType.PROJECT}
isDraft isDraft
/> />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu <CustomMenu
menuItemsClassName="z-[14]" ellipsis
placement="bottom-start"
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg" placement={placements}
menuItemsClassName="z-[14]"
closeOnSelect closeOnSelect
ellipsis
> >
{isEditingAllowed && ( {MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { key={item.key}
setTrackElement(activeLayout); onClick={(e) => {
setIssueToEdit(issue); e.preventDefault();
setCreateUpdateIssueModal(true); e.stopPropagation();
item.action();
}} }}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
> >
<div className="flex items-center gap-2"> {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<Pencil className="h-3 w-3" /> <div>
Edit <h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} );
{isDeletingAllowed && ( })}
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Trash2 className="h-3 w-3" />
Delete
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu> </CustomMenu>
</> </>
); );

View File

@ -2,23 +2,24 @@ import { useState } from "react";
import omit from "lodash/omit"; import omit from "lodash/omit";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks
// ui
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react"; import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
// types
import { TIssue } from "@plane/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 // components
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
import { STATE_GROUPS } from "@/constants/state"; import { STATE_GROUPS } from "@/constants/state";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { useIssues, useEventTracker, useUser, useProjectState } from "@/hooks/store";
// components
// helpers // helpers
import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useIssues, useEventTracker, useUser, useProjectState } from "@/hooks/store";
// types // types
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps } from "../list/list-view-types";
// constants
export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => { export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { const {
@ -31,6 +32,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
portalElement, portalElement,
readOnly = false, readOnly = false,
placements = "bottom-start", placements = "bottom-start",
parentRef,
} = props; } = props;
// states // states
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
@ -79,6 +81,70 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
["id"] ["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 ( return (
<> <>
<ArchiveIssueModal <ArchiveIssueModal
@ -105,103 +171,49 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
}} }}
storeType={EIssuesStoreType.MODULE} storeType={EIssuesStoreType.MODULE}
/> />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu <CustomMenu
menuItemsClassName="z-[14]" ellipsis
placement={placements} placement={placements}
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg" menuItemsClassName="z-[14]"
closeOnSelect closeOnSelect
ellipsis
> >
{isEditingAllowed && ( {MENU_ITEMS.map((item) => {
<CustomMenu.MenuItem if (item.shouldRender === false) return null;
onClick={() => { return (
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
Edit
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<Link className="h-3 w-3" />
Copy link
</div>
</CustomMenu.MenuItem>
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Copy className="h-3 w-3" />
Make a copy
</div>
</CustomMenu.MenuItem>
)}
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
handleRemoveFromView && handleRemoveFromView();
}}
>
<div className="flex items-center gap-2">
<XCircle className="h-3 w-3" />
Remove from module
</div>
</CustomMenu.MenuItem>
)}
{isArchivingAllowed && (
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive</p>
<p className="text-xs text-custom-text-400">
Only completed or canceled
<br />
issues can be archived
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isDeletingAllowed && (
<CustomMenu.MenuItem <CustomMenu.MenuItem
key={item.key}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setTrackElement(activeLayout); item.action();
setDeleteIssueModal(true);
}} }}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
> >
<div className="flex items-center gap-2"> {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<Trash2 className="h-3 w-3" /> <div>
Delete <h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} );
})}
</CustomMenu> </CustomMenu>
</> </>
); );

View File

@ -2,22 +2,24 @@ import { useState } from "react";
import omit from "lodash/omit"; import omit from "lodash/omit";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
// types
import { TIssue } from "@plane/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"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
import { STATE_GROUPS } from "@/constants/state"; import { STATE_GROUPS } from "@/constants/state";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
// ui
// components
// helpers // helpers
import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
// types // types
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps } from "../list/list-view-types";
// constant
export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => { export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { const {
@ -28,7 +30,8 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
customActionButton, customActionButton,
portalElement, portalElement,
readOnly = false, readOnly = false,
placements = "bottom-start", placements = "bottom-end",
parentRef,
} = props; } = props;
// router // router
const router = useRouter(); const router = useRouter();
@ -56,9 +59,6 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
const isDeletingAllowed = isEditingAllowed; const isDeletingAllowed = isEditingAllowed;
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`; const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () => const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() => copyUrlToClipboard(issueLink).then(() =>
setToast({ setToast({
@ -67,6 +67,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const isDraftIssue = router?.asPath?.includes("draft-issues") || false; const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
@ -79,6 +80,63 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
["id"] ["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 ( return (
<> <>
<ArchiveIssueModal <ArchiveIssueModal
@ -106,89 +164,49 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
storeType={EIssuesStoreType.PROJECT} storeType={EIssuesStoreType.PROJECT}
isDraft={isDraftIssue} isDraft={isDraftIssue}
/> />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu <CustomMenu
menuItemsClassName="z-[14]" ellipsis
placement={placements} placement={placements}
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg" menuItemsClassName="z-[14]"
closeOnSelect closeOnSelect
ellipsis
> >
{isEditingAllowed && ( {MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { key={item.key}
setTrackElement(activeLayout); onClick={(e) => {
setIssueToEdit(issue); e.preventDefault();
setCreateUpdateIssueModal(true); e.stopPropagation();
item.action();
}} }}
> className={cn(
<div className="flex items-center gap-2"> "flex items-center gap-2",
<Pencil className="h-3 w-3" /> {
Edit "text-custom-text-400": item.disabled,
</div> },
</CustomMenu.MenuItem> item.className
)} )}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<Link className="h-3 w-3" />
Copy link
</div>
</CustomMenu.MenuItem>
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
}}
> >
<div className="flex items-center gap-2"> {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<Copy className="h-3 w-3" /> <div>
Make a copy <h5>{item.title}</h5>
</div> {item.description && (
</CustomMenu.MenuItem> <p
)} className={cn("text-custom-text-300 whitespace-pre-line", {
{isArchivingAllowed && ( "text-custom-text-400": item.disabled,
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> })}
{isInArchivableGroup ? ( >
<div className="flex items-center gap-2"> {item.description}
<ArchiveIcon className="h-3 w-3" />
Archive
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive</p>
<p className="text-xs text-custom-text-400">
Only completed or canceled
<br />
issues can be archived
</p> </p>
</div>
</div>
)} )}
</CustomMenu.MenuItem>
)}
{isDeletingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Trash2 className="h-3 w-3" />
Delete
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} );
})}
</CustomMenu> </CustomMenu>
</> </>
); );

View File

@ -3,7 +3,7 @@ import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
import { TIssue, IIssueDisplayFilterOptions } from "@plane/types"; import { IIssueDisplayFilterOptions } from "@plane/types";
// hooks // hooks
// components // components
import { EmptyState } from "@/components/empty-state"; 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 { useCommandPalette, useEventTracker, useGlobalView, useIssues, useProject, useUser } from "@/hooks/store";
import { useIssuesActions } from "@/hooks/use-issues-actions"; import { useIssuesActions } from "@/hooks/use-issues-actions";
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties"; import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
import { TRenderQuickActions } from "../list/list-view-types";
export const AllIssueLayoutRoot: React.FC = observer(() => { export const AllIssueLayoutRoot: React.FC = observer(() => {
// router // router
@ -127,9 +128,10 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
[updateFilters, workspaceSlug, globalViewId] [updateFilters, workspaceSlug, globalViewId]
); );
const renderQuickActions = useCallback( const renderQuickActions: TRenderQuickActions = useCallback(
(issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => ( ({ issue, parentRef, customActionButton, placement, portalElement }) => (
<AllIssueQuickActions <AllIssueQuickActions
parentRef={parentRef}
customActionButton={customActionButton} customActionButton={customActionButton}
issue={issue} issue={issue}
handleDelete={async () => removeIssue(issue.project_id, issue.id)} handleDelete={async () => 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)} handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
portalElement={portalElement} portalElement={portalElement}
readOnly={!canEditProperties(issue.project_id)} readOnly={!canEditProperties(issue.project_id)}
placements={placement}
/> />
), ),
[canEditProperties, removeIssue, updateIssue, archiveIssue] [canEditProperties, removeIssue, updateIssue, archiveIssue]

View File

@ -1,7 +1,7 @@
import { FC, useCallback } from "react"; import { FC, useCallback } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { TIssue, IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types"; import { IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types";
// hooks // hooks
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
@ -10,7 +10,7 @@ import { useIssuesActions } from "@/hooks/use-issues-actions";
// views // views
// types // types
// constants // constants
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types";
import { SpreadsheetView } from "./spreadsheet-view"; import { SpreadsheetView } from "./spreadsheet-view";
export type SpreadsheetStoreType = export type SpreadsheetStoreType =
@ -66,9 +66,10 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => {
[projectId, updateFilters] [projectId, updateFilters]
); );
const renderQuickActions = useCallback( const renderQuickActions: TRenderQuickActions = useCallback(
(issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => ( ({ issue, parentRef, customActionButton, placement, portalElement }) => (
<QuickActions <QuickActions
parentRef={parentRef}
customActionButton={customActionButton} customActionButton={customActionButton}
issue={issue} issue={issue}
handleDelete={async () => removeIssue(issue.project_id, issue.id)} handleDelete={async () => 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)} handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
portalElement={portalElement} portalElement={portalElement}
readOnly={!isEditingAllowed || isCompletedCycle} readOnly={!isEditingAllowed || isCompletedCycle}
placements={placement}
/> />
), ),
[isEditingAllowed, isCompletedCycle, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue] [isEditingAllowed, isCompletedCycle, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue]

View File

@ -16,17 +16,14 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// types // types
// local components // local components
import { TRenderQuickActions } from "../list/list-view-types";
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { IssueColumn } from "./issue-column"; import { IssueColumn } from "./issue-column";
interface Props { interface Props {
displayProperties: IIssueDisplayProperties; displayProperties: IIssueDisplayProperties;
isEstimateEnabled: boolean; isEstimateEnabled: boolean;
quickActions: ( quickActions: TRenderQuickActions;
issue: TIssue,
customActionButton?: React.ReactElement,
portalElement?: HTMLDivElement | null
) => React.ReactNode;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
portalElement: React.MutableRefObject<HTMLDivElement | null>; portalElement: React.MutableRefObject<HTMLDivElement | null>;
@ -112,11 +109,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
interface IssueRowDetailsProps { interface IssueRowDetailsProps {
displayProperties: IIssueDisplayProperties; displayProperties: IIssueDisplayProperties;
isEstimateEnabled: boolean; isEstimateEnabled: boolean;
quickActions: ( quickActions: TRenderQuickActions;
issue: TIssue,
customActionButton?: React.ReactElement,
portalElement?: HTMLDivElement | null
) => React.ReactNode;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
portalElement: React.MutableRefObject<HTMLDivElement | null>; portalElement: React.MutableRefObject<HTMLDivElement | null>;
@ -143,23 +136,25 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
setExpanded, setExpanded,
spreadsheetColumnsList, spreadsheetColumnsList,
} = props; } = props;
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// refs
const cellRef = useRef(null);
const menuActionRef = useRef<HTMLDivElement | null>(null);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
//hooks // hooks
const { getProjectIdentifierById } = useProject(); const { getProjectIdentifierById } = useProject();
const { peekIssue, setPeekIssue } = useIssueDetail(); const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const menuActionRef = useRef<HTMLDivElement | null>(null);
const handleIssuePeekOverview = (issue: TIssue) => const handleIssuePeekOverview = (issue: TIssue) =>
workspaceSlug && workspaceSlug &&
issue && issue &&
issue.project_id && issue.project_id &&
issue.id && issue.id &&
peekIssue?.issueId !== issue.id && !getIsIssuePeeked(issue.id) &&
setPeekIssue({ workspaceSlug: workspaceSlug.toString(), projectId: issue.project_id, issueId: issue.id }); setPeekIssue({ workspaceSlug: workspaceSlug.toString(), projectId: issue.project_id, issueId: issue.id });
const { subIssues: subIssuesStore, issue } = useIssueDetail(); const { subIssues: subIssuesStore, issue } = useIssueDetail();
@ -196,16 +191,13 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
return ( return (
<> <>
<td <td
ref={cellRef}
id={`issue-${issueDetail.id}`} id={`issue-${issueDetail.id}`}
className={cn( className={cn(
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200", "group sticky left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200",
{
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
},
{
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issueDetail.id,
},
{ {
"border-b-[0.5px]": !getIsIssuePeeked(issueDetail.id),
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issueDetail.id),
"shadow-[8px_22px_22px_10px_rgba(0,0,0,0.05)]": isScrolled.current, "shadow-[8px_22px_22px_10px_rgba(0,0,0,0.05)]": isScrolled.current,
} }
)} )}
@ -226,7 +218,12 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
</span> </span>
<div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}> <div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}>
{quickActions(issueDetail, customActionButton, portalElement.current)} {quickActions({
issue: issueDetail,
parentRef: cellRef,
customActionButton,
portalElement: portalElement.current,
})}
</div> </div>
</div> </div>

View File

@ -4,6 +4,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@pl
//types //types
import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigation"; import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigation";
//components //components
import { TRenderQuickActions } from "../list/list-view-types";
import { SpreadsheetIssueRow } from "./issue-row"; import { SpreadsheetIssueRow } from "./issue-row";
import { SpreadsheetHeader } from "./spreadsheet-header"; import { SpreadsheetHeader } from "./spreadsheet-header";
@ -13,11 +14,7 @@ type Props = {
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void; handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
issueIds: string[]; issueIds: string[];
isEstimateEnabled: boolean; isEstimateEnabled: boolean;
quickActions: ( quickActions: TRenderQuickActions;
issue: TIssue,
customActionButton?: React.ReactElement,
portalElement?: HTMLDivElement | null
) => React.ReactNode;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
portalElement: React.MutableRefObject<HTMLDivElement | null>; portalElement: React.MutableRefObject<HTMLDivElement | null>;

View File

@ -6,6 +6,7 @@ import { Spinner } from "@plane/ui";
import { SpreadsheetQuickAddIssueForm } from "@/components/issues"; import { SpreadsheetQuickAddIssueForm } from "@/components/issues";
import { SPREADSHEET_PROPERTY_LIST } from "@/constants/spreadsheet"; import { SPREADSHEET_PROPERTY_LIST } from "@/constants/spreadsheet";
import { useProject } from "@/hooks/store"; import { useProject } from "@/hooks/store";
import { TRenderQuickActions } from "../list/list-view-types";
import { SpreadsheetTable } from "./spreadsheet-table"; import { SpreadsheetTable } from "./spreadsheet-table";
// types // types
//hooks //hooks
@ -15,11 +16,7 @@ type Props = {
displayFilters: IIssueDisplayFilterOptions; displayFilters: IIssueDisplayFilterOptions;
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void; handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
issueIds: string[] | undefined; issueIds: string[] | undefined;
quickActions: ( quickActions: TRenderQuickActions;
issue: TIssue,
customActionButton?: React.ReactElement,
portalElement?: HTMLDivElement | null
) => React.ReactNode;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
quickAddCallback?: ( quickAddCallback?: (

View File

@ -25,7 +25,7 @@ import { IssueLabelSelect } from "@/components/issues/select";
import { CreateLabelModal } from "@/components/labels"; import { CreateLabelModal } from "@/components/labels";
// helpers // helpers
import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper"; 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"; import { shouldRenderProject } from "@/helpers/project.helper";
// hooks // hooks
import { useAppRouter, useEstimate, useInstance, useIssueDetail, useProject, useWorkspace } from "@/hooks/store"; import { useAppRouter, useEstimate, useInstance, useIssueDetail, useProject, useWorkspace } from "@/hooks/store";
@ -468,17 +468,13 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
workspaceSlug={workspaceSlug?.toString() as string} workspaceSlug={workspaceSlug?.toString() as string}
workspaceId={workspaceId} workspaceId={workspaceId}
projectId={projectId} projectId={projectId}
// dragDropEnabled={false}
onChange={(_description: object, description_html: string) => { onChange={(_description: object, description_html: string) => {
onChange(description_html); onChange(description_html);
handleFormChange(); handleFormChange();
}} }}
ref={editorRef} ref={editorRef}
tabIndex={getTabIndex("description_html")} tabIndex={getTabIndex("description_html")}
placeholder={(isFocused) => { placeholder={getDescriptionPlaceholder}
if (isFocused) return "Press '/' for commands...";
else return "Click to add description";
}}
/> />
)} )}
/> />

View File

@ -109,7 +109,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
// clearing up the description state when we leave the component // clearing up the description state when we leave the component
return () => setDescription(undefined); return () => setDescription(undefined);
}, [data, projectId, workspaceProjectIds, isOpen, activeProjectId]); }, [data, projectId, isOpen, activeProjectId]);
const addIssueToCycle = async (issue: TIssue, cycleId: string) => { const addIssueToCycle = async (issue: TIssue, cycleId: string) => {
if (!workspaceSlug || !activeProjectId) return; if (!workspaceSlug || !activeProjectId) return;

View File

@ -42,7 +42,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
} = props; } = props;
const { const {
peekIssue, getIsIssuePeeked,
setPeekIssue, setPeekIssue,
issue: { getIssueById }, issue: { getIssueById },
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers }, subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
@ -65,7 +65,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
issue && issue &&
issue.project_id && issue.project_id &&
issue.id && issue.id &&
peekIssue?.issueId !== issue.id && !getIsIssuePeeked(issue.id) &&
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
if (!issue) return <></>; if (!issue) return <></>;

View File

@ -52,7 +52,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
: "opacity-0 group-hover:pointer-events-auto group-hover:opacity-100" : "opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
} ${isLabelGroup && "-top-0.5"}`} } ${isLabelGroup && "-top-0.5"}`}
> >
<CustomMenu ellipsis buttonClassName="h-4 w-4 leading-4 text-custom-sidebar-text-400"> <CustomMenu ellipsis>
{customMenuItems.map( {customMenuItems.map(
({ isVisible, onClick, CustomIcon, text, key }) => ({ isVisible, onClick, CustomIcon, text, key }) =>
isVisible && ( isVisible && (

View File

@ -50,7 +50,7 @@ export const ArchivedModulesView: FC<IArchivedModulesView> = observer((props) =>
<div className="flex h-full w-full justify-between"> <div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg"> <div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{filteredArchivedModuleIds.map((moduleId) => ( {filteredArchivedModuleIds.map((moduleId) => (
<ModuleListItem key={moduleId} moduleId={moduleId} isArchived /> <ModuleListItem key={moduleId} moduleId={moduleId} />
))} ))}
</div> </div>
<ModulePeekOverview <ModulePeekOverview

View File

@ -12,6 +12,8 @@ export * from "./module-card-item";
export * from "./module-list-item"; export * from "./module-list-item";
export * from "./module-peek-overview"; export * from "./module-peek-overview";
export * from "./quick-actions"; export * from "./quick-actions";
export * from "./module-list-item-action";
export * from "./module-view-header";
// archived modules // archived modules
export * from "./archived-modules"; export * from "./archived-modules";

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useRef } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -24,6 +24,8 @@ type Props = {
export const ModuleCardItem: React.FC<Props> = observer((props) => { export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { moduleId } = props; const { moduleId } = props;
// refs
const parentRef = useRef(null);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -145,7 +147,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
return ( return (
<div className="relative"> <div className="relative">
<Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}> <Link ref={parentRef} href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md"> <div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
<div> <div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@ -239,6 +241,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
)} )}
{workspaceSlug && projectId && ( {workspaceSlug && projectId && (
<ModuleQuickActions <ModuleQuickActions
parentRef={parentRef}
moduleId={moduleId} moduleId={moduleId}
projectId={projectId.toString()} projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}

View File

@ -0,0 +1,168 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// icons
import { User2 } from "lucide-react";
// types
import { IModule } from "@plane/types";
// ui
import { Avatar, AvatarGroup, Tooltip, setPromiseToast } 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";
// hooks
import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
moduleId: string;
moduleDetails: IModule;
parentRef: React.RefObject<HTMLDivElement>;
};
export const ModuleListItemAction: FC<Props> = 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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 && (
<span
className="flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: moduleStatus.color,
backgroundColor: `${moduleStatus.color}20`,
}}
>
{moduleStatus.label}
</span>
)}
{renderDate && (
<span className=" text-xs text-custom-text-300">
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
</span>
)}
<Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center gap-1">
{moduleDetails.member_ids.length > 0 ? (
<AvatarGroup showTooltip={false}>
{moduleDetails.member_ids.map((member_id) => {
const member = getUserDetails(member_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{isEditingAllowed && !moduleDetails.archived_at && (
<FavoriteStar
onClick={(e) => {
if (moduleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
selected={moduleDetails.is_favorite}
/>
)}
{workspaceSlug && projectId && (
<ModuleQuickActions
parentRef={parentRef}
moduleId={moduleId}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
</>
);
});

View File

@ -1,102 +1,45 @@
import React from "react"; import React, { useRef } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Check, Info, User2 } from "lucide-react"; // icons
import { Check, Info } from "lucide-react";
// ui // ui
import { Avatar, AvatarGroup, CircularProgressIndicator, Tooltip, setPromiseToast } from "@plane/ui"; import { CircularProgressIndicator } from "@plane/ui";
// components // components
import { FavoriteStar } from "@/components/core"; import { ListItem } from "@/components/core/list";
import { ModuleQuickActions } from "@/components/modules"; import { ModuleListItemAction } 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";
// hooks // hooks
import { useModule, useUser, useEventTracker, useMember } from "@/hooks/store"; import { useModule } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = { type Props = {
moduleId: string; moduleId: string;
isArchived?: boolean;
}; };
export const ModuleListItem: React.FC<Props> = observer((props) => { export const ModuleListItem: React.FC<Props> = observer((props) => {
const { moduleId, isArchived = false } = props; const { moduleId } = props;
// refs
const parentRef = useRef(null);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug } = router.query;
// store hooks // store hooks
const { const { getModuleById } = useModule();
membership: { currentProjectRole }, const { isMobile } = usePlatformOS();
} = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { captureEvent } = useEventTracker();
// derived values // derived values
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const { isMobile } = usePlatformOS();
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).then( if (!moduleDetails) return null;
() => {
captureEvent(MODULE_FAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
});
}
);
setPromiseToast(addToFavoritePromise, { const completionPercentage =
loading: "Adding module to favorites...", ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
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<HTMLButtonElement>) => { const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const removeFromFavoritePromise = removeModuleFromFavorites( const completedModuleCheck = moduleDetails.status === "completed";
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.",
},
});
};
// handlers
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => { const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -116,40 +59,15 @@ export const ModuleListItem: React.FC<Props> = 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 ( return (
<div className="relative"> <ListItem
<Link title={moduleDetails?.name ?? ""}
href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`} itemLink={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}
onClick={(e) => { onItemClick={(e) => {
if (isArchived) { if (moduleDetails.archived_at) openModuleOverview(e);
openModuleOverview(e);
}
}} }}
> prependTitleElement={
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 sm:flex-row"> <CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
<div className="flex items-center gap-4 truncate">
<span className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}>
{completedModuleCheck ? ( {completedModuleCheck ? (
progress === 100 ? ( progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" /> <Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
@ -162,80 +80,20 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
<span className="text-xs text-custom-text-300">{`${progress}%`}</span> <span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)} )}
</CircularProgressIndicator> </CircularProgressIndicator>
</span> }
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}> appendTitleElement={
<span className="truncate text-base font-medium">{moduleDetails.name}</span> <button
</Tooltip> onClick={openModuleOverview}
</div> className={`z-[5] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
<button onClick={openModuleOverview} className="z-[5] hidden flex-shrink-0 group-hover:flex"> >
<Info className="h-4 w-4 text-custom-text-400" /> <Info className="h-4 w-4 text-custom-text-400" />
</button> </button>
</div> }
</div> actionableItems={
<span className="h-6 w-52 flex-shrink-0" /> <ModuleListItemAction moduleId={moduleId} moduleDetails={moduleDetails} parentRef={parentRef} />
</div> }
</Link> isMobile={isMobile}
<div className="absolute right-5 bottom-8 flex items-center gap-1.5"> parentRef={parentRef}
<div className="flex flex-shrink-0 items-center justify-center">
{moduleStatus && (
<span
className="flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: moduleStatus.color,
backgroundColor: `${moduleStatus.color}20`,
}}
>
{moduleStatus.label}
</span>
)}
</div>
<div className="relative flex w-full items-center justify-between gap-2.5 sm:w-auto sm:flex-shrink-0 sm:justify-end ">
<div className="text-xs text-custom-text-300">
{renderDate && (
<span className=" text-xs text-custom-text-300">
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
</span>
)}
</div>
<div className="relative flex flex-shrink-0 items-center gap-3">
<Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center gap-1">
{moduleDetails.member_ids.length > 0 ? (
<AvatarGroup showTooltip={false}>
{moduleDetails.member_ids.map((member_id) => {
const member = getUserDetails(member_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{isEditingAllowed && !isArchived && (
<FavoriteStar
onClick={(e) => {
if (moduleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
selected={moduleDetails.is_favorite}
/> />
)}
{workspaceSlug && projectId && (
<ModuleQuickActions
moduleId={moduleId}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
isArchived={isArchived}
/>
)}
</div>
</div>
</div>
</div>
); );
}); });

View File

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="hidden h-full sm:flex items-center gap-3 self-end">
<div className="flex items-center">
{!isSearchOpen && (
<button
type="button"
className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
// updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
<ModuleOrderByDropdown
value={displayFilters?.order_by}
onChange={(val) => {
if (!projectId || val === displayFilters?.order_by) return;
updateDisplayFilters(projectId.toString(), {
order_by: val,
});
}}
/>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<ModuleFiltersSelection
displayFilters={displayFilters ?? {}}
filters={filters ?? {}}
handleDisplayFiltersUpdate={(val) => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), val);
}}
handleFiltersUpdate={handleFilters}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
<div className="hidden md:flex items-center gap-1 rounded bg-custom-background-80 p-1">
{MODULE_VIEW_LAYOUTS.map((layout) => (
<Tooltip key={layout.key} tooltipContent={layout.title} isMobile={isMobile}>
<button
type="button"
className={cn(
"group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100",
{
"bg-custom-background-100 shadow-custom-shadow-2xs": displayFilters?.layout === layout.key,
}
)}
onClick={() => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), { layout: layout.key });
}}
>
<layout.icon
strokeWidth={2}
className={cn("h-3.5 w-3.5 text-custom-text-200", {
"text-custom-text-100": displayFilters?.layout === layout.key,
})}
/>
</button>
</Tooltip>
))}
</div>
</div>
);
});

View File

@ -2,6 +2,7 @@ import { observer } from "mobx-react-lite";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// components // components
import { ListLayout } from "@/components/core/list";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "@/components/modules"; import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "@/components/modules";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "@/components/ui"; import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "@/components/ui";
@ -69,11 +70,11 @@ export const ModulesListView: React.FC = observer(() => {
{displayFilters?.layout === "list" && ( {displayFilters?.layout === "list" && (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between"> <div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg"> <ListLayout>
{filteredModuleIds.map((moduleId) => ( {filteredModuleIds.map((moduleId) => (
<ModuleListItem key={moduleId} moduleId={moduleId} /> <ModuleListItem key={moduleId} moduleId={moduleId} />
))} ))}
</div> </ListLayout>
<ModulePeekOverview <ModulePeekOverview
projectId={projectId?.toString() ?? ""} projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""} workspaceSlug={workspaceSlug?.toString() ?? ""}

View File

@ -2,27 +2,28 @@ import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// icons // icons
import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react"; import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
// ui // ui
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { ArchiveModuleModal, CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules"; import { ArchiveModuleModal, CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules";
// constants // constants
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers // helpers
import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks // hooks
import { useModule, useEventTracker, useUser } from "@/hooks/store"; import { useModule, useEventTracker, useUser } from "@/hooks/store";
type Props = { type Props = {
parentRef: React.RefObject<HTMLDivElement>;
moduleId: string; moduleId: string;
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;
isArchived?: boolean;
}; };
export const ModuleQuickActions: React.FC<Props> = observer((props) => { export const ModuleQuickActions: React.FC<Props> = observer((props) => {
const { moduleId, projectId, workspaceSlug, isArchived } = props; const { parentRef, moduleId, projectId, workspaceSlug } = props;
// router // router
const router = useRouter(); const router = useRouter();
// states // states
@ -37,6 +38,7 @@ export const ModuleQuickActions: React.FC<Props> = observer((props) => {
const { getModuleById, restoreModule } = useModule(); const { getModuleById, restoreModule } = useModule();
// derived values // derived values
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
const isArchived = !!moduleDetails?.archived_at;
// auth // auth
const isEditingAllowed = const isEditingAllowed =
!!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER; !!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER;
@ -44,34 +46,25 @@ export const ModuleQuickActions: React.FC<Props> = observer((props) => {
const moduleState = moduleDetails?.status?.toLocaleLowerCase(); const moduleState = moduleDetails?.status?.toLocaleLowerCase();
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState); const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => { const moduleLink = `${workspaceSlug}/projects/${projectId}/modules/${moduleId}`;
e.stopPropagation(); const handleCopyText = () =>
e.preventDefault(); copyUrlToClipboard(moduleLink).then(() => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Link Copied!", title: "Link Copied!",
message: "Module link copied to clipboard.", message: "Module link copied to clipboard.",
}); });
}); });
}; const handleOpenInNewTab = () => window.open(`/${moduleLink}`, "_blank");
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => { const handleEditModule = () => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout"); setTrackElement("Modules page list layout");
setEditModal(true); setEditModal(true);
}; };
const handleArchiveModule = (e: React.MouseEvent<HTMLButtonElement>) => { const handleArchiveModule = () => setArchiveModuleModal(true);
e.preventDefault();
e.stopPropagation();
setArchiveModuleModal(true);
};
const handleRestoreModule = async (e: React.MouseEvent<HTMLButtonElement>) => { const handleRestoreModule = async () =>
e.preventDefault();
e.stopPropagation();
await restoreModule(workspaceSlug, projectId, moduleId) await restoreModule(workspaceSlug, projectId, moduleId)
.then(() => { .then(() => {
setToast({ setToast({
@ -88,15 +81,61 @@ export const ModuleQuickActions: React.FC<Props> = observer((props) => {
message: "Module could not be restored. Please try again.", message: "Module could not be restored. Please try again.",
}) })
); );
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => { const handleDeleteModule = () => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout"); setTrackElement("Modules page list layout");
setDeleteModal(true); 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 ( return (
<> <>
{moduleDetails && ( {moduleDetails && (
@ -118,60 +157,42 @@ export const ModuleQuickActions: React.FC<Props> = observer((props) => {
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} /> <DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
</div> </div>
)} )}
<CustomMenu ellipsis placement="left-start"> <ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
{isEditingAllowed && !isArchived && ( <CustomMenu ellipsis placement="bottom-end" closeOnSelect>
<CustomMenu.MenuItem onClick={handleEditModule}> {MENU_ITEMS.map((item) => {
<span className="flex items-center justify-start gap-2"> if (item.shouldRender === false) return null;
<Pencil className="h-3 w-3" /> return (
<span>Edit module</span> <CustomMenu.MenuItem
</span> key={item.key}
</CustomMenu.MenuItem> onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)} )}
{isEditingAllowed && !isArchived && ( >
<CustomMenu.MenuItem onClick={handleArchiveModule} disabled={!isInArchivableGroup}> {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
{isInArchivableGroup ? ( <div>
<div className="flex items-center gap-2"> <h5>{item.title}</h5>
<ArchiveIcon className="h-3 w-3" /> {item.description && (
Archive module <p
</div> className={cn("text-custom-text-300 whitespace-pre-line", {
) : ( "text-custom-text-400": item.disabled,
<div className="flex items-start gap-2"> })}
<ArchiveIcon className="h-3 w-3" /> >
<div className="-mt-1"> {item.description}
<p>Archive module</p>
<p className="text-xs text-custom-text-400">
Only completed or cancelled <br /> module can be archived.
</p> </p>
)}
</div> </div>
</div>
)}
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} );
{isEditingAllowed && isArchived && ( })}
<CustomMenu.MenuItem onClick={handleRestoreModule}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore module</span>
</span>
</CustomMenu.MenuItem>
)}
{!isArchived && (
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy module link</span>
</span>
</CustomMenu.MenuItem>
)}
<hr className="my-2 border-custom-border-200" />
{isEditingAllowed && (
<CustomMenu.MenuItem onClick={handleDeleteModule}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
)}
</CustomMenu> </CustomMenu>
</> </>
); );

View File

@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ArchiveRestoreIcon, ExternalLink, Link, Lock, Trash2, UsersRound } from "lucide-react"; import { ArchiveRestoreIcon, ExternalLink, Link, Lock, Trash2, UsersRound } from "lucide-react";
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { DeletePageModal } from "@/components/pages"; import { DeletePageModal } from "@/components/pages";
// helpers // helpers
@ -11,12 +11,13 @@ import { usePage } from "@/hooks/store";
type Props = { type Props = {
pageId: string; pageId: string;
parentRef: React.RefObject<HTMLElement>;
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;
}; };
export const PageQuickActions: React.FC<Props> = observer((props) => { export const PageQuickActions: React.FC<Props> = observer((props) => {
const { pageId, projectId, workspaceSlug } = props; const { pageId, parentRef, projectId, workspaceSlug } = props;
// states // states
const [deletePageModal, setDeletePageModal] = useState(false); const [deletePageModal, setDeletePageModal] = useState(false);
// store hooks // store hooks
@ -44,45 +45,39 @@ export const PageQuickActions: React.FC<Props> = observer((props) => {
const handleOpenInNewTab = () => window.open(`/${pageLink}`, "_blank"); const handleOpenInNewTab = () => window.open(`/${pageLink}`, "_blank");
const MENU_ITEMS: { const MENU_ITEMS: TContextMenuItem[] = [
key: string;
action: () => void;
label: string;
icon: React.FC<any>;
shouldRender: boolean;
}[] = [
{ {
key: "copy-link", key: "make-public-private",
action: handleCopyText, action: access === 0 ? makePrivate : makePublic,
label: "Copy link", title: access === 0 ? "Make private" : "Make public",
icon: Link, icon: access === 0 ? Lock : UsersRound,
shouldRender: true, shouldRender: canCurrentUserChangeAccess && !archived_at,
}, },
{ {
key: "open-new-tab", key: "open-new-tab",
action: handleOpenInNewTab, action: handleOpenInNewTab,
label: "Open in new tab", title: "Open in new tab",
icon: ExternalLink, icon: ExternalLink,
shouldRender: true, shouldRender: true,
}, },
{
key: "copy-link",
action: handleCopyText,
title: "Copy link",
icon: Link,
shouldRender: true,
},
{ {
key: "archive-restore", key: "archive-restore",
action: archived_at ? restore : archive, action: archived_at ? restore : archive,
label: archived_at ? "Restore" : "Archive", title: archived_at ? "Restore" : "Archive",
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
shouldRender: canCurrentUserArchivePage, shouldRender: canCurrentUserArchivePage,
}, },
{
key: "make-public-private",
action: access === 0 ? makePrivate : makePublic,
label: access === 0 ? "Make private" : "Make public",
icon: access === 0 ? Lock : UsersRound,
shouldRender: canCurrentUserChangeAccess && !archived_at,
},
{ {
key: "delete", key: "delete",
action: () => setDeletePageModal(true), action: () => setDeletePageModal(true),
label: "Delete", title: "Delete",
icon: Trash2, icon: Trash2,
shouldRender: canCurrentUserDeletePage && !!archived_at, shouldRender: canCurrentUserDeletePage && !!archived_at,
}, },
@ -96,6 +91,7 @@ export const PageQuickActions: React.FC<Props> = observer((props) => {
pageId={pageId} pageId={pageId}
projectId={projectId} projectId={projectId}
/> />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu placement="bottom-end" ellipsis closeOnSelect> <CustomMenu placement="bottom-end" ellipsis closeOnSelect>
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
if (!item.shouldRender) return null; if (!item.shouldRender) return null;
@ -109,8 +105,8 @@ export const PageQuickActions: React.FC<Props> = observer((props) => {
}} }}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<item.icon className="h-3 w-3" /> {item.icon && <item.icon className="h-3 w-3" />}
{item.label} {item.title}
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
); );
})} })}

View File

@ -26,7 +26,6 @@ export const PageContentBrowser: React.FC<Props> = (props) => {
return ( return (
<div className="h-full flex flex-col overflow-hidden"> <div className="h-full flex flex-col overflow-hidden">
<h2 className="font-medium">Outline</h2>
<div className="h-full flex flex-col items-start gap-y-2 overflow-y-auto mt-2"> <div className="h-full flex flex-col items-start gap-y-2 overflow-y-auto mt-2">
{markings.length !== 0 ? ( {markings.length !== 0 ? (
markings.map((marking) => { markings.map((marking) => {

View File

@ -14,7 +14,7 @@ import {
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks // hooks
import { useLabel, useMember, useProjectPages } from "@/hooks/store"; import { useMember, useProjectPages } from "@/hooks/store";
type Props = { type Props = {
pageType: TPageNavigationTabs; pageType: TPageNavigationTabs;
@ -29,7 +29,6 @@ export const PagesListHeaderRoot: React.FC<Props> = observer((props) => {
const { const {
workspace: { workspaceMemberIds }, workspace: { workspaceMemberIds },
} = useMember(); } = useMember();
const { projectLabels } = useLabel();
const handleRemoveFilter = useCallback( const handleRemoveFilter = useCallback(
(key: keyof TPageFilterProps, value: string | null) => { (key: keyof TPageFilterProps, value: string | null) => {
@ -48,7 +47,7 @@ export const PagesListHeaderRoot: React.FC<Props> = observer((props) => {
return ( return (
<> <>
<div className="flex-shrink-0 w-full border-b border-custom-border-200 px-3 relative flex items-center gap-4 justify-between"> <div className="flex-shrink-0 h-[50px] w-full border-b border-custom-border-200 px-6 relative flex items-center gap-4 justify-between">
<PageTabNavigation workspaceSlug={workspaceSlug} projectId={projectId} pageType={pageType} /> <PageTabNavigation workspaceSlug={workspaceSlug} projectId={projectId} pageType={pageType} />
<div className="h-full flex items-center gap-2 self-end"> <div className="h-full flex items-center gap-2 self-end">
<PageSearchInput projectId={projectId} /> <PageSearchInput projectId={projectId} />
@ -64,7 +63,6 @@ export const PagesListHeaderRoot: React.FC<Props> = observer((props) => {
<PageFiltersSelection <PageFiltersSelection
filters={filters} filters={filters}
handleFiltersUpdate={updateFilters} handleFiltersUpdate={updateFilters}
labels={projectLabels}
memberIds={workspaceMemberIds ?? undefined} memberIds={workspaceMemberIds ?? undefined}
/> />
</FiltersDropdown> </FiltersDropdown>

View File

@ -0,0 +1,95 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Circle, Earth, Info, Lock, Minus } from "lucide-react";
// ui
import { Avatar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components
import { FavoriteStar } from "@/components/core";
import { PageQuickActions } from "@/components/pages/dropdowns";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useMember, usePage } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
pageId: string;
parentRef: React.RefObject<HTMLElement>;
};
export const BlockItemAction: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, pageId, parentRef } = props;
// store hooks
const { access, created_at, is_favorite, owned_by, addToFavorites, removeFromFavorites } = usePage(pageId);
const { getUserDetails } = useMember();
// derived values
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
// handlers
const handleFavorites = () => {
if (is_favorite)
removeFromFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page removed from favorites.",
})
);
else
addToFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page added to favorites.",
})
);
};
return (
<>
{/* page details */}
<div className="flex items-center gap-2 text-custom-text-400">
{/* <span className="text-xs">Labels</span>
<Circle className="h-1 w-1 fill-custom-text-300" /> */}
<div className="cursor-default">
<Tooltip tooltipHeading="Owned by" tooltipContent={ownerDetails?.display_name}>
<Avatar src={ownerDetails?.avatar} name={ownerDetails?.display_name} />
</Tooltip>
</div>
<Circle className="h-1 w-1 fill-custom-text-300" />
{/* <span className="text-xs cursor-default">10m read</span>
<Circle className="h-1 w-1 fill-custom-text-300" /> */}
<div className="cursor-default">
<Tooltip tooltipContent={access === 0 ? "Public" : "Private"}>
{access === 0 ? <Earth className="h-3 w-3" /> : <Lock className="h-3 w-3" />}
</Tooltip>
</div>
</div>
{/* vertical divider */}
<Minus className="h-5 w-5 text-custom-text-400 rotate-90 -mx-3" strokeWidth={1} />
{/* page info */}
<Tooltip tooltipContent={`Created on ${renderFormattedDate(created_at)}`}>
<span className="h-4 w-4 grid place-items-center cursor-default">
<Info className="h-4 w-4 text-custom-text-300" />
</span>
</Tooltip>
{/* favorite/unfavorite */}
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleFavorites();
}}
selected={is_favorite}
/>
{/* quick actions dropdown */}
<PageQuickActions parentRef={parentRef} pageId={pageId} projectId={projectId} workspaceSlug={workspaceSlug} />
</>
);
});

View File

@ -1,15 +1,11 @@
import { FC } from "react"; import { FC, useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link";
import { Circle, Info, Lock, Minus, UsersRound } from "lucide-react";
import { Avatar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components // components
import { FavoriteStar } from "@/components/core"; import { ListItem } from "@/components/core/list";
import { PageQuickActions } from "@/components/pages"; import { BlockItemAction } from "@/components/pages/list";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks // hooks
import { useMember, usePage } from "@/hooks/store"; import { usePage } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TPageListBlock = { type TPageListBlock = {
workspaceSlug: string; workspaceSlug: string;
@ -19,85 +15,19 @@ type TPageListBlock = {
export const PageListBlock: FC<TPageListBlock> = observer((props) => { export const PageListBlock: FC<TPageListBlock> = observer((props) => {
const { workspaceSlug, projectId, pageId } = props; const { workspaceSlug, projectId, pageId } = props;
// refs
const parentRef = useRef(null);
// hooks // hooks
const { access, created_at, is_favorite, name, owned_by, addToFavorites, removeFromFavorites } = usePage(pageId); const { name } = usePage(pageId);
const { getUserDetails } = useMember(); const { isMobile } = usePlatformOS();
// derived values
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
const handleFavorites = () => {
if (is_favorite)
removeFromFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page removed from favorites.",
})
);
else
addToFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page added to favorites.",
})
);
};
return ( return (
<Link <ListItem
href={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`} title={name ?? ""}
className="flex items-center justify-between gap-5 py-7 px-6 hover:bg-custom-background-90" itemLink={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
> actionableItems={<BlockItemAction workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} parentRef={parentRef} />}
{/* page title */} isMobile={isMobile}
<Tooltip tooltipHeading="Title" tooltipContent={name}> parentRef={parentRef}
<h5 className="text-base font-medium truncate">{name}</h5>
</Tooltip>
{/* page properties */}
<div className="flex items-center gap-5 flex-shrink-0">
{/* page details */}
<div className="flex items-center gap-2 text-custom-text-400">
{/* <span className="text-xs">Labels</span>
<Circle className="h-1 w-1 fill-custom-text-300" /> */}
<div className="cursor-default">
<Tooltip tooltipHeading="Owned by" tooltipContent={ownerDetails?.display_name}>
<Avatar src={ownerDetails?.avatar} name={ownerDetails?.display_name} />
</Tooltip>
</div>
<Circle className="h-1 w-1 fill-custom-text-300" />
{/* <span className="text-xs cursor-default">10m read</span>
<Circle className="h-1 w-1 fill-custom-text-300" /> */}
<div className="cursor-default">
<Tooltip tooltipContent={access === 0 ? "Public" : "Private"}>
{access === 0 ? <UsersRound className="h-3 w-3" /> : <Lock className="h-3 w-3" />}
</Tooltip>
</div>
</div>
{/* vertical divider */}
<Minus className="h-5 w-5 text-custom-text-400 rotate-90 -mx-3" strokeWidth={1} />
{/* page info */}
<Tooltip tooltipContent={`Created on ${renderFormattedDate(created_at)}`}>
<span className="h-4 w-4 grid place-items-center cursor-default">
<Info className="h-4 w-4 text-custom-text-300" />
</span>
</Tooltip>
{/* favorite/unfavorite */}
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleFavorites();
}}
selected={is_favorite}
/> />
{/* quick actions dropdown */}
<PageQuickActions pageId={pageId} projectId={projectId} workspaceSlug={workspaceSlug} />
</div>
</Link>
); );
}); });

View File

@ -1,4 +1,3 @@
export * from "./created-at"; export * from "./created-at";
export * from "./created-by"; export * from "./created-by";
export * from "./labels";
export * from "./root"; export * from "./root";

View File

@ -1,93 +0,0 @@
import React, { useMemo, useState } from "react";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
// types
import { IIssueLabel } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues";
const LabelIcons = ({ color }: { color: string }) => (
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
);
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
labels: IIssueLabel[] | undefined;
searchQuery: string;
};
export const FilterLabels: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, labels, searchQuery } = props;
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (labels || []).filter((label) =>
label.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(label) => !(appliedFilters ?? []).includes(label.id),
(label) => label.name.toLowerCase(),
]);
}, [appliedFilters, labels, searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Labels${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((label) => (
<FilterOption
key={label?.id}
isChecked={appliedFilters?.includes(label?.id) ? true : false}
onClick={() => handleUpdate(label?.id)}
icon={<LabelIcons color={label.color} />}
title={label.name}
/>
))}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@ -1,20 +1,19 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { IIssueLabel, TPageFilterProps, TPageFilters } from "@plane/types"; import { TPageFilterProps, TPageFilters } from "@plane/types";
// components // components
import { FilterOption } from "@/components/issues"; import { FilterOption } from "@/components/issues";
import { FilterCreatedBy, FilterCreatedDate, FilterLabels } from "@/components/pages"; import { FilterCreatedBy, FilterCreatedDate } from "@/components/pages";
type Props = { type Props = {
filters: TPageFilters; filters: TPageFilters;
handleFiltersUpdate: <T extends keyof TPageFilters>(filterKey: T, filterValue: TPageFilters[T]) => void; handleFiltersUpdate: <T extends keyof TPageFilters>(filterKey: T, filterValue: TPageFilters[T]) => void;
labels?: IIssueLabel[] | undefined;
memberIds?: string[] | undefined; memberIds?: string[] | undefined;
}; };
export const PageFiltersSelection: React.FC<Props> = observer((props) => { export const PageFiltersSelection: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate, labels, memberIds } = props; const { filters, handleFiltersUpdate, memberIds } = props;
// states // states
const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
@ -93,16 +92,6 @@ export const PageFiltersSelection: React.FC<Props> = observer((props) => {
memberIds={memberIds} memberIds={memberIds}
/> />
</div> </div>
{/* labels */}
<div className="py-2">
<FilterLabels
appliedFilters={filters.filters?.labels ?? null}
handleUpdate={(val) => handleFilters("labels", val)}
searchQuery={filtersSearchQuery}
labels={labels}
/>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,7 @@
export * from "./applied-filters"; export * from "./applied-filters";
export * from "./filters"; export * from "./filters";
export * from "./block"; export * from "./block";
export * from "./block-item-action";
export * from "./order-by"; export * from "./order-by";
export * from "./root"; export * from "./root";
export * from "./search-input"; export * from "./search-input";

View File

@ -2,6 +2,8 @@ import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// types // types
import { TPageNavigationTabs } from "@plane/types"; import { TPageNavigationTabs } from "@plane/types";
// components
import { ListLayout } from "@/components/core/list";
// hooks // hooks
import { useProjectPages } from "@/hooks/store"; import { useProjectPages } from "@/hooks/store";
// components // components
@ -22,10 +24,10 @@ export const PagesListRoot: FC<TPagesListRoot> = observer((props) => {
if (!filteredPageIds) return <></>; if (!filteredPageIds) return <></>;
return ( return (
<div className="relative w-full h-full overflow-hidden overflow-y-auto divide-y-[0.5px] divide-custom-border-200"> <ListLayout>
{filteredPageIds.map((pageId) => ( {filteredPageIds.map((pageId) => (
<PageListBlock key={pageId} workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} /> <PageListBlock key={pageId} workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} />
))} ))}
</div> </ListLayout>
); );
}); });

View File

@ -1,12 +1,22 @@
import React, { useState } from "react"; import React, { useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react-lite";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ArchiveRestoreIcon, Check, LinkIcon, Lock, Pencil, Trash2 } from "lucide-react"; import { ArchiveRestoreIcon, Check, ExternalLink, LinkIcon, Lock, Pencil, Trash2, UserPlus } from "lucide-react";
// types // types
import type { IProject } from "@plane/types"; import type { IProject } from "@plane/types";
// ui // ui
import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui"; import {
Avatar,
AvatarGroup,
Button,
Tooltip,
TOAST_TYPE,
setToast,
setPromiseToast,
ContextMenu,
TContextMenuItem,
} from "@plane/ui";
// components // components
import { FavoriteStar } from "@/components/core"; import { FavoriteStar } from "@/components/core";
import { ArchiveRestoreProjectModal, DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project"; import { ArchiveRestoreProjectModal, DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project";
@ -30,6 +40,8 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false); const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
const [joinProjectModalOpen, setJoinProjectModal] = useState(false); const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
const [restoreProject, setRestoreProject] = useState(false); const [restoreProject, setRestoreProject] = useState(false);
// refs
const projectCardRef = useRef(null);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -80,14 +92,61 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
}); });
}; };
const projectLink = `${workspaceSlug}/projects/${project.id}/issues`;
const handleCopyText = () => const handleCopyText = () =>
copyUrlToClipboard(`${workspaceSlug}/projects/${project.id}/issues`).then(() => copyUrlToClipboard(projectLink).then(() =>
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Link Copied!", title: "Link Copied!",
message: "Project link copied to clipboard.", message: "Project link copied to clipboard.",
}) })
); );
const handleOpenInNewTab = () => window.open(`/${projectLink}`, "_blank");
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "edit",
action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`),
title: "Edit",
icon: Pencil,
shouldRender: !isArchived && (isOwner || isMember),
},
{
key: "join",
action: () => setJoinProjectModal(true),
title: "Join",
icon: UserPlus,
shouldRender: !project.is_member && !isArchived,
},
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: "Open in new tab",
icon: ExternalLink,
shouldRender: project.is_member,
},
{
key: "copy-link",
action: handleCopyText,
title: "Copy link",
icon: LinkIcon,
shouldRender: true,
},
{
key: "restore",
action: () => setRestoreProject(true),
title: "Restore",
icon: ArchiveRestoreIcon,
shouldRender: isArchived && isOwner,
},
{
key: "delete",
action: () => setDeleteProjectModal(true),
title: "Delete",
icon: Trash2,
shouldRender: isArchived && isOwner,
},
];
return ( return (
<> <>
@ -117,6 +176,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
/> />
)} )}
<Link <Link
ref={projectCardRef}
href={`/${workspaceSlug}/projects/${project.id}/issues`} href={`/${workspaceSlug}/projects/${project.id}/issues`}
onClick={(e) => { onClick={(e) => {
if (!project.is_member || isArchived) { if (!project.is_member || isArchived) {
@ -127,6 +187,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
}} }}
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100" className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100"
> >
<ContextMenu parentRef={projectCardRef} items={MENU_ITEMS} />
<div className="relative h-[118px] w-full rounded-t "> <div className="relative h-[118px] w-full rounded-t ">
<div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" /> <div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" />

View File

@ -1,5 +1,7 @@
export * from "./delete-view-modal"; export * from "./delete-view-modal";
export * from "./form"; export * from "./form";
export * from "./modal"; export * from "./modal";
export * from "./quick-actions";
export * from "./view-list-item"; export * from "./view-list-item";
export * from "./views-list"; export * from "./views-list";
export * from "./view-list-item-action";

View File

@ -0,0 +1,126 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
// types
import { IProjectView } from "@plane/types";
// ui
import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "@/components/views";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useUser } from "@/hooks/store";
type Props = {
parentRef: React.RefObject<HTMLElement>;
projectId: string;
view: IProjectView;
workspaceSlug: string;
};
export const ViewQuickActions: React.FC<Props> = observer((props) => {
const { parentRef, projectId, view, workspaceSlug } = props;
// states
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false);
const [deleteViewModal, setDeleteViewModal] = useState(false);
// store hooks
const {
membership: { currentProjectRole },
} = useUser();
// auth
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const viewLink = `${workspaceSlug}/projects/${projectId}/views/${view.id}`;
const handleCopyText = () =>
copyUrlToClipboard(viewLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "View link copied to clipboard.",
});
});
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "edit",
action: () => setCreateUpdateViewModal(true),
title: "Edit",
icon: Pencil,
shouldRender: isEditingAllowed,
},
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: "Open in new tab",
icon: ExternalLink,
},
{
key: "copy-link",
action: handleCopyText,
title: "Copy link",
icon: Link,
},
{
key: "delete",
action: () => setDeleteViewModal(true),
title: "Delete",
icon: Trash2,
shouldRender: isEditingAllowed,
},
];
return (
<>
<CreateUpdateProjectViewModal
isOpen={createUpdateViewModal}
onClose={() => setCreateUpdateViewModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
data={view}
/>
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View File

@ -0,0 +1,89 @@
import React, { FC, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// types
import { IProjectView } from "@plane/types";
// components
import { FavoriteStar } from "@/components/core";
import { DeleteProjectViewModal, CreateUpdateProjectViewModal, ViewQuickActions } from "@/components/views";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useProjectView, useUser } from "@/hooks/store";
type Props = {
parentRef: React.RefObject<HTMLElement>;
view: IProjectView;
};
export const ViewListItemAction: FC<Props> = observer((props) => {
const { parentRef, view } = props;
// states
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false);
const [deleteViewModal, setDeleteViewModal] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const {
membership: { currentProjectRole },
} = useUser();
const { addViewToFavorites, removeViewFromFavorites } = useProjectView();
// derived values
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
// @ts-expect-error key types are not compatible
const totalFilters = calculateTotalFilters(view.filters ?? {});
// handlers
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId) return;
addViewToFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId) return;
removeViewFromFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
};
return (
<>
{workspaceSlug && projectId && view && (
<CreateUpdateProjectViewModal
isOpen={createUpdateViewModal}
onClose={() => setCreateUpdateViewModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
data={view}
/>
)}
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<p className="hidden rounded bg-custom-background-80 px-2 py-1 text-xs text-custom-text-200 group-hover:block">
{totalFilters} {totalFilters === 1 ? "filter" : "filters"}
</p>
{isEditingAllowed && (
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (view.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites();
}}
selected={view.is_favorite}
/>
)}
{projectId && workspaceSlug && (
<ViewQuickActions
parentRef={parentRef}
projectId={projectId.toString()}
view={view}
workspaceSlug={workspaceSlug.toString()}
/>
)}
</>
);
});

View File

@ -1,151 +1,35 @@
import React, { useState } from "react"; import { FC, useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
// types // types
import { IProjectView } from "@plane/types"; import { IProjectView } from "@plane/types";
// ui
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { FavoriteStar } from "@/components/core"; import { ListItem } from "@/components/core/list";
import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "@/components/views"; import { ViewListItemAction } from "@/components/views";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks // hooks
import { useProjectView, useUser } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = { type Props = {
view: IProjectView; view: IProjectView;
}; };
export const ProjectViewListItem: React.FC<Props> = observer((props) => { export const ProjectViewListItem: FC<Props> = observer((props) => {
const { view } = props; const { view } = props;
// states // refs
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false); const parentRef = useRef(null);
const [deleteViewModal, setDeleteViewModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// store hooks // store hooks
const { const { isMobile } = usePlatformOS();
membership: { currentProjectRole },
} = useUser();
const { addViewToFavorites, removeViewFromFavorites } = useProjectView();
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId) return;
addViewToFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId) return;
removeViewFromFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/views/${view.id}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "View link copied to clipboard.",
});
});
};
// @ts-expect-error key types are not compatible
const totalFilters = calculateTotalFilters(view.filters ?? {});
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
return ( return (
<> <ListItem
{workspaceSlug && projectId && view && ( title={view.name}
<CreateUpdateProjectViewModal itemLink={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}
isOpen={createUpdateViewModal} actionableItems={<ViewListItemAction parentRef={parentRef} view={view} />}
onClose={() => setCreateUpdateViewModal(false)} isMobile={isMobile}
workspaceSlug={workspaceSlug.toString()} parentRef={parentRef}
projectId={projectId.toString()}
data={view}
/> />
)}
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<div className="group border-b border-custom-border-200 hover:bg-custom-background-90">
<Link href={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}>
<div className="relative flex h-[52px] w-full items-center justify-between rounded p-4">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-4 overflow-hidden">
<div className="flex flex-col overflow-hidden ">
<p className="truncate break-all text-sm font-medium leading-4">{view.name}</p>
{view?.description && <p className="break-all text-xs text-custom-text-200">{view.description}</p>}
</div>
</div>
<div className="ml-2 flex flex-shrink-0">
<div className="flex items-center gap-4">
<p className="hidden rounded bg-custom-background-80 px-2 py-1 text-xs text-custom-text-200 group-hover:block">
{totalFilters} {totalFilters === 1 ? "filter" : "filters"}
</p>
{isEditingAllowed && (
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (view.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites();
}}
selected={view.is_favorite}
/>
)}
<CustomMenu ellipsis>
{isEditingAllowed && (
<>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCreateUpdateViewModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<PencilIcon size={14} strokeWidth={2} />
<span>Edit View</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeleteViewModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<TrashIcon size={14} strokeWidth={2} />
<span>Delete View</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy view link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</div>
</Link>
</div>
</>
); );
}); });

View File

@ -1,53 +1,117 @@
import { useState } from "react"; import { useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react-lite";
import { Search } from "lucide-react"; // ui
// hooks import { Search, X } from "lucide-react";
// components // components
import { Input } from "@plane/ui"; import { ListLayout } from "@/components/core/list";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { ViewListLoader } from "@/components/ui"; import { ViewListLoader } from "@/components/ui";
import { ProjectViewListItem } from "@/components/views"; import { ProjectViewListItem } from "@/components/views";
// ui
// constants // constants
import { EmptyStateType } from "@/constants/empty-state"; import { EmptyStateType } from "@/constants/empty-state";
// helper
import { cn } from "@/helpers/common.helper";
// hooks
import { useCommandPalette, useProjectView } from "@/hooks/store"; import { useCommandPalette, useProjectView } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export const ProjectViewsList = observer(() => { export const ProjectViewsList = observer(() => {
// states // states
const [query, setQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// refs
const inputRef = useRef<HTMLInputElement>(null);
// store hooks // store hooks
const { toggleCreateViewModal } = useCommandPalette(); const { toggleCreateViewModal } = useCommandPalette();
const { projectViewIds, getViewById, loader } = useProjectView(); const { projectViewIds, getViewById, loader } = useProjectView();
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
if (loader || !projectViewIds) return <ViewListLoader />; if (loader || !projectViewIds) return <ViewListLoader />;
// derived values
const viewsList = projectViewIds.map((viewId) => getViewById(viewId)); const viewsList = projectViewIds.map((viewId) => getViewById(viewId));
const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(query.toLowerCase())); const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(searchQuery.toLowerCase()));
// handlers
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") setSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
return ( return (
<> <>
{viewsList.length > 0 ? ( {viewsList.length > 0 ? (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<div className="flex w-full flex-shrink-0 flex-col overflow-hidden"> <div className="h-[50px] flex-shrink-0 w-full border-b border-custom-border-200 px-6 relative flex items-center gap-4 justify-between">
<div className="flex w-full items-center gap-2.5 border-b border-custom-border-200 px-5 py-3"> <div className="flex items-center">
<Search className="text-custom-text-200" size={14} strokeWidth={2} /> <span className="block text-sm font-medium">Project Views</span>
<Input </div>
className="w-full bg-transparent !p-0 text-xs leading-5 text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <div className="h-full flex items-center gap-2">
value={query} <div className="flex items-center">
onChange={(e) => setQuery(e.target.value)} {!isSearchOpen && (
<button
type="button"
className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search" placeholder="Search"
mode="true-transparent" value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/> />
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
setSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div> </div>
</div> </div>
<div className="vertical-scrollbar scrollbar-lg flex h-full w-full flex-col"> </div>
</div>
<ListLayout>
{filteredViewsList.length > 0 ? ( {filteredViewsList.length > 0 ? (
filteredViewsList.map((view) => <ProjectViewListItem key={view.id} view={view} />) filteredViewsList.map((view) => <ProjectViewListItem key={view.id} view={view} />)
) : ( ) : (
<p className="mt-10 text-center text-sm text-custom-text-300">No results found</p> <p className="mt-10 text-center text-sm text-custom-text-300">No results found</p>
)} )}
</div> </ListLayout>
</div> </div>
) : ( ) : (
<EmptyState type={EmptyStateType.PROJECT_VIEW} primaryButtonOnClick={() => toggleCreateViewModal(true)} /> <EmptyState type={EmptyStateType.PROJECT_VIEW} primaryButtonOnClick={() => toggleCreateViewModal(true)} />

View File

@ -132,7 +132,7 @@ export const DURATION_FILTER_OPTIONS: {
}[] = [ }[] = [
{ {
key: EDurationFilters.NONE, key: EDurationFilters.NONE,
label: "None", label: "All time",
}, },
{ {
key: EDurationFilters.TODAY, key: EDurationFilters.TODAY,

View File

@ -1,13 +1,6 @@
import differenceInCalendarDays from "date-fns/differenceInCalendarDays"; import differenceInCalendarDays from "date-fns/differenceInCalendarDays";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
// helpers
import { getDate } from "@/helpers/date-time.helper";
import { orderArrayBy } from "@/helpers/array.helper";
// types // types
import { IGanttBlock } from "@/components/gantt-chart";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { STATE_GROUPS } from "@/constants/state";
import { import {
TIssue, TIssue,
TIssueGroupByOptions, TIssueGroupByOptions,
@ -16,6 +9,13 @@ import {
TIssueParams, TIssueParams,
TStateGroups, TStateGroups,
} from "@plane/types"; } from "@plane/types";
import { IGanttBlock } from "@/components/gantt-chart";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { STATE_GROUPS } from "@/constants/state";
// helpers
import { orderArrayBy } from "@/helpers/array.helper";
import { getDate } from "@/helpers/date-time.helper";
type THandleIssuesMutation = ( type THandleIssuesMutation = (
formData: Partial<TIssue>, formData: Partial<TIssue>,
@ -205,3 +205,9 @@ export const formatTextList = (TextArray: string[]): string => {
return `${TextArray.slice(0, 3).join(", ")}, and +${count - 3} more`; return `${TextArray.slice(0, 3).join(", ")}, and +${count - 3} more`;
} }
}; };
export const getDescriptionPlaceholder = (isFocused: boolean, description: string | undefined): string => {
const isDescriptionEmpty = !description || description === "<p></p>" || description.trim() === "";
if (!isDescriptionEmpty || isFocused) return "Press '/' for commands...";
else return "Click to add description";
};

View File

@ -7,7 +7,7 @@ import { TModuleFilters } from "@plane/types";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { ModulesListHeader } from "@/components/headers"; import { ModulesListHeader } from "@/components/headers";
import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; import { ModuleAppliedFiltersList, ModuleViewHeader, ModulesListView } from "@/components/modules";
// types // types
// hooks // hooks
import ModulesListMobileHeader from "@/components/modules/moduels-list-mobile-header"; import ModulesListMobileHeader from "@/components/modules/moduels-list-mobile-header";
@ -57,6 +57,12 @@ const ProjectModulesPage: NextPageWithLayout = observer(() => {
<> <>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
<div className="h-[50px] flex-shrink-0 w-full border-b border-custom-border-200 px-6 relative flex items-center gap-4 justify-between">
<div className="flex items-center">
<span className="block text-sm font-medium">Module name</span>
</div>
<ModuleViewHeader />
</div>
{calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3"> <div className="border-b border-custom-border-200 px-5 py-3">
<ModuleAppliedFiltersList <ModuleAppliedFiltersList

View File

@ -4,10 +4,11 @@ import { useRouter } from "next/router";
// types // types
import { TPageNavigationTabs } from "@plane/types"; import { TPageNavigationTabs } from "@plane/types";
// components // components
import { PageHead } from "@/components/core";
import { PagesHeader } from "@/components/headers"; import { PagesHeader } from "@/components/headers";
import { PagesListRoot, PagesListView } from "@/components/pages"; import { PagesListRoot, PagesListView } from "@/components/pages";
// hooks // hooks
import { useAppRouter } from "@/hooks/store"; import { useAppRouter, useProject } from "@/hooks/store";
// layouts // layouts
import { AppLayout } from "@/layouts/app-layout"; import { AppLayout } from "@/layouts/app-layout";
// lib // lib
@ -19,6 +20,10 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
const { type } = router.query; const { type } = router.query;
// store hooks // store hooks
const { workspaceSlug, projectId } = useAppRouter(); const { workspaceSlug, projectId } = useAppRouter();
const { getProjectById } = useProject();
// derived values
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name ? `${project?.name} - Pages` : undefined;
const currentPageType = (): TPageNavigationTabs => { const currentPageType = (): TPageNavigationTabs => {
const pageType = type?.toString(); const pageType = type?.toString();
@ -29,6 +34,8 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
if (!workspaceSlug || !projectId) return <></>; if (!workspaceSlug || !projectId) return <></>;
return ( return (
<>
<PageHead title={pageTitle} />
<PagesListView <PagesListView
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()} projectId={projectId.toString()}
@ -40,6 +47,7 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
projectId={projectId.toString()} projectId={projectId.toString()}
/> />
</PagesListView> </PagesListView>
</>
); );
}); });

View File

@ -41,6 +41,7 @@ class MyDocument extends Document {
)} )}
</Head> </Head>
<body> <body>
<div id="context-menu-portal" />
<Main /> <Main />
<NextScript /> <NextScript />
{process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && ( {process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && (

Some files were not shown because too many files have changed in this diff Show More