mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of gurusainath:makeplane/plane into chore-admin-file-structure
This commit is contained in:
commit
0e1ae5ce96
69
.github/workflows/auto-merge.yml
vendored
69
.github/workflows/auto-merge.yml
vendored
@ -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
|
|
76
.github/workflows/create-sync-pr.yml
vendored
76
.github/workflows/create-sync-pr.yml
vendored
@ -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
44
.github/workflows/repo-sync.yml
vendored
Normal 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
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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...";
|
||||||
|
@ -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) => {
|
||||||
|
@ -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) => {
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
2
packages/ui/src/dropdowns/context-menu/index.ts
Normal file
2
packages/ui/src/dropdowns/context-menu/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./item";
|
||||||
|
export * from "./root";
|
54
packages/ui/src/dropdowns/context-menu/item.tsx
Normal file
54
packages/ui/src/dropdowns/context-menu/item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
157
packages/ui/src/dropdowns/context-menu/root.tsx
Normal file
157
packages/ui/src/dropdowns/context-menu/root.tsx
Normal 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;
|
||||||
|
};
|
@ -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";
|
||||||
|
@ -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>
|
||||||
|
2
web/components/core/list/index.ts
Normal file
2
web/components/core/list/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./list-item";
|
||||||
|
export * from "./list-root";
|
56
web/components/core/list/list-item.tsx
Normal file
56
web/components/core/list/list-item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
10
web/components/core/list/list-root.tsx
Normal file
10
web/components/core/list/list-root.tsx
Normal 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>;
|
||||||
|
};
|
@ -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()}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
|
156
web/components/cycles/list/cycle-list-item-action.tsx
Normal file
156
web/components/cycles/list/cycle-list-item-action.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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,139 +76,47 @@ 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={
|
||||||
}}
|
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
|
||||||
>
|
{isCompleted ? (
|
||||||
<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">
|
progress === 100 ? (
|
||||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||||
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
) : (
|
||||||
<div className="flex-shrink-0">
|
<span className="text-sm text-custom-primary-100">{`!`}</span>
|
||||||
<CircularProgressIndicator size={38} percentage={progress}>
|
)
|
||||||
{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" />
|
) : (
|
||||||
) : (
|
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||||
<span className="text-sm text-custom-primary-100">{`!`}</span>
|
|
||||||
)
|
|
||||||
) : progress === 100 ? (
|
|
||||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
|
||||||
)}
|
|
||||||
</CircularProgressIndicator>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex items-center gap-2.5 overflow-hidden">
|
|
||||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
|
|
||||||
<Tooltip tooltipContent={cycleDetails.name} position="top" isMobile={isMobile}>
|
|
||||||
<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" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-custom-text-300 flex-shrink-0">
|
|
||||||
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
|
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
</CircularProgressIndicator>
|
||||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
}
|
||||||
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
|
appendTitleElement={
|
||||||
<div className="flex w-10 cursor-default items-center justify-center">
|
<button
|
||||||
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
|
onClick={openCycleOverview}
|
||||||
<AvatarGroup showTooltip={false}>
|
className={`z-[5] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
|
||||||
{cycleDetails.assignee_ids?.map((assignee_id) => {
|
>
|
||||||
const member = getUserDetails(assignee_id);
|
<Info className="h-4 w-4 text-custom-text-400" />
|
||||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
</button>
|
||||||
})}
|
}
|
||||||
</AvatarGroup>
|
actionableItems={
|
||||||
) : (
|
<CycleListItemAction
|
||||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
workspaceSlug={workspaceSlug}
|
||||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
projectId={projectId}
|
||||||
</span>
|
cycleId={cycleId}
|
||||||
)}
|
cycleDetails={cycleDetails}
|
||||||
</div>
|
parentRef={parentRef}
|
||||||
</Tooltip>
|
/>
|
||||||
|
}
|
||||||
{isEditingAllowed && !isArchived && (
|
isMobile={isMobile}
|
||||||
<FavoriteStar
|
parentRef={parentRef}
|
||||||
onClick={(e) => {
|
/>
|
||||||
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
|
|
||||||
else handleAddToFavorites(e);
|
|
||||||
}}
|
|
||||||
selected={!!cycleDetails.is_favorite}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<CycleQuickActions
|
|
||||||
cycleId={cycleId}
|
|
||||||
projectId={projectId}
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
isArchived={isArchived}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
|
@ -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();
|
||||||
{isEditingAllowed && !isArchived && (
|
e.stopPropagation();
|
||||||
<CustomMenu.MenuItem onClick={handleArchiveCycle} disabled={!isCompleted}>
|
item.action();
|
||||||
{isCompleted ? (
|
}}
|
||||||
<div className="flex items-center gap-2">
|
className={cn(
|
||||||
<ArchiveIcon className="h-3 w-3" />
|
"flex items-center gap-2",
|
||||||
Archive cycle
|
{
|
||||||
</div>
|
"text-custom-text-400": item.disabled,
|
||||||
) : (
|
},
|
||||||
<div className="flex items-start gap-2">
|
item.className
|
||||||
<ArchiveIcon className="h-3 w-3" />
|
)}
|
||||||
<div className="-mt-1">
|
>
|
||||||
<p>Archive cycle</p>
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
<p className="text-xs text-custom-text-400">
|
<div>
|
||||||
Only completed cycle <br /> can be archived.
|
<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>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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)}
|
||||||
|
@ -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)}
|
||||||
|
@ -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,52 +29,48 @@ 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}>
|
<Breadcrumbs.BreadcrumbItem
|
||||||
<Breadcrumbs.BreadcrumbItem
|
type="text"
|
||||||
type="text"
|
link={
|
||||||
link={
|
<BreadcrumbLink
|
||||||
<BreadcrumbLink
|
label={currentProjectDetails?.name ?? "Project"}
|
||||||
label={currentProjectDetails?.name ?? "Project"}
|
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
icon={
|
||||||
icon={
|
currentProjectDetails && (
|
||||||
currentProjectDetails && (
|
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||||
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
|
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
|
||||||
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
|
</span>
|
||||||
</span>
|
)
|
||||||
)
|
}
|
||||||
}
|
/>
|
||||||
/>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
<Breadcrumbs.BreadcrumbItem
|
||||||
<Breadcrumbs.BreadcrumbItem
|
type="text"
|
||||||
type="text"
|
link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />}
|
||||||
link={
|
/>
|
||||||
<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />
|
</Breadcrumbs>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Breadcrumbs>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{canUserCreateCycle && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
prependIcon={<Plus />}
|
|
||||||
onClick={() => {
|
|
||||||
setTrackElement("Cycles page");
|
|
||||||
toggleCreateCycleModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="hidden sm:block">Add</div> Cycle
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{canUserCreateCycle && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
prependIcon={<Plus />}
|
||||||
|
onClick={() => {
|
||||||
|
setTrackElement("Cycles page");
|
||||||
|
toggleCreateCycleModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="hidden sm:block">Add</div> Cycle
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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"
|
||||||
|
@ -114,124 +114,121 @@ 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()}>
|
||||||
<Breadcrumbs.BreadcrumbItem
|
<Breadcrumbs.BreadcrumbItem
|
||||||
type="text"
|
type="text"
|
||||||
link={
|
link={
|
||||||
<BreadcrumbLink
|
<BreadcrumbLink
|
||||||
href={`/${workspaceSlug}/projects`}
|
href={`/${workspaceSlug}/projects`}
|
||||||
label={currentProjectDetails?.name ?? "Project"}
|
label={currentProjectDetails?.name ?? "Project"}
|
||||||
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 className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
|
||||||
<Briefcase className="h-4 w-4" />
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
) : (
|
||||||
/>
|
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||||
}
|
<Briefcase className="h-4 w-4" />
|
||||||
/>
|
</span>
|
||||||
|
)
|
||||||
<Breadcrumbs.BreadcrumbItem
|
}
|
||||||
type="text"
|
/>
|
||||||
link={
|
|
||||||
<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Breadcrumbs>
|
|
||||||
{issueCount && issueCount > 0 ? (
|
|
||||||
<Tooltip
|
|
||||||
isMobile={isMobile}
|
|
||||||
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in this project`}
|
|
||||||
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">
|
|
||||||
{issueCount}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{currentProjectDetails?.is_deployed && deployUrl && (
|
|
||||||
<a
|
|
||||||
href={`${deployUrl}/${workspaceSlug}/${currentProjectDetails?.id}`}
|
|
||||||
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
|
|
||||||
Public
|
|
||||||
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="hidden items-center gap-2 md:flex">
|
|
||||||
<LayoutSelection
|
|
||||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
|
||||||
onChange={(layout) => handleLayoutChange(layout)}
|
|
||||||
selectedLayout={activeLayout}
|
|
||||||
/>
|
|
||||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
|
||||||
<FilterSelection
|
|
||||||
filters={issueFilters?.filters ?? {}}
|
|
||||||
handleFiltersUpdate={handleFiltersUpdate}
|
|
||||||
layoutDisplayFiltersOptions={
|
|
||||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
|
||||||
}
|
}
|
||||||
labels={projectLabels}
|
|
||||||
memberIds={projectMemberIds ?? undefined}
|
|
||||||
states={projectStates}
|
|
||||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
|
||||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
|
||||||
/>
|
/>
|
||||||
</FiltersDropdown>
|
|
||||||
<FiltersDropdown title="Display" placement="bottom-end">
|
|
||||||
<DisplayFiltersSelection
|
|
||||||
layoutDisplayFiltersOptions={
|
|
||||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
|
||||||
}
|
|
||||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
|
||||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
|
||||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
|
||||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
|
||||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
|
||||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
|
||||||
/>
|
|
||||||
</FiltersDropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canUserCreateIssue && (
|
<Breadcrumbs.BreadcrumbItem
|
||||||
<>
|
type="text"
|
||||||
<Button
|
link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
|
||||||
className="hidden md:block"
|
/>
|
||||||
onClick={() => setAnalyticsModal(true)}
|
</Breadcrumbs>
|
||||||
variant="neutral-primary"
|
{issueCount && issueCount > 0 ? (
|
||||||
size="sm"
|
<Tooltip
|
||||||
|
isMobile={isMobile}
|
||||||
|
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in this project`}
|
||||||
|
position="bottom"
|
||||||
>
|
>
|
||||||
Analytics
|
<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">
|
||||||
</Button>
|
{issueCount}
|
||||||
<Button
|
</span>
|
||||||
onClick={() => {
|
</Tooltip>
|
||||||
setTrackElement("Project issues page");
|
) : null}
|
||||||
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
|
</div>
|
||||||
}}
|
{currentProjectDetails?.is_deployed && deployUrl && (
|
||||||
size="sm"
|
<a
|
||||||
prependIcon={<Plus />}
|
href={`${deployUrl}/${workspaceSlug}/${currentProjectDetails?.id}`}
|
||||||
>
|
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
|
||||||
<div className="hidden sm:block">Add</div> Issue
|
target="_blank"
|
||||||
</Button>
|
rel="noopener noreferrer"
|
||||||
</>
|
>
|
||||||
|
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
|
||||||
|
Public
|
||||||
|
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="items-center gap-2 hidden md:flex">
|
||||||
|
<LayoutSelection
|
||||||
|
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||||
|
onChange={(layout) => handleLayoutChange(layout)}
|
||||||
|
selectedLayout={activeLayout}
|
||||||
|
/>
|
||||||
|
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||||
|
<FilterSelection
|
||||||
|
filters={issueFilters?.filters ?? {}}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
layoutDisplayFiltersOptions={
|
||||||
|
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||||
|
}
|
||||||
|
labels={projectLabels}
|
||||||
|
memberIds={projectMemberIds ?? undefined}
|
||||||
|
states={projectStates}
|
||||||
|
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||||
|
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||||
|
/>
|
||||||
|
</FiltersDropdown>
|
||||||
|
<FiltersDropdown title="Display" placement="bottom-end">
|
||||||
|
<DisplayFiltersSelection
|
||||||
|
layoutDisplayFiltersOptions={
|
||||||
|
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||||
|
}
|
||||||
|
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||||
|
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||||
|
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||||
|
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||||
|
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||||
|
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||||
|
/>
|
||||||
|
</FiltersDropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canUserCreateIssue && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="hidden md:block"
|
||||||
|
onClick={() => setAnalyticsModal(true)}
|
||||||
|
variant="neutral-primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Analytics
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setTrackElement("Project issues page");
|
||||||
|
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
prependIcon={<Plus />}
|
||||||
|
>
|
||||||
|
<div className="hidden sm:block">Add</div> Issue
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
|
@ -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}>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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">
|
||||||
|
@ -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={() => {
|
||||||
|
@ -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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
@ -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)}
|
||||||
|
@ -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,
|
||||||
|
@ -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?: (
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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;
|
||||||
|
@ -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?: (
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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)}
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
||||||
|
@ -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)}
|
||||||
|
@ -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">
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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) => {
|
||||||
<CustomMenu.MenuItem
|
if (item.shouldRender === false) return null;
|
||||||
onClick={() => {
|
return (
|
||||||
setTrackElement("Global issues");
|
<CustomMenu.MenuItem
|
||||||
setIssueToEdit(issue);
|
key={item.key}
|
||||||
setCreateUpdateIssueModal(true);
|
onClick={(e) => {
|
||||||
}}
|
e.preventDefault();
|
||||||
>
|
e.stopPropagation();
|
||||||
<div className="flex items-center gap-2">
|
item.action();
|
||||||
<Pencil className="h-3 w-3" />
|
}}
|
||||||
Edit
|
className={cn(
|
||||||
</div>
|
"flex items-center gap-2",
|
||||||
</CustomMenu.MenuItem>
|
{
|
||||||
)}
|
"text-custom-text-400": item.disabled,
|
||||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
},
|
||||||
<div className="flex items-center gap-2">
|
item.className
|
||||||
<ExternalLink className="h-3 w-3" />
|
)}
|
||||||
Open in new tab
|
>
|
||||||
</div>
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
</CustomMenu.MenuItem>
|
<div>
|
||||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
<h5>{item.title}</h5>
|
||||||
<div className="flex items-center gap-2">
|
{item.description && (
|
||||||
<Link className="h-3 w-3" />
|
<p
|
||||||
Copy link
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
</div>
|
"text-custom-text-400": item.disabled,
|
||||||
</CustomMenu.MenuItem>
|
})}
|
||||||
{isEditingAllowed && (
|
>
|
||||||
<CustomMenu.MenuItem
|
{item.description}
|
||||||
onClick={() => {
|
|
||||||
setTrackElement("Global issues");
|
|
||||||
setCreateUpdateIssueModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
Make a copy
|
|
||||||
</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>
|
</div>
|
||||||
)}
|
</CustomMenu.MenuItem>
|
||||||
</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>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
)}
|
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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" />
|
<CustomMenu.MenuItem
|
||||||
Restore
|
key={item.key}
|
||||||
</div>
|
onClick={(e) => {
|
||||||
</CustomMenu.MenuItem>
|
e.preventDefault();
|
||||||
)}
|
e.stopPropagation();
|
||||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
item.action();
|
||||||
<div className="flex items-center gap-2">
|
}}
|
||||||
<ExternalLink className="h-3 w-3" />
|
className={cn(
|
||||||
Open in new tab
|
"flex items-center gap-2",
|
||||||
</div>
|
{
|
||||||
</CustomMenu.MenuItem>
|
"text-custom-text-400": item.disabled,
|
||||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
},
|
||||||
<div className="flex items-center gap-2">
|
item.className
|
||||||
<Link className="h-3 w-3" />
|
)}
|
||||||
Copy link
|
>
|
||||||
</div>
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
</CustomMenu.MenuItem>
|
<div>
|
||||||
{isEditingAllowed && (
|
<h5>{item.title}</h5>
|
||||||
<CustomMenu.MenuItem
|
{item.description && (
|
||||||
onClick={() => {
|
<p
|
||||||
setTrackElement(activeLayout);
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
setDeleteIssueModal(true);
|
"text-custom-text-400": item.disabled,
|
||||||
}}
|
})}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
{item.description}
|
||||||
<Trash2 className="h-3 w-3" />
|
</p>
|
||||||
Delete issue
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
);
|
||||||
|
})}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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) => {
|
||||||
<CustomMenu.MenuItem
|
if (item.shouldRender === false) return null;
|
||||||
onClick={() => {
|
return (
|
||||||
setIssueToEdit({
|
<CustomMenu.MenuItem
|
||||||
...issue,
|
key={item.key}
|
||||||
cycle_id: cycleId?.toString() ?? null,
|
onClick={(e) => {
|
||||||
});
|
e.preventDefault();
|
||||||
setTrackElement(activeLayout);
|
e.stopPropagation();
|
||||||
setCreateUpdateIssueModal(true);
|
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">
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
<ExternalLink className="h-3 w-3" />
|
<div>
|
||||||
Open in new tab
|
<h5>{item.title}</h5>
|
||||||
</div>
|
{item.description && (
|
||||||
</CustomMenu.MenuItem>
|
<p
|
||||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
<div className="flex items-center gap-2">
|
"text-custom-text-400": item.disabled,
|
||||||
<Link className="h-3 w-3" />
|
})}
|
||||||
Copy link
|
>
|
||||||
</div>
|
{item.description}
|
||||||
</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 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>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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) => {
|
||||||
<CustomMenu.MenuItem
|
if (item.shouldRender === false) return null;
|
||||||
onClick={() => {
|
return (
|
||||||
setTrackElement(activeLayout);
|
<CustomMenu.MenuItem
|
||||||
setIssueToEdit(issue);
|
key={item.key}
|
||||||
setCreateUpdateIssueModal(true);
|
onClick={(e) => {
|
||||||
}}
|
e.preventDefault();
|
||||||
>
|
e.stopPropagation();
|
||||||
<div className="flex items-center gap-2">
|
item.action();
|
||||||
<Pencil className="h-3 w-3" />
|
}}
|
||||||
Edit
|
className={cn(
|
||||||
</div>
|
"flex items-center gap-2",
|
||||||
</CustomMenu.MenuItem>
|
{
|
||||||
)}
|
"text-custom-text-400": item.disabled,
|
||||||
{isDeletingAllowed && (
|
},
|
||||||
<CustomMenu.MenuItem
|
item.className
|
||||||
onClick={() => {
|
)}
|
||||||
setTrackElement(activeLayout);
|
>
|
||||||
setDeleteIssueModal(true);
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
}}
|
<div>
|
||||||
>
|
<h5>{item.title}</h5>
|
||||||
<div className="flex items-center gap-2">
|
{item.description && (
|
||||||
<Trash2 className="h-3 w-3" />
|
<p
|
||||||
Delete
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
</div>
|
"text-custom-text-400": item.disabled,
|
||||||
</CustomMenu.MenuItem>
|
})}
|
||||||
)}
|
>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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()] : [] });
|
<CustomMenu.MenuItem
|
||||||
setTrackElement(activeLayout);
|
key={item.key}
|
||||||
setCreateUpdateIssueModal(true);
|
onClick={(e) => {
|
||||||
}}
|
e.preventDefault();
|
||||||
>
|
e.stopPropagation();
|
||||||
<div className="flex items-center gap-2">
|
item.action();
|
||||||
<Pencil className="h-3 w-3" />
|
}}
|
||||||
Edit
|
className={cn(
|
||||||
</div>
|
"flex items-center gap-2",
|
||||||
</CustomMenu.MenuItem>
|
{
|
||||||
)}
|
"text-custom-text-400": item.disabled,
|
||||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
},
|
||||||
<div className="flex items-center gap-2">
|
item.className
|
||||||
<ExternalLink className="h-3 w-3" />
|
)}
|
||||||
Open in new tab
|
>
|
||||||
</div>
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
</CustomMenu.MenuItem>
|
<div>
|
||||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
<h5>{item.title}</h5>
|
||||||
<div className="flex items-center gap-2">
|
{item.description && (
|
||||||
<Link className="h-3 w-3" />
|
<p
|
||||||
Copy link
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
</div>
|
"text-custom-text-400": item.disabled,
|
||||||
</CustomMenu.MenuItem>
|
})}
|
||||||
{isEditingAllowed && (
|
>
|
||||||
<CustomMenu.MenuItem
|
{item.description}
|
||||||
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>
|
</p>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CustomMenu.MenuItem>
|
||||||
</CustomMenu.MenuItem>
|
);
|
||||||
)}
|
})}
|
||||||
{isDeletingAllowed && (
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setTrackElement(activeLayout);
|
|
||||||
setDeleteIssueModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
Delete
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
)}
|
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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) => {
|
||||||
<CustomMenu.MenuItem
|
if (item.shouldRender === false) return null;
|
||||||
onClick={() => {
|
return (
|
||||||
setTrackElement(activeLayout);
|
<CustomMenu.MenuItem
|
||||||
setIssueToEdit(issue);
|
key={item.key}
|
||||||
setCreateUpdateIssueModal(true);
|
onClick={(e) => {
|
||||||
}}
|
e.preventDefault();
|
||||||
>
|
e.stopPropagation();
|
||||||
<div className="flex items-center gap-2">
|
item.action();
|
||||||
<Pencil className="h-3 w-3" />
|
}}
|
||||||
Edit
|
className={cn(
|
||||||
</div>
|
"flex items-center gap-2",
|
||||||
</CustomMenu.MenuItem>
|
{
|
||||||
)}
|
"text-custom-text-400": item.disabled,
|
||||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
},
|
||||||
<div className="flex items-center gap-2">
|
item.className
|
||||||
<ExternalLink className="h-3 w-3" />
|
)}
|
||||||
Open in new tab
|
>
|
||||||
</div>
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
</CustomMenu.MenuItem>
|
<div>
|
||||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
<h5>{item.title}</h5>
|
||||||
<div className="flex items-center gap-2">
|
{item.description && (
|
||||||
<Link className="h-3 w-3" />
|
<p
|
||||||
Copy link
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
</div>
|
"text-custom-text-400": item.disabled,
|
||||||
</CustomMenu.MenuItem>
|
})}
|
||||||
{isEditingAllowed && (
|
>
|
||||||
<CustomMenu.MenuItem
|
{item.description}
|
||||||
onClick={() => {
|
|
||||||
setTrackElement(activeLayout);
|
|
||||||
setCreateUpdateIssueModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
Make a copy
|
|
||||||
</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>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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]
|
||||||
|
@ -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]
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
@ -218,7 +210,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
|||||||
>
|
>
|
||||||
<div className="relative flex cursor-pointer items-center text-center text-xs hover:text-custom-text-100">
|
<div className="relative flex cursor-pointer items-center text-center text-xs hover:text-custom-text-100">
|
||||||
<span
|
<span
|
||||||
className={`flex items-center justify-center font-medium group-hover:opacity-0 ${
|
className={`flex items-center justify-center font-medium group-hover:opacity-0 ${
|
||||||
isMenuActive ? "opacity-0" : "opacity-100"
|
isMenuActive ? "opacity-0" : "opacity-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -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>
|
||||||
|
|
||||||
|
@ -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>;
|
||||||
|
@ -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?: (
|
||||||
|
@ -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";
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -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;
|
||||||
|
@ -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 <></>;
|
||||||
|
@ -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 && (
|
||||||
|
@ -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
|
||||||
|
@ -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";
|
||||||
|
@ -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()}
|
||||||
|
168
web/components/modules/module-list-item-action.tsx
Normal file
168
web/components/modules/module-list-item-action.tsx
Normal 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()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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,126 +59,41 @@ 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={
|
||||||
}}
|
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
|
||||||
>
|
{completedModuleCheck ? (
|
||||||
<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">
|
progress === 100 ? (
|
||||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||||
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
) : (
|
||||||
<div className="flex items-center gap-4 truncate">
|
<span className="text-sm text-custom-primary-100">{`!`}</span>
|
||||||
<span className="flex-shrink-0">
|
)
|
||||||
<CircularProgressIndicator size={38} percentage={progress}>
|
) : progress === 100 ? (
|
||||||
{completedModuleCheck ? (
|
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||||
progress === 100 ? (
|
) : (
|
||||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||||
) : (
|
|
||||||
<span className="text-sm text-custom-primary-100">{`!`}</span>
|
|
||||||
)
|
|
||||||
) : progress === 100 ? (
|
|
||||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
|
||||||
)}
|
|
||||||
</CircularProgressIndicator>
|
|
||||||
</span>
|
|
||||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
|
||||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<button onClick={openModuleOverview} className="z-[5] hidden flex-shrink-0 group-hover:flex">
|
|
||||||
<Info className="h-4 w-4 text-custom-text-400" />
|
|
||||||
</button>
|
|
||||||
</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="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>
|
</CircularProgressIndicator>
|
||||||
<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">
|
appendTitleElement={
|
||||||
{renderDate && (
|
<button
|
||||||
<span className=" text-xs text-custom-text-300">
|
onClick={openModuleOverview}
|
||||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
className={`z-[5] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
|
||||||
</span>
|
>
|
||||||
)}
|
<Info className="h-4 w-4 text-custom-text-400" />
|
||||||
</div>
|
</button>
|
||||||
|
}
|
||||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
actionableItems={
|
||||||
<Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`} isMobile={isMobile}>
|
<ModuleListItemAction moduleId={moduleId} moduleDetails={moduleDetails} parentRef={parentRef} />
|
||||||
<div className="flex w-10 cursor-default items-center justify-center gap-1">
|
}
|
||||||
{moduleDetails.member_ids.length > 0 ? (
|
isMobile={isMobile}
|
||||||
<AvatarGroup showTooltip={false}>
|
parentRef={parentRef}
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
178
web/components/modules/module-view-header.tsx
Normal file
178
web/components/modules/module-view-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -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() ?? ""}
|
||||||
|
@ -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();
|
||||||
{isEditingAllowed && !isArchived && (
|
e.stopPropagation();
|
||||||
<CustomMenu.MenuItem onClick={handleArchiveModule} disabled={!isInArchivableGroup}>
|
item.action();
|
||||||
{isInArchivableGroup ? (
|
}}
|
||||||
<div className="flex items-center gap-2">
|
className={cn(
|
||||||
<ArchiveIcon className="h-3 w-3" />
|
"flex items-center gap-2",
|
||||||
Archive module
|
{
|
||||||
</div>
|
"text-custom-text-400": item.disabled,
|
||||||
) : (
|
},
|
||||||
<div className="flex items-start gap-2">
|
item.className
|
||||||
<ArchiveIcon className="h-3 w-3" />
|
)}
|
||||||
<div className="-mt-1">
|
>
|
||||||
<p>Archive module</p>
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
<p className="text-xs text-custom-text-400">
|
<div>
|
||||||
Only completed or cancelled <br /> module can be archived.
|
<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>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -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) => {
|
||||||
|
@ -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>
|
||||||
|
95
web/components/pages/list/block-item-action.tsx
Normal file
95
web/components/pages/list/block-item-action.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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" />
|
||||||
|
|
||||||
|
@ -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";
|
||||||
|
126
web/components/views/quick-actions.tsx
Normal file
126
web/components/views/quick-actions.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
89
web/components/views/view-list-item-action.tsx
Normal file
89
web/components/views/view-list-item-action.tsx
Normal 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()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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 && (
|
||||||
placeholder="Search"
|
<button
|
||||||
mode="true-transparent"
|
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) => 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>
|
</div>
|
||||||
<div className="vertical-scrollbar scrollbar-lg flex h-full w-full flex-col">
|
<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)} />
|
||||||
|
@ -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,
|
||||||
|
@ -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";
|
||||||
|
};
|
||||||
|
@ -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
|
||||||
|
@ -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,17 +34,20 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
|
|||||||
|
|
||||||
if (!workspaceSlug || !projectId) return <></>;
|
if (!workspaceSlug || !projectId) return <></>;
|
||||||
return (
|
return (
|
||||||
<PagesListView
|
<>
|
||||||
workspaceSlug={workspaceSlug.toString()}
|
<PageHead title={pageTitle} />
|
||||||
projectId={projectId.toString()}
|
<PagesListView
|
||||||
pageType={currentPageType()}
|
|
||||||
>
|
|
||||||
<PagesListRoot
|
|
||||||
pageType={currentPageType()}
|
|
||||||
workspaceSlug={workspaceSlug.toString()}
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
projectId={projectId.toString()}
|
projectId={projectId.toString()}
|
||||||
/>
|
pageType={currentPageType()}
|
||||||
</PagesListView>
|
>
|
||||||
|
<PagesListRoot
|
||||||
|
pageType={currentPageType()}
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
projectId={projectId.toString()}
|
||||||
|
/>
|
||||||
|
</PagesListView>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user