mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'chore-admin-file-structure' of gurusainath:makeplane/plane into chore-admin-file-structure
This commit is contained in:
commit
da16e686bf
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:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
- "sync/**"
|
||||
|
||||
env:
|
||||
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
|
||||
CURRENT_BRANCH: ${{ github.ref_name }}
|
||||
SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
|
||||
TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
|
||||
REVIEWER: ${{ vars.SYNC_PR_REVIEWER }}
|
||||
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
|
||||
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
|
||||
|
||||
jobs:
|
||||
sync_changes:
|
||||
runs-on: ubuntu-20.04
|
||||
Check_Branch:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
|
||||
steps:
|
||||
- name: Check if current branch matches the secret
|
||||
id: check-branch
|
||||
run: |
|
||||
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
|
||||
echo "MATCH=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "MATCH=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
Auto_Merge:
|
||||
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
|
||||
needs: [Check_Branch]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-depth: 0 # Fetch all history for all branches and tags
|
||||
|
||||
- name: Setup GH CLI
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config user.name "$ACCOUNT_USER_NAME"
|
||||
git config user.email "$ACCOUNT_USER_EMAIL"
|
||||
|
||||
- name: Setup GH CLI and Git Config
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
@ -31,25 +56,14 @@ jobs:
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Push Changes to Target Repo A
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
- name: Create PR to Target Branch
|
||||
run: |
|
||||
TARGET_REPO="${{ secrets.TARGET_REPO_A }}"
|
||||
TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
|
||||
|
||||
- name: Push Changes to Target Repo B
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ secrets.TARGET_REPO_B }}"
|
||||
TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH
|
||||
# get all pull requests and check if there is already a PR
|
||||
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number')
|
||||
if [ -n "$PR_EXISTS" ]; then
|
||||
echo "Pull Request already exists: $PR_EXISTS"
|
||||
else
|
||||
echo "Creating new pull request"
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "")
|
||||
echo "Pull Request created: $PR_URL"
|
||||
fi
|
||||
|
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
|
@ -113,6 +113,7 @@ export const InstanceSignInForm: FC = (props) => {
|
||||
placeholder="name@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -39,6 +39,7 @@ type TFormData = {
|
||||
email: string;
|
||||
company_name: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
is_telemetry_enabled: boolean;
|
||||
};
|
||||
|
||||
@ -66,6 +67,7 @@ export const InstanceSignUpForm: FC = (props) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [formData, setFormData] = useState<TFormData>(defaultFromData);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
|
||||
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
@ -107,7 +109,11 @@ export const InstanceSignUpForm: FC = (props) => {
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() =>
|
||||
formData.first_name && formData.email && formData.password && getPasswordStrength(formData.password) >= 3
|
||||
formData.first_name &&
|
||||
formData.email &&
|
||||
formData.password &&
|
||||
getPasswordStrength(formData.password) >= 3 &&
|
||||
formData.password === formData.confirm_password
|
||||
? false
|
||||
: true,
|
||||
[formData]
|
||||
@ -144,6 +150,7 @@ export const InstanceSignUpForm: FC = (props) => {
|
||||
placeholder="Wilber"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleFormChange("first_name", e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1">
|
||||
@ -214,6 +221,8 @@ export const InstanceSignUpForm: FC = (props) => {
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
/>
|
||||
{showPassword ? (
|
||||
<button
|
||||
@ -236,21 +245,53 @@ export const InstanceSignUpForm: FC = (props) => {
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
|
||||
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
|
||||
)}
|
||||
<PasswordStrengthMeter password={formData.password} />
|
||||
{isPasswordInputFocused && <PasswordStrengthMeter password={formData.password} />}
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">
|
||||
Confirm password
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="confirm_password"
|
||||
value={formData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!formData.confirm_password && formData.password !== formData.confirm_password && (
|
||||
<span className="text-sm text-red-500">Passwords don{"'"}t match</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center pt-2 gap-2">
|
||||
<Checkbox
|
||||
id="is_telemetry_enabled"
|
||||
name="is_telemetry_enabled"
|
||||
value={formData.is_telemetry_enabled ? "True" : "False"}
|
||||
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
|
||||
checked={formData.is_telemetry_enabled}
|
||||
/>
|
||||
<div>
|
||||
<Checkbox
|
||||
id="is_telemetry_enabled"
|
||||
name="is_telemetry_enabled"
|
||||
value={formData.is_telemetry_enabled ? "True" : "False"}
|
||||
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
|
||||
checked={formData.is_telemetry_enabled}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-sm text-custom-text-300 font-medium cursor-pointer" htmlFor="is_telemetry_enabled">
|
||||
Allow Plane to anonymously collect usage events.
|
||||
</label>
|
||||
<a href="#" className="text-sm font-medium text-blue-500 hover:text-blue-600">
|
||||
<a href="https://docs.plane.so/telemetry" className="text-sm font-medium text-blue-500 hover:text-blue-600">
|
||||
See More
|
||||
</a>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { FC } from "react";
|
||||
import { AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
|
||||
type TBanner = {
|
||||
type: "success" | "error";
|
||||
@ -10,19 +10,21 @@ export const Banner: FC<TBanner> = (props) => {
|
||||
const { type, message } = props;
|
||||
|
||||
return (
|
||||
<div className={`rounded-md p-4 w-full ${type === "error" ? "bg-red-50" : "bg-green-50"}`}>
|
||||
<div
|
||||
className={`rounded-md p-2 w-full border ${type === "error" ? "bg-red-500/5 border-red-400" : "bg-green-500/5 border-green-400"}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex-shrink-0">
|
||||
{type === "error" ? (
|
||||
<span className="flex items-center justify-center h-6 w-6 bg-red-500 rounded-full">
|
||||
<AlertCircle className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
<span className="flex items-center justify-center h-6 w-6 rounded-full">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<CheckCircle className="h-5 w-5 text-green-400" aria-hidden="true" />
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm font-medium ${type === "error" ? "text-red-800" : "text-green-800"} `}>{message}</p>
|
||||
<div className="ml-1">
|
||||
<p className={`text-sm font-medium ${type === "error" ? "text-red-600" : "text-green-600"}`}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
// logo
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||
|
||||
@ -12,21 +11,16 @@ type TDefaultLayout = {
|
||||
|
||||
export const DefaultLayout: FC<TDefaultLayout> = (props) => {
|
||||
const { children } = props;
|
||||
const pathname = usePathname();
|
||||
|
||||
console.log("pathname", pathname);
|
||||
|
||||
return (
|
||||
<div className="relative h-screen max-h-max w-full overflow-hidden overflow-y-auto flex flex-col">
|
||||
<div className="flex-shrink-0 h-[120px]">
|
||||
<div className="relative h-full container mx-auto px-5 lg:px-0 flex items-center justify-between gap-5 z-50">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
|
||||
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
||||
</div>
|
||||
<div className="h-screen w-full overflow-hidden overflow-y-auto flex flex-col">
|
||||
<div className="container h-[100px] flex-shrink-0 mx-auto px-5 lg:px-0 flex items-center justify-between gap-5 z-50">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
|
||||
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex-grow">{children}</div>
|
||||
<div className="w-full px-5 lg:px-0 mb-[100px] flex-grow">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -52,6 +52,7 @@ export const InstanceWrapper: FC<TInstanceWrapper> = observer((props) => {
|
||||
);
|
||||
|
||||
if (instance?.instance?.is_setup_done && pageType === EInstancePageType.PRE_SETUP) redirect("/");
|
||||
|
||||
if (!instance?.instance?.is_setup_done && pageType === EInstancePageType.POST_SETUP) redirect("/setup");
|
||||
|
||||
return <>{children}</>;
|
||||
|
BIN
admin/public/favicon/android-chrome-192x192.png
Normal file
BIN
admin/public/favicon/android-chrome-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
BIN
admin/public/favicon/android-chrome-512x512.png
Normal file
BIN
admin/public/favicon/android-chrome-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
BIN
admin/public/favicon/apple-touch-icon.png
Normal file
BIN
admin/public/favicon/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
BIN
admin/public/favicon/favicon-16x16.png
Normal file
BIN
admin/public/favicon/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
admin/public/favicon/favicon-32x32.png
Normal file
BIN
admin/public/favicon/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
admin/public/favicon/favicon.ico
Normal file
BIN
admin/public/favicon/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 919 B |
1
admin/public/favicon/site.webmanifest
Normal file
1
admin/public/favicon/site.webmanifest
Normal file
@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
@ -29,8 +29,8 @@ class SignInAuthEndpoint(View):
|
||||
if instance is None or not instance.is_setup_done:
|
||||
# Redirection params
|
||||
params = {
|
||||
"error_code": "REQUIRED_EMAIL_PASSWORD",
|
||||
"error_message": "Both email and password are required",
|
||||
"error_code": "INSTANCE_NOT_CONFIGURED",
|
||||
"error_message": "Instance is not configured",
|
||||
}
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
|
@ -26,7 +26,7 @@ from plane.authentication.utils.workspace_project_join import (
|
||||
from plane.bgtasks.magic_link_code_task import magic_link
|
||||
from plane.license.models import Instance
|
||||
from plane.authentication.utils.host import base_host
|
||||
from plane.db.models import User
|
||||
from plane.db.models import User, Profile
|
||||
|
||||
|
||||
class MagicGenerateEndpoint(APIView):
|
||||
@ -123,11 +123,12 @@ class MagicSignInEndpoint(View):
|
||||
request=request, key=f"magic_{email}", code=code
|
||||
)
|
||||
user = provider.authenticate()
|
||||
profile = Profile.objects.get(user=user)
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user)
|
||||
# Process workspace and project invitations
|
||||
process_workspace_project_invitations(user=user)
|
||||
if user.is_password_autoset:
|
||||
if user.is_password_autoset and profile.is_onboarded:
|
||||
path = "accounts/set-password"
|
||||
else:
|
||||
# Get the redirection path
|
||||
|
@ -22,7 +22,7 @@ from plane.authentication.utils.login import user_login
|
||||
from plane.bgtasks.magic_link_code_task import magic_link
|
||||
from plane.license.models import Instance
|
||||
from plane.authentication.utils.host import base_host
|
||||
from plane.db.models import User
|
||||
from plane.db.models import User, Profile
|
||||
|
||||
|
||||
class MagicGenerateSpaceEndpoint(APIView):
|
||||
@ -122,9 +122,18 @@ class MagicSignInSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user)
|
||||
# redirect to referer path
|
||||
if user.is_password_autoset and profile.is_onboarded:
|
||||
path = "spaces/accounts/set-password"
|
||||
else:
|
||||
# Get the redirection path
|
||||
path = (
|
||||
str(next_path)
|
||||
if next_path
|
||||
else "spaces"
|
||||
)
|
||||
url = urljoin(
|
||||
base_host(request=request),
|
||||
str(next_path) if next_path else "spaces",
|
||||
path
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
@ -34,7 +34,7 @@ interface CustomEditorProps {
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
|
@ -43,7 +43,7 @@ type TArguments = {
|
||||
cancelUploadImage?: () => void;
|
||||
uploadFile: UploadImage;
|
||||
};
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
@ -147,7 +147,7 @@ export const CoreEditorExtensions = ({
|
||||
|
||||
if (placeholder) {
|
||||
if (typeof placeholder === "string") return placeholder;
|
||||
else return placeholder(editor.isFocused);
|
||||
else return placeholder(editor.isFocused, editor.getHTML());
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
|
@ -31,7 +31,7 @@ interface IDocumentEditor {
|
||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
tabIndex?: number;
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
}
|
||||
|
||||
const DocumentEditor = (props: IDocumentEditor) => {
|
||||
|
@ -32,7 +32,7 @@ export interface ILiteTextEditor {
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
tabIndex?: number;
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
}
|
||||
|
||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
|
@ -35,7 +35,7 @@ export type IRichTextEditor = {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
|
4
packages/types/src/auth.d.ts
vendored
4
packages/types/src/auth.d.ts
vendored
@ -26,6 +26,6 @@ export interface IPasswordSignInData {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ICsrfTokenData {
|
||||
export interface ICsrfTokenData {
|
||||
csrf_token: string;
|
||||
};
|
||||
}
|
||||
|
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-select";
|
||||
export * from "./custom-search-select";
|
||||
|
@ -180,7 +180,7 @@ export const PasswordForm: React.FC<Props> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
{!!passwordFormData.confirm_password && passwordFormData.password !== passwordFormData.confirm_password && (
|
||||
<span className="text-sm text-red-500">Password doesn{"'"}t match</span>
|
||||
<span className="text-sm text-red-500">Passwords don{"'"}t match</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
@ -26,43 +26,28 @@ type TTitle = {
|
||||
};
|
||||
|
||||
type THeaderSubheader = {
|
||||
[mode in EAuthModes]: {
|
||||
[step in Exclude<EAuthSteps, EAuthSteps.EMAIL>]: TTitle;
|
||||
};
|
||||
[mode in EAuthModes]: TTitle;
|
||||
};
|
||||
|
||||
const Titles: THeaderSubheader = {
|
||||
const titles: THeaderSubheader = {
|
||||
[EAuthModes.SIGN_IN]: {
|
||||
[EAuthSteps.PASSWORD]: {
|
||||
header: "Sign in to Plane",
|
||||
subHeader: "Get back to your projects and make progress",
|
||||
},
|
||||
[EAuthSteps.UNIQUE_CODE]: {
|
||||
header: "Sign in to Plane",
|
||||
subHeader: "Get back to your projects and make progress",
|
||||
},
|
||||
header: "Sign in to upvote or comment",
|
||||
subHeader: "Contribute in nudging the features you want to get built.",
|
||||
},
|
||||
[EAuthModes.SIGN_UP]: {
|
||||
[EAuthSteps.PASSWORD]: {
|
||||
header: "Create your account",
|
||||
subHeader: "Progress, visualize, and measure work how it works best for you.",
|
||||
},
|
||||
[EAuthSteps.UNIQUE_CODE]: {
|
||||
header: "Create your account",
|
||||
subHeader: "Progress, visualize, and measure work how it works best for you.",
|
||||
},
|
||||
header: "Comment or react to issues",
|
||||
subHeader: "Use plane to add your valuable inputs to features.",
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: Better approach for this.
|
||||
const getHeaderSubHeader = (mode: EAuthModes | null, step: EAuthSteps): TTitle => {
|
||||
const getHeaderSubHeader = (mode: EAuthModes | null): TTitle => {
|
||||
if (mode) {
|
||||
return (Titles[mode] as any)[step];
|
||||
return titles[mode];
|
||||
}
|
||||
|
||||
return {
|
||||
header: "Get started with Plane",
|
||||
subHeader: "Progress, visualize, and measure work how it works best for you.",
|
||||
header: "Comment or react to issues",
|
||||
subHeader: "Use plane to add your valuable inputs to features.",
|
||||
};
|
||||
};
|
||||
|
||||
@ -81,7 +66,7 @@ export const AuthRoot = observer(() => {
|
||||
// derived values
|
||||
const isSmtpConfigured = instance?.config?.is_smtp_configured;
|
||||
|
||||
const { header, subHeader } = getHeaderSubHeader(authMode, authStep);
|
||||
const { header, subHeader } = getHeaderSubHeader(authMode);
|
||||
|
||||
const handelEmailVerification = async (data: IEmailCheckData) => {
|
||||
// update the global email state
|
||||
|
@ -1,7 +1,10 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
// hooks
|
||||
// types
|
||||
import useTimer from "hooks/use-timer";
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
import { IEmailCheckData } from "types/auth";
|
||||
// icons
|
||||
import { CircleCheck, XCircle } from "lucide-react";
|
||||
// ui
|
||||
@ -10,9 +13,6 @@ import { Button, Input } from "@plane/ui";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// services
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
import useTimer from "hooks/use-timer";
|
||||
import useToast from "hooks/use-toast";
|
||||
import { IEmailCheckData } from "types/auth";
|
||||
import { EAuthModes } from "./root";
|
||||
|
||||
type Props = {
|
||||
@ -175,8 +175,8 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
// disabled={!isValid || hasEmailChanged}
|
||||
loading={isRequestingNewCode}
|
||||
disabled={isRequestingNewCode || !uniqueCodeFormData.code}
|
||||
>
|
||||
{isRequestingNewCode ? "Sending code" : submitButtonText}
|
||||
</Button>
|
||||
|
@ -210,7 +210,7 @@ export const OnBoardingForm: React.FC<Props> = observer((props) => {
|
||||
disabled={isButtonDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Updating..." : "Continue"}
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
@ -6,6 +6,7 @@ class MyDocument extends Document {
|
||||
<Html>
|
||||
<Head />
|
||||
<body className="w-100 bg-custom-background-100 antialiased">
|
||||
<div id="context-menu-portal" />
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
|
@ -181,7 +181,7 @@ const ResetPasswordPage: NextPage = () => {
|
||||
)}
|
||||
</div>
|
||||
{!!resetFormData.confirm_password && resetFormData.password !== resetFormData.confirm_password && (
|
||||
<span className="text-sm text-red-500">Password doesn{"'"}t match</span>
|
||||
<span className="text-sm text-red-500">Passwords don{"'"}t match</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
@ -4,6 +4,7 @@
|
||||
"NODE_ENV",
|
||||
"NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_DEPLOY_URL",
|
||||
"NEXT_PUBLIC_GOD_MODE_URL",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
|
||||
"NEXT_PUBLIC_ENABLE_SENTRY",
|
||||
@ -16,7 +17,6 @@
|
||||
"NEXT_PUBLIC_DEPLOY_WITH_NGINX",
|
||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
||||
"NEXT_PUBLIC_GOD_MODE",
|
||||
"NEXT_PUBLIC_POSTHOG_DEBUG",
|
||||
"SENTRY_AUTH_TOKEN"
|
||||
],
|
||||
|
29
web/components/account/auth-forms/auth-banner.tsx
Normal file
29
web/components/account/auth-forms/auth-banner.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { FC } from "react";
|
||||
import { Info, X } from "lucide-react";
|
||||
// helpers
|
||||
import { TAuthErrorInfo } from "@/helpers/authentication.helper";
|
||||
|
||||
type TAuthBanner = {
|
||||
bannerData: TAuthErrorInfo | undefined;
|
||||
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
|
||||
};
|
||||
|
||||
export const AuthBanner: FC<TAuthBanner> = (props) => {
|
||||
const { bannerData, handleBannerData } = props;
|
||||
|
||||
if (!bannerData) return <></>;
|
||||
return (
|
||||
<div className="relative inline-flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10">
|
||||
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
|
||||
<Info size={16} className="text-custom-primary-100" />
|
||||
</div>
|
||||
<div className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</div>
|
||||
<div
|
||||
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80"
|
||||
onClick={() => handleBannerData && handleBannerData(undefined)}
|
||||
>
|
||||
<X className="w-4 h-4 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
99
web/components/account/auth-forms/auth-header.tsx
Normal file
99
web/components/account/auth-forms/auth-header.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { IWorkspaceMemberInvitation } from "@plane/types";
|
||||
// components
|
||||
import { WorkspaceLogo } from "@/components/workspace/logo";
|
||||
// helpers
|
||||
import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper";
|
||||
// services
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
|
||||
type TAuthHeader = {
|
||||
workspaceSlug: string | undefined;
|
||||
invitationId: string | undefined;
|
||||
invitationEmail: string | undefined;
|
||||
authMode: EAuthModes;
|
||||
currentAuthStep: EAuthSteps;
|
||||
handleLoader: (isLoading: boolean) => void;
|
||||
};
|
||||
|
||||
const Titles = {
|
||||
[EAuthModes.SIGN_IN]: {
|
||||
[EAuthSteps.EMAIL]: {
|
||||
header: "Sign in to Plane",
|
||||
subHeader: "Get back to your projects and make progress",
|
||||
},
|
||||
[EAuthSteps.PASSWORD]: {
|
||||
header: "Sign in to Plane",
|
||||
subHeader: "Get back to your projects and make progress",
|
||||
},
|
||||
[EAuthSteps.UNIQUE_CODE]: {
|
||||
header: "Sign in to Plane",
|
||||
subHeader: "Get back to your projects and make progress",
|
||||
},
|
||||
},
|
||||
[EAuthModes.SIGN_UP]: {
|
||||
[EAuthSteps.EMAIL]: {
|
||||
header: "Create your account",
|
||||
subHeader: "Start tracking your projects with Plane",
|
||||
},
|
||||
[EAuthSteps.PASSWORD]: {
|
||||
header: "Create your account",
|
||||
subHeader: "Progress, visualize, and measure work how it works best for you.",
|
||||
},
|
||||
[EAuthSteps.UNIQUE_CODE]: {
|
||||
header: "Create your account",
|
||||
subHeader: "Progress, visualize, and measure work how it works best for you.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const workSpaceService = new WorkspaceService();
|
||||
|
||||
export const AuthHeader: FC<TAuthHeader> = (props) => {
|
||||
const { workspaceSlug, invitationId, invitationEmail, authMode, currentAuthStep, handleLoader } = props;
|
||||
// state
|
||||
const [invitation, setInvitation] = useState<IWorkspaceMemberInvitation | undefined>(undefined);
|
||||
|
||||
const getHeaderSubHeader = (
|
||||
step: EAuthSteps,
|
||||
mode: EAuthModes,
|
||||
invitation: IWorkspaceMemberInvitation | undefined,
|
||||
email: string | undefined
|
||||
) => {
|
||||
if (invitation && email && invitation.email === email && invitation.workspace) {
|
||||
const workspace = invitation.workspace;
|
||||
return {
|
||||
header: (
|
||||
<>
|
||||
Join <WorkspaceLogo logo={workspace?.logo} name={workspace?.name} classNames="w-8 h-9" /> {workspace.name}
|
||||
</>
|
||||
),
|
||||
subHeader: `${
|
||||
mode == EAuthModes.SIGN_UP ? "Create an account" : "Sign in"
|
||||
} to start managing work with your team.`,
|
||||
};
|
||||
}
|
||||
|
||||
return Titles[mode][step];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceSlug && invitationId) {
|
||||
handleLoader(true);
|
||||
workSpaceService
|
||||
.getWorkspaceInvitation(workspaceSlug, invitationId)
|
||||
.then((res) => setInvitation(res))
|
||||
.catch(() => setInvitation(undefined))
|
||||
.finally(() => handleLoader(false));
|
||||
} else setInvitation(undefined);
|
||||
}, [workspaceSlug, invitationId, handleLoader]);
|
||||
|
||||
const { header, subHeader } = getHeaderSubHeader(currentAuthStep, authMode, invitation, invitationEmail);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-center">
|
||||
<h3 className="text-3xl font-bold text-onboarding-text-100">{header}</h3>
|
||||
<p className="font-medium text-onboarding-text-400">{subHeader}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { FC, FormEvent, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// icons
|
||||
import { CircleAlert, XCircle } from "lucide-react";
|
||||
// types
|
||||
@ -10,82 +9,72 @@ import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "@/helpers/string.helper";
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: IEmailCheckData) => Promise<void>;
|
||||
type TAuthEmailForm = {
|
||||
defaultEmail: string;
|
||||
onSubmit: (data: IEmailCheckData) => Promise<void>;
|
||||
};
|
||||
|
||||
type TEmailFormValues = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const AuthEmailForm: React.FC<Props> = observer((props) => {
|
||||
export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
const { onSubmit, defaultEmail } = props;
|
||||
// hooks
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
} = useForm<TEmailFormValues>({
|
||||
defaultValues: {
|
||||
email: defaultEmail,
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [email, setEmail] = useState(defaultEmail);
|
||||
|
||||
const handleFormSubmit = async (data: TEmailFormValues) => {
|
||||
const emailError = useMemo(
|
||||
() => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined),
|
||||
[email]
|
||||
);
|
||||
|
||||
const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const payload: IEmailCheckData = {
|
||||
email: data.email,
|
||||
email: email,
|
||||
};
|
||||
onSubmit(payload);
|
||||
await onSubmit(payload);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-8 space-y-4 w-5/6 sm:w-96">
|
||||
<form onSubmit={handleFormSubmit} className="mx-auto mt-8 space-y-4 w-5/6 sm:w-96">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: "Email is required",
|
||||
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.email)}
|
||||
placeholder="name@company.com"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||
autoFocus
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => onChange("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="flex items-center gap-1 text-xs text-red-600 px-0.5">
|
||||
<CircleAlert height={12} width={12} />
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
hasError={Boolean(emailError?.email)}
|
||||
placeholder="name@company.com"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||
autoFocus
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setEmail("")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{emailError?.email && (
|
||||
<p className="flex items-center gap-1 text-xs text-red-600 px-0.5">
|
||||
<CircleAlert height={12} width={12} />
|
||||
{emailError.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={!isValid} loading={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={email.length === 0 || Boolean(emailError?.email)}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
|
@ -1,5 +1,10 @@
|
||||
export * from "./sign-up-root";
|
||||
export * from "./sign-in-root";
|
||||
|
||||
export * from "./auth-header";
|
||||
export * from "./auth-banner";
|
||||
|
||||
export * from "./email";
|
||||
export * from "./forgot-password-popover";
|
||||
export * from "./password";
|
||||
export * from "./root";
|
||||
export * from "./unique-code";
|
||||
|
@ -6,10 +6,11 @@ import { Eye, EyeOff, XCircle } from "lucide-react";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// components
|
||||
import { EAuthModes, EAuthSteps, ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/account";
|
||||
import { ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/account";
|
||||
// constants
|
||||
import { FORGOT_PASSWORD } from "@/constants/event-tracker";
|
||||
// helpers
|
||||
import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { getPasswordStrength } from "@/helpers/password.helper";
|
||||
// hooks
|
||||
@ -58,7 +59,7 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
const redirectToUniqueCodeLogin = async () => {
|
||||
const redirectToUniqueCodeSignIn = async () => {
|
||||
handleStepChange(EAuthSteps.UNIQUE_CODE);
|
||||
};
|
||||
|
||||
@ -94,51 +95,78 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
className="mx-auto mt-5 space-y-4 w-5/6 sm:w-96"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/${mode === EAuthModes.SIGN_IN ? "sign-in" : "sign-up"}/`}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={passwordFormData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
// hasError={Boolean(errors.email)}
|
||||
placeholder="name@company.com"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||
<form
|
||||
className="mx-auto mt-5 space-y-4 w-5/6 sm:w-96"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/${mode === EAuthModes.SIGN_IN ? "sign-in" : "sign-up"}/`}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={passwordFormData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
// hasError={Boolean(errors.email)}
|
||||
placeholder="name@company.com"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||
/>
|
||||
{passwordFormData.email.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
/>
|
||||
{passwordFormData.email.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
|
||||
{mode === EAuthModes.SIGN_IN ? "Password" : "Set a password"}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={passwordFormData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{passwordSupport}
|
||||
</div>
|
||||
{mode === EAuthModes.SIGN_UP && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
|
||||
{mode === EAuthModes.SIGN_IN ? "Password" : "Set a password"}
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">
|
||||
Confirm password
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={passwordFormData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
placeholder="Enter password"
|
||||
name="confirm_password"
|
||||
value={passwordFormData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
@ -152,64 +180,36 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{passwordSupport}
|
||||
</div>
|
||||
{mode === EAuthModes.SIGN_UP && getPasswordStrength(passwordFormData.password) >= 3 && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">
|
||||
Confirm password
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="confirm_password"
|
||||
value={passwordFormData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!passwordFormData.confirm_password && passwordFormData.password !== passwordFormData.confirm_password && (
|
||||
<span className="text-sm text-red-500">Password doesn{"'"}t match</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{mode === EAuthModes.SIGN_IN ? (
|
||||
<>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSmtpConfigured ? "Continue" : "Go to workspace"}
|
||||
</Button>
|
||||
{instance && isSmtpConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={redirectToUniqueCodeLogin}
|
||||
variant="outline-primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
Sign in with unique code
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
Create account
|
||||
</Button>
|
||||
{!!passwordFormData.confirm_password && passwordFormData.password !== passwordFormData.confirm_password && (
|
||||
<span className="text-sm text-red-500">Passwords don{"'"}t match</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{mode === EAuthModes.SIGN_IN ? (
|
||||
<>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSmtpConfigured ? "Continue" : "Go to workspace"}
|
||||
</Button>
|
||||
{instance && isSmtpConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={redirectToUniqueCodeSignIn}
|
||||
variant="outline-primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
Sign in with unique code
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
Create account
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -1,236 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
import { IEmailCheckData, IWorkspaceMemberInvitation } from "@plane/types";
|
||||
// ui
|
||||
import { Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
AuthEmailForm,
|
||||
AuthPasswordForm,
|
||||
OAuthOptions,
|
||||
TermsAndConditions,
|
||||
UniqueCodeForm,
|
||||
} from "@/components/account";
|
||||
import { WorkspaceLogo } from "@/components/workspace/logo";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
|
||||
const authService = new AuthService();
|
||||
const workSpaceService = new WorkspaceService();
|
||||
|
||||
export enum EAuthSteps {
|
||||
EMAIL = "EMAIL",
|
||||
PASSWORD = "PASSWORD",
|
||||
UNIQUE_CODE = "UNIQUE_CODE",
|
||||
OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
|
||||
}
|
||||
|
||||
export enum EAuthModes {
|
||||
SIGN_IN = "SIGN_IN",
|
||||
SIGN_UP = "SIGN_UP",
|
||||
}
|
||||
|
||||
type Props = {
|
||||
mode: EAuthModes;
|
||||
};
|
||||
|
||||
const Titles = {
|
||||
[EAuthModes.SIGN_IN]: {
|
||||
[EAuthSteps.EMAIL]: {
|
||||
header: "Sign in to Plane",
|
||||
subHeader: "Get back to your projects and make progress",
|
||||
},
|
||||
[EAuthSteps.PASSWORD]: {
|
||||
header: "Sign in to Plane",
|
||||
subHeader: "Get back to your projects and make progress",
|
||||
},
|
||||
[EAuthSteps.UNIQUE_CODE]: {
|
||||
header: "Sign in to Plane",
|
||||
subHeader: "Get back to your projects and make progress",
|
||||
},
|
||||
[EAuthSteps.OPTIONAL_SET_PASSWORD]: {
|
||||
header: "",
|
||||
subHeader: "",
|
||||
},
|
||||
},
|
||||
[EAuthModes.SIGN_UP]: {
|
||||
[EAuthSteps.EMAIL]: {
|
||||
header: "Create your account",
|
||||
subHeader: "Start tracking your projects with Plane",
|
||||
},
|
||||
[EAuthSteps.PASSWORD]: {
|
||||
header: "Create your account",
|
||||
subHeader: "Progress, visualize, and measure work how it works best for you.",
|
||||
},
|
||||
[EAuthSteps.UNIQUE_CODE]: {
|
||||
header: "Create your account",
|
||||
subHeader: "Progress, visualize, and measure work how it works best for you.",
|
||||
},
|
||||
[EAuthSteps.OPTIONAL_SET_PASSWORD]: {
|
||||
header: "",
|
||||
subHeader: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const getHeaderSubHeader = (
|
||||
step: EAuthSteps,
|
||||
mode: EAuthModes,
|
||||
invitation?: IWorkspaceMemberInvitation | undefined,
|
||||
email?: string
|
||||
) => {
|
||||
if (invitation && email && invitation.email === email && invitation.workspace) {
|
||||
const workspace = invitation.workspace;
|
||||
return {
|
||||
header: (
|
||||
<>
|
||||
Join <WorkspaceLogo logo={workspace?.logo} name={workspace?.name} classNames="w-8 h-9" /> {workspace.name}
|
||||
</>
|
||||
),
|
||||
subHeader: `${
|
||||
mode == EAuthModes.SIGN_UP ? "Create an account" : "Sign in"
|
||||
} to start managing work with your team.`,
|
||||
};
|
||||
}
|
||||
|
||||
return Titles[mode][step];
|
||||
};
|
||||
|
||||
export const AuthRoot = observer((props: Props) => {
|
||||
const { mode } = props;
|
||||
//router
|
||||
const router = useRouter();
|
||||
const { email: emailParam, invitation_id, slug } = router.query;
|
||||
// states
|
||||
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
|
||||
const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
|
||||
const [invitation, setInvitation] = useState<IWorkspaceMemberInvitation | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// hooks
|
||||
const { instance } = useInstance();
|
||||
// derived values
|
||||
const isSmtpConfigured = instance?.config?.is_smtp_configured;
|
||||
|
||||
const redirectToSignUp = (email: string) => {
|
||||
if (isEmpty(email)) router.push({ pathname: "/", query: router.query });
|
||||
else router.push({ pathname: "/", query: { ...router.query, email: email } });
|
||||
};
|
||||
|
||||
const redirectToSignIn = (email: string) => {
|
||||
if (isEmpty(email)) router.push({ pathname: "/accounts/sign-in", query: router.query });
|
||||
else router.push({ pathname: "/accounts/sign-in", query: { ...router.query, email: email } });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (invitation_id && slug) {
|
||||
setIsLoading(true);
|
||||
workSpaceService
|
||||
.getWorkspaceInvitation(slug.toString(), invitation_id.toString())
|
||||
.then((res) => {
|
||||
setInvitation(res);
|
||||
})
|
||||
.catch(() => {
|
||||
setInvitation(undefined);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setInvitation(undefined);
|
||||
}
|
||||
}, [invitation_id, slug]);
|
||||
|
||||
const { header, subHeader } = getHeaderSubHeader(authStep, mode, invitation, email);
|
||||
|
||||
// step 1 submit handler- email verification
|
||||
const handleEmailVerification = async (data: IEmailCheckData) => {
|
||||
setEmail(data.email);
|
||||
|
||||
const emailCheck = mode === EAuthModes.SIGN_UP ? authService.signUpEmailCheck : authService.signInEmailCheck;
|
||||
|
||||
await emailCheck(data)
|
||||
.then((res) => {
|
||||
if (mode === EAuthModes.SIGN_IN && !res.is_password_autoset) {
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
} else {
|
||||
if (isSmtpConfigured) {
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
} else {
|
||||
if (mode === EAuthModes.SIGN_IN) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Unable to process request please contact Administrator to reset password",
|
||||
});
|
||||
} else {
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.error_code === "USER_DOES_NOT_EXIST") {
|
||||
redirectToSignUp(data.email);
|
||||
return;
|
||||
} else if (err?.error_code === "USER_ALREADY_EXIST") {
|
||||
redirectToSignIn(data.email);
|
||||
return;
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error_message ?? "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const isOAuthEnabled =
|
||||
instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex flex-col">
|
||||
<div className="text-center space-y-1 py-4 mx-auto sm:w-96">
|
||||
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">{header}</h3>
|
||||
<p className="font-medium text-onboarding-text-400">{subHeader}</p>
|
||||
</div>
|
||||
{authStep === EAuthSteps.EMAIL && <AuthEmailForm defaultEmail={email} onSubmit={handleEmailVerification} />}
|
||||
{authStep === EAuthSteps.UNIQUE_CODE && (
|
||||
<UniqueCodeForm
|
||||
email={email}
|
||||
handleEmailClear={() => {
|
||||
setEmail("");
|
||||
setAuthStep(EAuthSteps.EMAIL);
|
||||
}}
|
||||
submitButtonText="Continue"
|
||||
mode={mode}
|
||||
/>
|
||||
)}
|
||||
{authStep === EAuthSteps.PASSWORD && (
|
||||
<AuthPasswordForm
|
||||
email={email}
|
||||
handleEmailClear={() => {
|
||||
setEmail("");
|
||||
setAuthStep(EAuthSteps.EMAIL);
|
||||
}}
|
||||
handleStepChange={(step) => setAuthStep(step)}
|
||||
mode={mode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isOAuthEnabled && authStep !== EAuthSteps.OPTIONAL_SET_PASSWORD && <OAuthOptions />}
|
||||
|
||||
<TermsAndConditions isSignUp={mode === EAuthModes.SIGN_UP} />
|
||||
</>
|
||||
);
|
||||
});
|
126
web/components/account/auth-forms/sign-in-root.tsx
Normal file
126
web/components/account/auth-forms/sign-in-root.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { IEmailCheckData } from "@plane/types";
|
||||
import { Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
AuthHeader,
|
||||
AuthBanner,
|
||||
AuthEmailForm,
|
||||
AuthPasswordForm,
|
||||
OAuthOptions,
|
||||
TermsAndConditions,
|
||||
UniqueCodeForm,
|
||||
} from "@/components/account";
|
||||
// helpers
|
||||
import {
|
||||
EAuthModes,
|
||||
EAuthSteps,
|
||||
EAuthenticationErrorCodes,
|
||||
EErrorAlertType,
|
||||
TAuthErrorInfo,
|
||||
authErrorHandler,
|
||||
} from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const SignInAuthRoot = observer(() => {
|
||||
//router
|
||||
const router = useRouter();
|
||||
const { email: emailParam, invitation_id, slug: workspaceSlug, error_code, error_message } = router.query;
|
||||
// states
|
||||
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
|
||||
const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
|
||||
// hooks
|
||||
const { instance } = useInstance();
|
||||
// derived values
|
||||
const authMode = EAuthModes.SIGN_IN;
|
||||
|
||||
useEffect(() => {
|
||||
if (error_code && error_message) {
|
||||
const errorhandler = authErrorHandler(
|
||||
error_code?.toString() as EAuthenticationErrorCodes,
|
||||
error_message?.toString()
|
||||
);
|
||||
if (errorhandler) setErrorInfo(errorhandler);
|
||||
}
|
||||
}, [error_code, error_message]);
|
||||
|
||||
// step 1 submit handler- email verification
|
||||
const handleEmailVerification = async (data: IEmailCheckData) => {
|
||||
setEmail(data.email);
|
||||
|
||||
await authService
|
||||
.signInEmailCheck(data)
|
||||
.then(() => {
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
})
|
||||
.catch((err) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error_message ?? "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const isOAuthEnabled =
|
||||
instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative max-w-lg mx-auto flex flex-col space-y-6">
|
||||
<AuthHeader
|
||||
workspaceSlug={workspaceSlug?.toString() || undefined}
|
||||
invitationId={invitation_id?.toString() || undefined}
|
||||
invitationEmail={email || undefined}
|
||||
authMode={EAuthModes.SIGN_IN}
|
||||
currentAuthStep={authStep}
|
||||
handleLoader={setIsLoading}
|
||||
/>
|
||||
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
|
||||
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
|
||||
)}
|
||||
{authStep === EAuthSteps.EMAIL && <AuthEmailForm defaultEmail={email} onSubmit={handleEmailVerification} />}
|
||||
{authStep === EAuthSteps.UNIQUE_CODE && (
|
||||
<UniqueCodeForm
|
||||
email={email}
|
||||
handleEmailClear={() => {
|
||||
setEmail("");
|
||||
setAuthStep(EAuthSteps.EMAIL);
|
||||
}}
|
||||
submitButtonText="Continue"
|
||||
mode={authMode}
|
||||
/>
|
||||
)}
|
||||
{authStep === EAuthSteps.PASSWORD && (
|
||||
<AuthPasswordForm
|
||||
email={email}
|
||||
handleEmailClear={() => {
|
||||
setEmail("");
|
||||
setAuthStep(EAuthSteps.EMAIL);
|
||||
}}
|
||||
handleStepChange={(step) => setAuthStep(step)}
|
||||
mode={authMode}
|
||||
/>
|
||||
)}
|
||||
{isOAuthEnabled && <OAuthOptions />}
|
||||
<TermsAndConditions isSignUp={false} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
133
web/components/account/auth-forms/sign-up-root.tsx
Normal file
133
web/components/account/auth-forms/sign-up-root.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
import { IEmailCheckData } from "@plane/types";
|
||||
// ui
|
||||
import { Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
AuthHeader,
|
||||
AuthBanner,
|
||||
AuthEmailForm,
|
||||
AuthUniqueCodeForm,
|
||||
AuthPasswordForm,
|
||||
OAuthOptions,
|
||||
TermsAndConditions,
|
||||
} from "@/components/account";
|
||||
// helpers
|
||||
import {
|
||||
EAuthModes,
|
||||
EAuthSteps,
|
||||
EAuthenticationErrorCodes,
|
||||
EErrorAlertType,
|
||||
TAuthErrorInfo,
|
||||
authErrorHandler,
|
||||
} from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
// service initialization
|
||||
const authService = new AuthService();
|
||||
|
||||
export const SignUpAuthRoot: FC = observer(() => {
|
||||
//router
|
||||
const router = useRouter();
|
||||
const { email: emailParam, invitation_id, slug: workspaceSlug, error_code, error_message } = router.query;
|
||||
// states
|
||||
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
|
||||
const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
|
||||
// hooks
|
||||
const { instance } = useInstance();
|
||||
// derived values
|
||||
const authMode = EAuthModes.SIGN_UP;
|
||||
const isSmtpConfigured = instance?.config?.is_smtp_configured;
|
||||
|
||||
useEffect(() => {
|
||||
if (error_code && error_message) {
|
||||
const errorhandler = authErrorHandler(
|
||||
error_code?.toString() as EAuthenticationErrorCodes,
|
||||
error_message?.toString()
|
||||
);
|
||||
if (errorhandler) setErrorInfo(errorhandler);
|
||||
}
|
||||
}, [error_code, error_message]);
|
||||
|
||||
// email verification
|
||||
const handleEmailVerification = async (data: IEmailCheckData) => {
|
||||
setEmail(data.email);
|
||||
await authService
|
||||
.signUpEmailCheck(data)
|
||||
.then(() => {
|
||||
if (isSmtpConfigured) setAuthStep(EAuthSteps.PASSWORD);
|
||||
else setAuthStep(EAuthSteps.PASSWORD);
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorhandler = authErrorHandler(error?.error_code, error?.error_message);
|
||||
if (errorhandler) {
|
||||
setErrorInfo(errorhandler);
|
||||
return;
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.error_message ?? "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const isOAuthEnabled =
|
||||
instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-lg px-5 mx-auto flex flex-col space-y-6">
|
||||
<AuthHeader
|
||||
workspaceSlug={workspaceSlug?.toString() || undefined}
|
||||
invitationId={invitation_id?.toString() || undefined}
|
||||
invitationEmail={email || undefined}
|
||||
authMode={EAuthModes.SIGN_UP}
|
||||
currentAuthStep={authStep}
|
||||
handleLoader={setIsLoading}
|
||||
/>
|
||||
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
|
||||
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
|
||||
)}
|
||||
{authStep === EAuthSteps.EMAIL && <AuthEmailForm defaultEmail={email} onSubmit={handleEmailVerification} />}
|
||||
{authStep === EAuthSteps.UNIQUE_CODE && (
|
||||
<AuthUniqueCodeForm
|
||||
email={email}
|
||||
handleEmailClear={() => {
|
||||
setEmail("");
|
||||
setAuthStep(EAuthSteps.EMAIL);
|
||||
}}
|
||||
submitButtonText="Continue"
|
||||
mode={authMode}
|
||||
/>
|
||||
)}
|
||||
{authStep === EAuthSteps.PASSWORD && (
|
||||
<AuthPasswordForm
|
||||
email={email}
|
||||
handleEmailClear={() => {
|
||||
setEmail("");
|
||||
setAuthStep(EAuthSteps.EMAIL);
|
||||
}}
|
||||
handleStepChange={(step) => setAuthStep(step)}
|
||||
mode={authMode}
|
||||
/>
|
||||
)}
|
||||
{isOAuthEnabled && <OAuthOptions />}
|
||||
<TermsAndConditions isSignUp={authMode === EAuthModes.SIGN_UP} />
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,17 +1,14 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CircleCheck, XCircle } from "lucide-react";
|
||||
// types
|
||||
import { IEmailCheckData } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// constants
|
||||
// helpers
|
||||
import { EAuthModes } from "@/helpers/authentication.helper";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import useTimer from "@/hooks/use-timer";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
import { EAuthModes } from "./root";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
@ -33,7 +30,7 @@ const defaultValues: TUniqueCodeFormValues = {
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
export const AuthUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
const { email, handleEmailClear, submitButtonText, mode } = props;
|
||||
// states
|
||||
const [uniqueCodeFormData, setUniqueCodeFormData] = useState<TUniqueCodeFormValues>({ ...defaultValues, email });
|
||||
@ -72,10 +69,10 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleRequestNewCode = async () => {
|
||||
const handleRequestNewCode = async (email: string) => {
|
||||
setIsRequestingNewCode(true);
|
||||
|
||||
await handleSendNewCode(uniqueCodeFormData.email)
|
||||
await handleSendNewCode(email)
|
||||
.then(() => setResendCodeTimer(30))
|
||||
.finally(() => setIsRequestingNewCode(false));
|
||||
};
|
||||
@ -86,10 +83,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
}, [csrfToken]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsRequestingNewCode(true);
|
||||
handleSendNewCode(email)
|
||||
.then(() => setResendCodeTimer(30))
|
||||
.finally(() => setIsRequestingNewCode(false));
|
||||
handleRequestNewCode(email);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@ -149,7 +143,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRequestNewCode}
|
||||
onClick={() => handleRequestNewCode(uniqueCodeFormData.email)}
|
||||
className={`${
|
||||
isRequestNewCodeDisabled
|
||||
? "text-onboarding-text-400"
|
||||
@ -165,7 +159,14 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" loading={isRequestingNewCode}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={isRequestingNewCode}
|
||||
disabled={isRequestingNewCode || !uniqueCodeFormData.code}
|
||||
>
|
||||
{isRequestingNewCode ? "Sending code" : submitButtonText}
|
||||
</Button>
|
||||
</form>
|
||||
|
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 Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@ -20,6 +21,8 @@ type Props = {
|
||||
|
||||
export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
|
||||
const { cycleId } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -90,6 +93,7 @@ export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={parentRef}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`}
|
||||
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 && (
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId.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 Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@ -28,6 +28,8 @@ export interface ICyclesBoardCard {
|
||||
|
||||
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
const { cycleId, workspaceSlug, projectId } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store
|
||||
@ -150,7 +152,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
|
||||
return (
|
||||
<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 items-center justify-between gap-2">
|
||||
<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>
|
||||
);
|
||||
|
@ -76,7 +76,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
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">
|
||||
{CYCLE_TABS_LIST.map((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 Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// icons
|
||||
import { Check, Info, User2 } from "lucide-react";
|
||||
import { Check, Info } from "lucide-react";
|
||||
// types
|
||||
import type { TCycleGroups } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
|
||||
import { CircularProgressIndicator } from "@plane/ui";
|
||||
// components
|
||||
import { FavoriteStar } from "@/components/core";
|
||||
import { CycleQuickActions } from "@/components/cycles";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "@/constants/cycle";
|
||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { CycleListItemAction } from "@/components/cycles/list";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store";
|
||||
import { useCycle } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type TCyclesListItem = {
|
||||
@ -29,79 +22,41 @@ type TCyclesListItem = {
|
||||
handleRemoveFromFavorites?: () => void;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
const { cycleId, workspaceSlug, projectId, isArchived } = props;
|
||||
const { cycleId, workspaceSlug, projectId } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
||||
const { getUserDetails } = useMember();
|
||||
const { getCycleById } = useCycle();
|
||||
|
||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
|
||||
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
|
||||
() => {
|
||||
captureEvent(CYCLE_FAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
}
|
||||
);
|
||||
if (!cycleDetails) return null;
|
||||
|
||||
setPromiseToast(addToFavoritePromise, {
|
||||
loading: "Adding cycle to favorites...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Cycle added to favorites.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Couldn't add the cycle to favorites. Please try again.",
|
||||
},
|
||||
});
|
||||
};
|
||||
// computed
|
||||
// TODO: change this logic once backend fix the response
|
||||
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
const isCompleted = cycleStatus === "completed";
|
||||
|
||||
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const cycleTotalIssues =
|
||||
cycleDetails.backlog_issues +
|
||||
cycleDetails.unstarted_issues +
|
||||
cycleDetails.started_issues +
|
||||
cycleDetails.completed_issues +
|
||||
cycleDetails.cancelled_issues;
|
||||
|
||||
const removeFromFavoritePromise = removeCycleFromFavorites(
|
||||
workspaceSlug?.toString(),
|
||||
projectId.toString(),
|
||||
cycleId
|
||||
).then(() => {
|
||||
captureEvent(CYCLE_UNFAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
});
|
||||
const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100;
|
||||
|
||||
setPromiseToast(removeFromFavoritePromise, {
|
||||
loading: "Removing cycle from favorites...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Cycle removed from favorites.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Couldn't remove the cycle from favorites. Please try again.",
|
||||
},
|
||||
});
|
||||
};
|
||||
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
|
||||
|
||||
// handlers
|
||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||
const { query } = router;
|
||||
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 (
|
||||
<div className="relative">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
|
||||
onClick={(e) => {
|
||||
if (isArchived) {
|
||||
openCycleOverview(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
||||
<div className="flex-shrink-0">
|
||||
<CircularProgressIndicator size={38} percentage={progress}>
|
||||
{isCompleted ? (
|
||||
progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<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>
|
||||
<ListItem
|
||||
title={cycleDetails?.name ?? ""}
|
||||
itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
|
||||
onItemClick={(e) => {
|
||||
if (cycleDetails.archived_at) openCycleOverview(e);
|
||||
}}
|
||||
prependTitleElement={
|
||||
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
|
||||
{isCompleted ? (
|
||||
progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center">
|
||||
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycleDetails.assignee_ids?.map((assignee_id) => {
|
||||
const member = getUserDetails(assignee_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
|
||||
else handleAddToFavorites(e);
|
||||
}}
|
||||
selected={!!cycleDetails.is_favorite}
|
||||
/>
|
||||
)}
|
||||
<CycleQuickActions
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CircularProgressIndicator>
|
||||
}
|
||||
appendTitleElement={
|
||||
<button
|
||||
onClick={openCycleOverview}
|
||||
className={`z-[5] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
|
||||
>
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
}
|
||||
actionableItems={
|
||||
<CycleListItemAction
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycleId={cycleId}
|
||||
cycleDetails={cycleDetails}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -5,22 +5,15 @@ type Props = {
|
||||
cycleIds: string[];
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const CyclesListMap: React.FC<Props> = (props) => {
|
||||
const { cycleIds, projectId, workspaceSlug, isArchived } = props;
|
||||
const { cycleIds, projectId, workspaceSlug } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{cycleIds.map((cycleId) => (
|
||||
<CyclesListItem
|
||||
key={cycleId}
|
||||
cycleId={cycleId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
<CyclesListItem key={cycleId} cycleId={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from "./cycles-list-item";
|
||||
export * from "./cycles-list-map";
|
||||
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 { Disclosure } from "@headlessui/react";
|
||||
// components
|
||||
import { ListLayout } from "@/components/core/list";
|
||||
import { CyclePeekOverview, CyclesListMap } from "@/components/cycles";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
@ -21,12 +22,11 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<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
|
||||
cycleIds={cycleIds}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
{completedCycleIds.length !== 0 && (
|
||||
<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.Panel>
|
||||
<CyclesListMap
|
||||
cycleIds={completedCycleIds}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
<CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
</Disclosure.Panel>
|
||||
</Disclosure>
|
||||
)}
|
||||
</div>
|
||||
</ListLayout>
|
||||
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,27 +2,28 @@ import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// icons
|
||||
import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
// ui
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useCycle, useEventTracker, useUser } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
cycleId: string;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { cycleId, projectId, workspaceSlug, isArchived } = props;
|
||||
const { parentRef, cycleId, projectId, workspaceSlug } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// states
|
||||
@ -37,40 +38,31 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { getCycleById, restoreCycle } = useCycle();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
const isArchived = !!cycleDetails?.archived_at;
|
||||
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
|
||||
// auth
|
||||
const isEditingAllowed =
|
||||
!!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER;
|
||||
|
||||
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
|
||||
const cycleLink = `${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`;
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(cycleLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Cycle link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank");
|
||||
|
||||
const handleEditCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handleEditCycle = () => {
|
||||
setTrackElement("Cycles page list layout");
|
||||
setUpdateModal(true);
|
||||
};
|
||||
|
||||
const handleArchiveCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setArchiveCycleModal(true);
|
||||
};
|
||||
const handleArchiveCycle = () => setArchiveCycleModal(true);
|
||||
|
||||
const handleRestoreCycle = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handleRestoreCycle = async () =>
|
||||
await restoreCycle(workspaceSlug, projectId, cycleId)
|
||||
.then(() => {
|
||||
setToast({
|
||||
@ -87,15 +79,61 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
message: "Cycle could not be restored. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handleDeleteCycle = () => {
|
||||
setTrackElement("Cycles page list layout");
|
||||
setDeleteModal(true);
|
||||
};
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: handleEditCycle,
|
||||
shouldRender: isEditingAllowed && !isCompleted && !isArchived,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
shouldRender: !isArchived,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: "Copy link",
|
||||
icon: LinkIcon,
|
||||
shouldRender: !isArchived,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
action: handleArchiveCycle,
|
||||
title: "Archive",
|
||||
description: isCompleted ? undefined : "Only completed cycle can\nbe archived.",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
shouldRender: isEditingAllowed && !isArchived,
|
||||
disabled: !isCompleted,
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
action: handleRestoreCycle,
|
||||
title: "Restore",
|
||||
icon: ArchiveRestoreIcon,
|
||||
shouldRender: isEditingAllowed && isArchived,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: handleDeleteCycle,
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: isEditingAllowed && !isCompleted && !isArchived,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{cycleDetails && (
|
||||
@ -123,60 +161,42 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CustomMenu ellipsis placement="bottom-end">
|
||||
{!isCompleted && isEditingAllowed && !isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Edit cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleArchiveCycle} disabled={!isCompleted}>
|
||||
{isCompleted ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
Archive cycle
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
<div className="-mt-1">
|
||||
<p>Archive cycle</p>
|
||||
<p className="text-xs text-custom-text-400">
|
||||
Only completed cycle <br /> can be archived.
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -36,7 +36,7 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
} = props;
|
||||
// store hooks
|
||||
const { updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
const { peekIssue } = useIssueDetail();
|
||||
const { getIsIssuePeeked } = useIssueDetail();
|
||||
|
||||
const isBlockVisibleOnChart = block.start_date && block.target_date;
|
||||
|
||||
@ -81,8 +81,9 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
<div
|
||||
className={cn("relative h-full", {
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue?.issueId === block.data.id,
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(
|
||||
block.data.id
|
||||
),
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(block.id)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
|
@ -25,7 +25,7 @@ export const IssuesSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
const { block, enableReorder, provided, snapshot } = props;
|
||||
// store hooks
|
||||
const { updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
const { peekIssue } = useIssueDetail();
|
||||
const { getIsIssuePeeked } = useIssueDetail();
|
||||
|
||||
const duration = findTotalDaysInRange(block.start_date, block.target_date);
|
||||
|
||||
@ -33,8 +33,9 @@ export const IssuesSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
<div
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": snapshot.isDragging,
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue?.issueId === block.data.id,
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(
|
||||
block.data.id
|
||||
),
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(block.id)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
|
@ -1,15 +1,16 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
// icons
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
// ui
|
||||
import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui";
|
||||
// helpers
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { ProjectLogo } from "@/components/project";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
|
||||
|
||||
export const CyclesHeader: FC = observer(() => {
|
||||
@ -28,52 +29,48 @@ export const CyclesHeader: FC = observer(() => {
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 items-center justify-between gap-x-2 gap-y-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>
|
||||
<Breadcrumbs onBack={router.back}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
icon={
|
||||
currentProjectDetails && (
|
||||
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
|
||||
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<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 w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<div>
|
||||
<Breadcrumbs onBack={router.back}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
icon={
|
||||
currentProjectDetails && (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</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>
|
||||
{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>
|
||||
);
|
||||
});
|
||||
|
@ -1,32 +1,21 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { ListFilter, Plus, Search, X } from "lucide-react";
|
||||
// types
|
||||
import { TModuleFilters } from "@plane/types";
|
||||
// icons
|
||||
import { Plus } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, Tooltip, DiceIcon } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, DiceIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { FiltersDropdown } from "@/components/issues";
|
||||
import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules";
|
||||
import { ProjectLogo } from "@/components/project";
|
||||
// constants
|
||||
import { MODULE_VIEW_LAYOUTS } from "@/constants/module";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useMember, useModuleFilter, useProject, useUser, useCommandPalette } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
|
||||
|
||||
export const ModulesListHeader: React.FC = observer(() => {
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { workspaceSlug } = router.query;
|
||||
// store hooks
|
||||
const { toggleCreateModuleModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
@ -34,54 +23,6 @@ export const ModulesListHeader: React.FC = observer(() => {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const {
|
||||
workspace: { workspaceMemberIds },
|
||||
} = useMember();
|
||||
const {
|
||||
currentProjectDisplayFilters: displayFilters,
|
||||
currentProjectFilters: filters,
|
||||
searchQuery,
|
||||
updateDisplayFilters,
|
||||
updateFilters,
|
||||
updateSearchQuery,
|
||||
} = useModuleFilter();
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
|
||||
// outside click detector hook
|
||||
useOutsideClickDetector(inputRef, () => {
|
||||
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
|
||||
});
|
||||
|
||||
const handleFilters = useCallback(
|
||||
(key: keyof TModuleFilters, value: string | string[]) => {
|
||||
if (!projectId) return;
|
||||
const newValues = filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value))
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
else {
|
||||
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(projectId.toString(), { [key]: newValues });
|
||||
},
|
||||
[filters, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
|
||||
else {
|
||||
setIsSearchOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// auth
|
||||
const canUserCreateModule =
|
||||
@ -116,97 +57,6 @@ export const ModulesListHeader: React.FC = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
<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 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
|
@ -114,124 +114,121 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
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="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs onBack={() => router.back()}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects`}
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
icon={
|
||||
currentProjectDetails ? (
|
||||
currentProjectDetails && (
|
||||
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
|
||||
<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" />
|
||||
|
||||
<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 items-center gap-2.5">
|
||||
<Breadcrumbs onBack={() => router.back()}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects`}
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
icon={
|
||||
currentProjectDetails ? (
|
||||
currentProjectDetails && (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
|
||||
</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
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
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"
|
||||
<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"
|
||||
>
|
||||
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>
|
||||
</>
|
||||
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||
{issueCount}
|
||||
</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="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>
|
||||
</>
|
||||
);
|
||||
|
@ -26,13 +26,11 @@ type Props = {
|
||||
isEditable: boolean;
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
setIsSubmitting: Dispatch<SetStateAction<"submitting" | "submitted" | "saved">>;
|
||||
swrIssueDescription: string | undefined;
|
||||
};
|
||||
|
||||
export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting, swrIssueDescription } =
|
||||
props;
|
||||
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
|
||||
// hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
||||
@ -137,7 +135,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
swrIssueDescription={swrIssueDescription}
|
||||
swrIssueDescription={null}
|
||||
initialValue={issue.description_html ?? "<p></p>"}
|
||||
disabled={!isEditable}
|
||||
issueOperations={issueOperations}
|
||||
|
@ -24,14 +24,13 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
|
||||
const { data: swrIssueDetails } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId && inboxIssueId
|
||||
? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
|
||||
: null,
|
||||
workspaceSlug && projectId && inboxIssueId
|
||||
? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId)
|
||||
: null,
|
||||
{ revalidateOnFocus: true }
|
||||
: null
|
||||
);
|
||||
|
||||
const isEditable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
@ -61,7 +60,6 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
|
||||
isEditable={isEditable && !isIssueDisabled}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
swrIssueDescription={swrIssueDetails?.issue.description_html}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -136,9 +136,12 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
|
||||
/>
|
||||
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} />
|
||||
<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>
|
||||
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-3">
|
||||
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}>
|
||||
|
@ -5,6 +5,8 @@ import { TIssue } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
|
||||
// helpers
|
||||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store";
|
||||
|
||||
@ -39,10 +41,7 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
|
||||
projectId={projectId}
|
||||
dragDropEnabled={false}
|
||||
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
|
||||
placeholder={(isFocused) => {
|
||||
if (isFocused) return "Press '/' for commands...";
|
||||
else return "Click to add description";
|
||||
}}
|
||||
placeholder={getDescriptionPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -50,7 +50,7 @@ export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props)
|
||||
<div
|
||||
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`,
|
||||
{ "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">
|
||||
|
@ -65,14 +65,14 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
|
||||
});
|
||||
|
||||
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="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) => (
|
||||
<div
|
||||
key={option?.key}
|
||||
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`
|
||||
)}
|
||||
onClick={() => {
|
||||
|
@ -1,44 +1,42 @@
|
||||
import { FC } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// icons
|
||||
import { UserCog2 } from "lucide-react";
|
||||
// ui
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
import { Button } from "@plane/ui";
|
||||
// images
|
||||
import instanceNotReady from "public/instance/plane-instance-not-ready.webp";
|
||||
import PlaneBlackLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg";
|
||||
import PlaneWhiteLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg";
|
||||
|
||||
type TInstanceNotReady = {
|
||||
isGodModeEnabled: boolean;
|
||||
handleGodModeStateChange?: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export const InstanceNotReady: FC<TInstanceNotReady> = () => {
|
||||
// const { isGodModeEnabled, handleGodModeStateChange } = props;
|
||||
import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.svg";
|
||||
import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.svg";
|
||||
import PlaneTakeOffImage from "@/public/plane-takeoff.png";
|
||||
|
||||
export const InstanceNotReady: FC = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo;
|
||||
|
||||
const planeGodModeUrl = `${process.env.NEXT_PUBLIC_GOD_MODE_URL}/god-mode/setup/?auth_enabled=0`;
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full overflow-y-auto bg-onboarding-gradient-100">
|
||||
<div className="h-full w-full pt-24">
|
||||
<div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-100 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3">
|
||||
<div className="relative h-full rounded-t-md bg-onboarding-gradient-200 px-7 sm:px-0">
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Image src={planeLogo} className="h-[44px] w-full" alt="Plane logo" />
|
||||
<div className="relative h-screen max-h-max w-full overflow-hidden overflow-y-auto flex flex-col">
|
||||
<div className="flex-shrink-0 h-[120px]">
|
||||
<div className="relative h-full container mx-auto px-5 lg:px-0 flex items-center justify-between gap-5 z-50">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Image src={planeLogo} className="h-[24px] w-full" alt="Plane logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex-grow">
|
||||
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
|
||||
<div className="w-auto max-w-2xl relative space-y-8 py-10">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
|
||||
<Image src={PlaneTakeOffImage} alt="Plane Logo" />
|
||||
<p className="font-medium text-base text-custom-text-400">
|
||||
Get started by setting up your instance and workspace
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-20">
|
||||
<Image src={instanceNotReady} className="w-full" alt="Instance not ready" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-5 py-12 pb-20">
|
||||
<h3 className="text-2xl font-medium">Your Plane instance isn{"'"}t ready yet</h3>
|
||||
<p className="text-sm">Ask your Instance Admin to complete set-up first.</p>
|
||||
<a href="/god-mode" className={`${getButtonStyling("primary", "md")} mt-4`}>
|
||||
<UserCog2 className="h-3.5 w-3.5" />
|
||||
Get started
|
||||
<div>
|
||||
<a href={planeGodModeUrl}>
|
||||
<Button size="lg" className="w-full">
|
||||
Get started
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -9,6 +9,8 @@ import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor";
|
||||
import { TIssueOperations } from "@/components/issues/issue-detail";
|
||||
// helpers
|
||||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
@ -19,7 +21,7 @@ export type IssueDescriptionInputProps = {
|
||||
initialValue: string | undefined;
|
||||
disabled?: boolean;
|
||||
issueOperations: TIssueOperations;
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void;
|
||||
swrIssueDescription: string | null | undefined;
|
||||
};
|
||||
@ -106,12 +108,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
|
||||
debouncedFormSave();
|
||||
}}
|
||||
placeholder={
|
||||
placeholder
|
||||
? placeholder
|
||||
: (isFocused) => {
|
||||
if (isFocused) return "Press '/' for commands...";
|
||||
else return "Click to add description";
|
||||
}
|
||||
placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
|
@ -89,8 +89,9 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
layout={displayFilters?.calendar?.layout}
|
||||
showWeekends={displayFilters?.calendar?.show_weekends ?? false}
|
||||
quickActions={(issue, customActionButton, placement) => (
|
||||
quickActions={({ issue, parentRef, customActionButton, placement }) => (
|
||||
<QuickActions
|
||||
parentRef={parentRef}
|
||||
customActionButton={customActionButton}
|
||||
issue={issue}
|
||||
handleDelete={async () => removeIssue(issue.project_id, issue.id)}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// types
|
||||
import type {
|
||||
@ -31,7 +30,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle";
|
||||
import { IModuleIssuesFilter } from "@/store/issue/module";
|
||||
import { IProjectIssuesFilter } from "@/store/issue/project";
|
||||
import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
|
||||
// types
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import type { ICalendarWeek } from "./types";
|
||||
|
||||
type Props = {
|
||||
@ -40,7 +39,7 @@ type Props = {
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
layout: "month" | "week" | undefined;
|
||||
showWeekends: boolean;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
quickAddCallback?: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Droppable } from "@hello-pangea/dnd";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// types
|
||||
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
|
||||
@ -15,13 +14,14 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle";
|
||||
import { IModuleIssuesFilter } from "@/store/issue/module";
|
||||
import { IProjectIssuesFilter } from "@/store/issue/project";
|
||||
import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
|
||||
type Props = {
|
||||
issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter;
|
||||
date: ICalendarDate;
|
||||
issues: TIssueMap | undefined;
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
quickAddCallback?: (
|
||||
|
@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
// components
|
||||
import { TIssue, TIssueMap } from "@plane/types";
|
||||
import { TIssueMap } from "@plane/types";
|
||||
import { CalendarIssueBlock } from "@/components/issues";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
issues: TIssueMap | undefined;
|
||||
issueId: string;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
isDragging?: boolean;
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { TIssue } from "@plane/types";
|
||||
@ -12,25 +11,27 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
// helpers
|
||||
// types
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
isDragging?: boolean;
|
||||
};
|
||||
|
||||
export const CalendarIssueBlock: React.FC<Props> = observer((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
|
||||
const { workspaceSlug, projectId } = useAppRouter();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
|
||||
const { isMobile } = usePlatformOS();
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
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.project_id &&
|
||||
issue.id &&
|
||||
peekIssue?.issueId !== issue.id &&
|
||||
!getIsIssuePeeked(issue.id) &&
|
||||
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||
|
||||
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
|
||||
@ -76,14 +77,13 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={blockRef}
|
||||
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 ",
|
||||
{
|
||||
"border-custom-primary-100 bg-custom-background-90 shadow-custom-shadow-rg": isDragging,
|
||||
},
|
||||
{ "bg-custom-background-100 hover:bg-custom-background-90": !isDragging },
|
||||
{
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issue.id,
|
||||
"bg-custom-background-90 shadow-custom-shadow-rg border-custom-primary-100": isDragging,
|
||||
"bg-custom-background-100 hover:bg-custom-background-90": !isDragging,
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
|
||||
}
|
||||
)}
|
||||
>
|
||||
@ -110,7 +110,12 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{quickActions(issue, customActionButton, placement)}
|
||||
{quickActions({
|
||||
issue,
|
||||
parentRef: blockRef,
|
||||
customActionButton,
|
||||
placement,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// types
|
||||
import { TIssue, TIssueMap } from "@plane/types";
|
||||
@ -8,12 +7,14 @@ import { TIssue, TIssueMap } from "@plane/types";
|
||||
import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
date: Date;
|
||||
issues: TIssueMap | undefined;
|
||||
issueIdList: string[] | null;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
isDragDisabled?: boolean;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
|
||||
// components
|
||||
@ -10,6 +9,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle";
|
||||
import { IModuleIssuesFilter } from "@/store/issue/module";
|
||||
import { IProjectIssuesFilter } from "@/store/issue/project";
|
||||
import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { ICalendarDate, ICalendarWeek } from "./types";
|
||||
|
||||
type Props = {
|
||||
@ -17,7 +17,7 @@ type Props = {
|
||||
issues: TIssueMap | undefined;
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
week: ICalendarWeek | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
quickAddCallback?: (
|
||||
|
@ -18,7 +18,7 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => {
|
||||
const { getProjectStates } = useProjectState();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
peekIssue,
|
||||
getIsIssuePeeked,
|
||||
setPeekIssue,
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
@ -30,7 +30,7 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => {
|
||||
workspaceSlug &&
|
||||
issueDetails &&
|
||||
!issueDetails.tempId &&
|
||||
peekIssue?.issueId !== issueDetails.id &&
|
||||
!getIsIssuePeeked(issueDetails.id) &&
|
||||
setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id });
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
|
@ -4,7 +4,6 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { TIssue } from "@plane/types";
|
||||
// hooks
|
||||
import { Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { DeleteIssueModal } from "@/components/issues";
|
||||
@ -15,7 +14,7 @@ import { useEventTracker, useIssueDetail, useIssues, useKanbanView, useUser } fr
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// ui
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types";
|
||||
//components
|
||||
import { KanBan } from "./default";
|
||||
import { KanBanSwimLanes } from "./swimlanes";
|
||||
@ -168,9 +167,10 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
});
|
||||
};
|
||||
|
||||
const renderQuickActions = useCallback(
|
||||
(issue: TIssue, customActionButton?: React.ReactElement) => (
|
||||
const renderQuickActions: TRenderQuickActions = useCallback(
|
||||
({ issue, parentRef, customActionButton }) => (
|
||||
<QuickActions
|
||||
parentRef={parentRef}
|
||||
customActionButton={customActionButton}
|
||||
issue={issue}
|
||||
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 { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { observer } from "mobx-react-lite";
|
||||
@ -11,6 +11,7 @@ import { useAppRouter, useIssueDetail, useProject, useKanbanView } from "@/hooks
|
||||
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// components
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
// ui
|
||||
@ -18,29 +19,29 @@ import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-
|
||||
// helper
|
||||
|
||||
interface IssueBlockProps {
|
||||
peekIssueId?: string;
|
||||
issueId: string;
|
||||
issuesMap: IIssueMap;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
isDragDisabled: boolean;
|
||||
draggableId: string;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization
|
||||
}
|
||||
|
||||
interface IssueDetailsBlockProps {
|
||||
cardRef: React.RefObject<HTMLElement>;
|
||||
issue: TIssue;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props: IssueDetailsBlockProps) => {
|
||||
const { issue, updateIssue, quickActions, isReadOnly, displayProperties } = props;
|
||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props) => {
|
||||
const { cardRef, issue, updateIssue, quickActions, isReadOnly, displayProperties } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
@ -61,7 +62,10 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||
className="absolute -top-1 right-0 hidden group-hover/kanban-block:block"
|
||||
onClick={handleEventPropagation}
|
||||
>
|
||||
{quickActions(issue)}
|
||||
{quickActions({
|
||||
issue,
|
||||
parentRef: cardRef,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</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 {
|
||||
peekIssueId,
|
||||
issueId,
|
||||
issuesMap,
|
||||
displayProperties,
|
||||
@ -104,17 +107,17 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
issueIds,
|
||||
} = props;
|
||||
|
||||
const cardRef = useRef<HTMLAnchorElement | null>(null);
|
||||
// hooks
|
||||
const { workspaceSlug } = useAppRouter();
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
|
||||
|
||||
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||
workspaceSlug &&
|
||||
issue &&
|
||||
issue.project_id &&
|
||||
issue.id &&
|
||||
peekIssue?.issueId !== issue.id &&
|
||||
!getIsIssuePeeked(issue.id) &&
|
||||
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||
|
||||
const issue = issuesMap[issueId];
|
||||
@ -184,9 +187,11 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
"block rounded border-[0.5px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
|
||||
{ "hover:cursor-pointer": isDragAllowed },
|
||||
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id },
|
||||
{ "bg-custom-background-80 z-[100]": isCurrentBlockDragging }
|
||||
{
|
||||
"hover:cursor-pointer": isDragAllowed,
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
|
||||
"bg-custom-background-80 z-[100]": isCurrentBlockDragging,
|
||||
}
|
||||
)}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
@ -200,6 +205,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
changingReference={issueIds}
|
||||
>
|
||||
<KanbanIssueDetailsBlock
|
||||
cardRef={cardRef}
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
updateIssue={updateIssue}
|
||||
|
@ -2,18 +2,18 @@ import { MutableRefObject, memo } from "react";
|
||||
//types
|
||||
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
||||
import { KanbanIssueBlock } from "@/components/issues";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
// components
|
||||
|
||||
interface IssueBlocksListProps {
|
||||
sub_group_id: string;
|
||||
columnId: string;
|
||||
issuesMap: IIssueMap;
|
||||
peekIssueId?: string;
|
||||
issueIds: string[];
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
isDragDisabled: boolean;
|
||||
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;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
@ -23,7 +23,6 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||
sub_group_id,
|
||||
columnId,
|
||||
issuesMap,
|
||||
peekIssueId,
|
||||
issueIds,
|
||||
displayProperties,
|
||||
isDragDisabled,
|
||||
@ -47,7 +46,6 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||
return (
|
||||
<KanbanIssueBlock
|
||||
key={draggableId}
|
||||
peekIssueId={peekIssueId}
|
||||
issueId={issueId}
|
||||
issuesMap={issuesMap}
|
||||
displayProperties={displayProperties}
|
||||
|
@ -14,18 +14,10 @@ import {
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
// hooks
|
||||
import {
|
||||
useCycle,
|
||||
useIssueDetail,
|
||||
useKanbanView,
|
||||
useLabel,
|
||||
useMember,
|
||||
useModule,
|
||||
useProject,
|
||||
useProjectState,
|
||||
} from "@/hooks/store";
|
||||
import { useCycle, useKanbanView, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
// types
|
||||
// parent components
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
|
||||
// components
|
||||
import { KanbanStoreType } from "./base-kanban-root";
|
||||
@ -42,7 +34,7 @@ export interface IGroupByKanBan {
|
||||
sub_group_id: string;
|
||||
isDragDisabled: boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: any;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
@ -95,7 +87,6 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
const cycle = useCycle();
|
||||
const moduleInfo = useModule();
|
||||
const projectState = useProjectState();
|
||||
const { peekIssue } = useIssueDetail();
|
||||
|
||||
const list = getGroupByColumns(
|
||||
group_by as GroupByColumnTypes,
|
||||
@ -176,7 +167,6 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
groupId={subList.id}
|
||||
issuesMap={issuesMap}
|
||||
issueIds={issueIds}
|
||||
peekIssueId={peekIssue?.issueId ?? ""}
|
||||
displayProperties={displayProperties}
|
||||
sub_group_by={sub_group_by}
|
||||
group_by={group_by}
|
||||
@ -208,7 +198,7 @@ export interface IKanBan {
|
||||
group_by: TIssueGroupByOptions | undefined;
|
||||
sub_group_id?: string;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
showEmptyGroup: boolean;
|
||||
|
@ -17,13 +17,13 @@ import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
//components
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils";
|
||||
import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from ".";
|
||||
|
||||
interface IKanbanGroup {
|
||||
groupId: string;
|
||||
issuesMap: IIssueMap;
|
||||
peekIssueId?: string;
|
||||
issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
sub_group_by: TIssueGroupByOptions | undefined;
|
||||
@ -31,7 +31,7 @@ interface IKanbanGroup {
|
||||
sub_group_id: string;
|
||||
isDragDisabled: boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
quickAddCallback?: (
|
||||
workspaceSlug: string,
|
||||
@ -56,7 +56,6 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
issuesMap,
|
||||
displayProperties,
|
||||
issueIds,
|
||||
peekIssueId,
|
||||
isDragDisabled,
|
||||
updateIssue,
|
||||
quickActions,
|
||||
@ -176,7 +175,6 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
sub_group_id={sub_group_id}
|
||||
columnId={groupId}
|
||||
issuesMap={issuesMap}
|
||||
peekIssueId={peekIssueId}
|
||||
issueIds={(issueIds as TGroupedIssues)?.[groupId] || []}
|
||||
displayProperties={displayProperties}
|
||||
isDragDisabled={isDragDisabled}
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
} from "@plane/types";
|
||||
// components
|
||||
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
|
||||
import { KanbanStoreType } from "./base-kanban-root";
|
||||
import { KanBan } from "./default";
|
||||
@ -106,7 +107,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
showEmptyGroup: boolean;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>;
|
||||
@ -235,7 +236,7 @@ export interface IKanBanSwimLanes {
|
||||
sub_group_by: TIssueGroupByOptions | undefined;
|
||||
group_by: TIssueGroupByOptions | undefined;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
showEmptyGroup: boolean;
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { FC, useCallback } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TIssue } from "@plane/types";
|
||||
// types
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
@ -9,7 +8,7 @@ import { useIssues, useUser } from "@/hooks/store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// components
|
||||
import { List } from "./default";
|
||||
import { IQuickActionProps } from "./list-view-types";
|
||||
import { IQuickActionProps, TRenderQuickActions } from "./list-view-types";
|
||||
// constants
|
||||
// hooks
|
||||
|
||||
@ -69,9 +68,10 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
const group_by = displayFilters?.group_by || null;
|
||||
const showEmptyGroup = displayFilters?.show_empty_groups ?? false;
|
||||
|
||||
const renderQuickActions = useCallback(
|
||||
(issue: TIssue) => (
|
||||
const renderQuickActions: TRenderQuickActions = useCallback(
|
||||
({ issue, parentRef }) => (
|
||||
<QuickActions
|
||||
parentRef={parentRef}
|
||||
issue={issue}
|
||||
handleDelete={async () => removeIssue(issue.project_id, issue.id)}
|
||||
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 { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types";
|
||||
// components
|
||||
// hooks
|
||||
// ui
|
||||
import { Spinner, Tooltip, ControlLink } from "@plane/ui";
|
||||
// helper
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppRouter, useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
import { TRenderQuickActions } from "./list-view-types";
|
||||
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
issuesMap: TIssueMap;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
}
|
||||
|
||||
export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlockProps) => {
|
||||
const { issuesMap, issueId, updateIssue, quickActions, displayProperties, canEditProperties } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// hooks
|
||||
const { workspaceSlug } = useAppRouter();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
|
||||
|
||||
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||
workspaceSlug &&
|
||||
issue &&
|
||||
issue.project_id &&
|
||||
issue.id &&
|
||||
peekIssue?.issueId !== issue.id &&
|
||||
!getIsIssuePeeked(issue.id) &&
|
||||
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||
|
||||
const issue = issuesMap[issueId];
|
||||
@ -44,11 +47,12 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
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,
|
||||
"last:border-b-transparent": peekIssue?.issueId !== issue.id,
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
|
||||
"last:border-b-transparent": !getIsIssuePeeked(issue.id),
|
||||
}
|
||||
)}
|
||||
>
|
||||
@ -86,7 +90,12 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
)}
|
||||
</div>
|
||||
{!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 className="flex flex-shrink-0 items-center gap-2">
|
||||
@ -100,7 +109,12 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
displayProperties={displayProperties}
|
||||
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">
|
||||
|
@ -3,6 +3,7 @@ import { FC, MutableRefObject } from "react";
|
||||
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
|
||||
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
|
||||
import { IssueBlock } from "@/components/issues";
|
||||
import { TRenderQuickActions } from "./list-view-types";
|
||||
// types
|
||||
|
||||
interface Props {
|
||||
@ -10,7 +11,7 @@ interface Props {
|
||||
issuesMap: TIssueMap;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
@ -16,13 +16,14 @@ import { useCycle, useLabel, useMember, useModule, useProject, useProjectState }
|
||||
// utils
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
import { TRenderQuickActions } from "./list-view-types";
|
||||
|
||||
export interface IGroupByList {
|
||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||
issuesMap: TIssueMap;
|
||||
group_by: string | null;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
enableIssueQuickAdd: boolean;
|
||||
showEmptyGroup?: boolean;
|
||||
@ -177,7 +178,7 @@ export interface IList {
|
||||
issuesMap: TIssueMap;
|
||||
group_by: string | null;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
showEmptyGroup: boolean;
|
||||
enableIssueQuickAdd: boolean;
|
||||
|
@ -2,6 +2,7 @@ import { Placement } from "@popperjs/core";
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
export interface IQuickActionProps {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
issue: TIssue;
|
||||
handleDelete: () => Promise<void>;
|
||||
handleUpdate?: (data: TIssue) => Promise<void>;
|
||||
@ -13,3 +14,17 @@ export interface IQuickActionProps {
|
||||
readOnly?: boolean;
|
||||
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 { useRouter } from "next/router";
|
||||
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// hooks
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// ui
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { STATE_GROUPS } from "@/constants/state";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { useEventTracker, useProjectState } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useProjectState } from "@/hooks/store";
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
// constants
|
||||
|
||||
export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||
const {
|
||||
@ -28,6 +29,8 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||
customActionButton,
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
parentRef,
|
||||
} = props;
|
||||
// states
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
@ -68,6 +71,63 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||
["id"]
|
||||
);
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: () => {
|
||||
setTrackElement("Global issues");
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
title: "Make a copy",
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
setTrackElement("Global issues");
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
action: handleOpenInNewTab,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
action: handleCopyIssueLink,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
title: "Archive",
|
||||
description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
action: () => setArchiveIssueModal(true),
|
||||
disabled: !isInArchivableGroup,
|
||||
shouldRender: isArchivingAllowed,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement("Global issues");
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArchiveIssueModal
|
||||
@ -94,89 +154,49 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||
}}
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
placement="bottom-start"
|
||||
ellipsis
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
placement={placements}
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement("Global issues");
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="h-3 w-3" />
|
||||
Copy link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement("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
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement("Global issues");
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -4,13 +4,14 @@ import { useRouter } from "next/router";
|
||||
// icons
|
||||
import { ArchiveRestoreIcon, ExternalLink, Link, Trash2 } from "lucide-react";
|
||||
// ui
|
||||
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssues, useUser } from "@/hooks/store";
|
||||
@ -18,7 +19,16 @@ import { useEventTracker, useIssues, useUser } from "@/hooks/store";
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
|
||||
export const ArchivedIssueQuickActions: React.FC<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
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
// 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 (
|
||||
<>
|
||||
<DeleteIssueModal
|
||||
@ -74,48 +116,49 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
onSubmit={handleDelete}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
placement="bottom-start"
|
||||
ellipsis
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
placement={placements}
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isRestoringAllowed && (
|
||||
<CustomMenu.MenuItem onClick={handleIssueRestore}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveRestoreIcon className="h-3 w-3" />
|
||||
Restore
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="h-3 w-3" />
|
||||
Copy link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete issue
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
|
@ -2,24 +2,24 @@ import { useState } from "react";
|
||||
import omit from "lodash/omit";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
// ui
|
||||
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// icons
|
||||
// ui
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { STATE_GROUPS } from "@/constants/state";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
// constants
|
||||
|
||||
export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||
const {
|
||||
@ -32,6 +32,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
parentRef,
|
||||
} = props;
|
||||
// states
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
@ -80,6 +81,73 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
["id"]
|
||||
);
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: () => {
|
||||
setIssueToEdit({
|
||||
...issue,
|
||||
cycle_id: cycleId?.toString() ?? null,
|
||||
});
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
title: "Make a copy",
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
action: handleOpenInNewTab,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
action: handleCopyIssueLink,
|
||||
},
|
||||
{
|
||||
key: "remove-from-cycle",
|
||||
title: "Remove from cycle",
|
||||
icon: XCircle,
|
||||
action: () => handleRemoveFromView?.(),
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
title: "Archive",
|
||||
description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
action: () => setArchiveIssueModal(true),
|
||||
disabled: !isInArchivableGroup,
|
||||
shouldRender: isArchivingAllowed,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isDeletingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArchiveIssueModal
|
||||
@ -106,104 +174,49 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
}}
|
||||
storeType={EIssuesStoreType.CYCLE}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
ellipsis
|
||||
placement={placements}
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setIssueToEdit({
|
||||
...issue,
|
||||
cycle_id: cycleId?.toString() ?? null,
|
||||
});
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="h-3 w-3" />
|
||||
Copy link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy className="h-3 w-3" />
|
||||
Make a copy
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleRemoveFromView && handleRemoveFromView();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Remove from 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
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isDeletingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -6,19 +6,30 @@ import { Pencil, Trash2 } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui";
|
||||
// components
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constant
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssues, useUser } from "@/hooks/store";
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
|
||||
export const DraftIssueQuickActions: React.FC<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
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
||||
@ -44,6 +55,30 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
["id"]
|
||||
);
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isDeletingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteIssueModal
|
||||
@ -52,7 +87,6 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
onSubmit={handleDelete}
|
||||
/>
|
||||
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={createUpdateIssueModal}
|
||||
onClose={() => {
|
||||
@ -66,43 +100,49 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
isDraft
|
||||
/>
|
||||
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
placement="bottom-start"
|
||||
ellipsis
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
placement={placements}
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isDeletingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
|
@ -2,23 +2,24 @@ import { useState } from "react";
|
||||
import omit from "lodash/omit";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
// ui
|
||||
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// ui
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { STATE_GROUPS } from "@/constants/state";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { useIssues, useEventTracker, useUser, useProjectState } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useIssues, useEventTracker, useUser, useProjectState } from "@/hooks/store";
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
// constants
|
||||
|
||||
export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||
const {
|
||||
@ -31,6 +32,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
parentRef,
|
||||
} = props;
|
||||
// states
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
@ -79,6 +81,70 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||
["id"]
|
||||
);
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: () => {
|
||||
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
title: "Make a copy",
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
action: handleOpenInNewTab,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
action: handleCopyIssueLink,
|
||||
},
|
||||
{
|
||||
key: "remove-from-module",
|
||||
title: "Remove from module",
|
||||
icon: XCircle,
|
||||
action: () => handleRemoveFromView?.(),
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
title: "Archive",
|
||||
description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
action: () => setArchiveIssueModal(true),
|
||||
disabled: !isInArchivableGroup,
|
||||
shouldRender: isArchivingAllowed,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isDeletingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArchiveIssueModal
|
||||
@ -105,103 +171,49 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||
}}
|
||||
storeType={EIssuesStoreType.MODULE}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
ellipsis
|
||||
placement={placements}
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="h-3 w-3" />
|
||||
Copy link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy className="h-3 w-3" />
|
||||
Make a copy
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleRemoveFromView && handleRemoveFromView();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Remove from module
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isArchivingAllowed && (
|
||||
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
|
||||
{isInArchivableGroup ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
Archive
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
<div className="-mt-1">
|
||||
<p>Archive</p>
|
||||
<p className="text-xs text-custom-text-400">
|
||||
Only completed or canceled
|
||||
<br />
|
||||
issues can be archived
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -2,22 +2,24 @@ import { useState } from "react";
|
||||
import omit from "lodash/omit";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// ui
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { STATE_GROUPS } from "@/constants/state";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
|
||||
// ui
|
||||
// components
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
// constant
|
||||
|
||||
export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||
const {
|
||||
@ -28,7 +30,8 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
customActionButton,
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
placements = "bottom-end",
|
||||
parentRef,
|
||||
} = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
@ -56,9 +59,6 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
const isDeletingAllowed = isEditingAllowed;
|
||||
|
||||
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
|
||||
|
||||
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
|
||||
|
||||
const handleCopyIssueLink = () =>
|
||||
copyUrlToClipboard(issueLink).then(() =>
|
||||
setToast({
|
||||
@ -67,6 +67,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
message: "Issue link copied to clipboard",
|
||||
})
|
||||
);
|
||||
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
|
||||
|
||||
const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
|
||||
|
||||
@ -79,6 +80,63 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
["id"]
|
||||
);
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
title: "Make a copy",
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
action: handleOpenInNewTab,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
action: handleCopyIssueLink,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
title: "Archive",
|
||||
description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
action: () => setArchiveIssueModal(true),
|
||||
disabled: !isInArchivableGroup,
|
||||
shouldRender: isArchivingAllowed,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isDeletingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArchiveIssueModal
|
||||
@ -106,89 +164,49 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
isDraft={isDraftIssue}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
ellipsis
|
||||
placement={placements}
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="h-3 w-3" />
|
||||
Copy link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy className="h-3 w-3" />
|
||||
Make a copy
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{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
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isDeletingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -3,7 +3,7 @@ import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { TIssue, IIssueDisplayFilterOptions } from "@plane/types";
|
||||
import { IIssueDisplayFilterOptions } from "@plane/types";
|
||||
// hooks
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
@ -19,6 +19,7 @@ import { EUserProjectRoles } from "@/constants/project";
|
||||
import { useCommandPalette, useEventTracker, useGlobalView, useIssues, useProject, useUser } from "@/hooks/store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
|
||||
export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
@ -127,9 +128,10 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
[updateFilters, workspaceSlug, globalViewId]
|
||||
);
|
||||
|
||||
const renderQuickActions = useCallback(
|
||||
(issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => (
|
||||
const renderQuickActions: TRenderQuickActions = useCallback(
|
||||
({ issue, parentRef, customActionButton, placement, portalElement }) => (
|
||||
<AllIssueQuickActions
|
||||
parentRef={parentRef}
|
||||
customActionButton={customActionButton}
|
||||
issue={issue}
|
||||
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)}
|
||||
portalElement={portalElement}
|
||||
readOnly={!canEditProperties(issue.project_id)}
|
||||
placements={placement}
|
||||
/>
|
||||
),
|
||||
[canEditProperties, removeIssue, updateIssue, archiveIssue]
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { FC, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { TIssue, IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types";
|
||||
import { IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types";
|
||||
// hooks
|
||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
@ -10,7 +10,7 @@ import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// views
|
||||
// types
|
||||
// constants
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types";
|
||||
import { SpreadsheetView } from "./spreadsheet-view";
|
||||
|
||||
export type SpreadsheetStoreType =
|
||||
@ -66,9 +66,10 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => {
|
||||
[projectId, updateFilters]
|
||||
);
|
||||
|
||||
const renderQuickActions = useCallback(
|
||||
(issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => (
|
||||
const renderQuickActions: TRenderQuickActions = useCallback(
|
||||
({ issue, parentRef, customActionButton, placement, portalElement }) => (
|
||||
<QuickActions
|
||||
parentRef={parentRef}
|
||||
customActionButton={customActionButton}
|
||||
issue={issue}
|
||||
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)}
|
||||
portalElement={portalElement}
|
||||
readOnly={!isEditingAllowed || isCompletedCycle}
|
||||
placements={placement}
|
||||
/>
|
||||
),
|
||||
[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";
|
||||
// types
|
||||
// local components
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import { IssueColumn } from "./issue-column";
|
||||
|
||||
interface Props {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
isEstimateEnabled: boolean;
|
||||
quickActions: (
|
||||
issue: TIssue,
|
||||
customActionButton?: React.ReactElement,
|
||||
portalElement?: HTMLDivElement | null
|
||||
) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
@ -112,11 +109,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
interface IssueRowDetailsProps {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
isEstimateEnabled: boolean;
|
||||
quickActions: (
|
||||
issue: TIssue,
|
||||
customActionButton?: React.ReactElement,
|
||||
portalElement?: HTMLDivElement | null
|
||||
) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
@ -143,23 +136,25 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
setExpanded,
|
||||
spreadsheetColumnsList,
|
||||
} = props;
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
// refs
|
||||
const cellRef = useRef(null);
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
//hooks
|
||||
// hooks
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
|
||||
const { isMobile } = usePlatformOS();
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||
workspaceSlug &&
|
||||
issue &&
|
||||
issue.project_id &&
|
||||
issue.id &&
|
||||
peekIssue?.issueId !== issue.id &&
|
||||
!getIsIssuePeeked(issue.id) &&
|
||||
setPeekIssue({ workspaceSlug: workspaceSlug.toString(), projectId: issue.project_id, issueId: issue.id });
|
||||
|
||||
const { subIssues: subIssuesStore, issue } = useIssueDetail();
|
||||
@ -196,16 +191,13 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
ref={cellRef}
|
||||
id={`issue-${issueDetail.id}`}
|
||||
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",
|
||||
{
|
||||
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
|
||||
},
|
||||
{
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issueDetail.id,
|
||||
},
|
||||
"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]": !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,
|
||||
}
|
||||
)}
|
||||
@ -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">
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
@ -226,7 +218,12 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
</span>
|
||||
|
||||
<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>
|
||||
|
||||
|
@ -4,6 +4,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@pl
|
||||
//types
|
||||
import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigation";
|
||||
//components
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { SpreadsheetIssueRow } from "./issue-row";
|
||||
import { SpreadsheetHeader } from "./spreadsheet-header";
|
||||
|
||||
@ -13,11 +14,7 @@ type Props = {
|
||||
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
issueIds: string[];
|
||||
isEstimateEnabled: boolean;
|
||||
quickActions: (
|
||||
issue: TIssue,
|
||||
customActionButton?: React.ReactElement,
|
||||
portalElement?: HTMLDivElement | null
|
||||
) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
|
@ -6,6 +6,7 @@ import { Spinner } from "@plane/ui";
|
||||
import { SpreadsheetQuickAddIssueForm } from "@/components/issues";
|
||||
import { SPREADSHEET_PROPERTY_LIST } from "@/constants/spreadsheet";
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { SpreadsheetTable } from "./spreadsheet-table";
|
||||
// types
|
||||
//hooks
|
||||
@ -15,11 +16,7 @@ type Props = {
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
issueIds: string[] | undefined;
|
||||
quickActions: (
|
||||
issue: TIssue,
|
||||
customActionButton?: React.ReactElement,
|
||||
portalElement?: HTMLDivElement | null
|
||||
) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
quickAddCallback?: (
|
||||
|
@ -25,7 +25,7 @@ import { IssueLabelSelect } from "@/components/issues/select";
|
||||
import { CreateLabelModal } from "@/components/labels";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper";
|
||||
import { getChangedIssuefields } from "@/helpers/issue.helper";
|
||||
import { getChangedIssuefields, getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
import { shouldRenderProject } from "@/helpers/project.helper";
|
||||
// hooks
|
||||
import { useAppRouter, useEstimate, useInstance, useIssueDetail, useProject, useWorkspace } from "@/hooks/store";
|
||||
@ -468,17 +468,13 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
workspaceSlug={workspaceSlug?.toString() as string}
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
// dragDropEnabled={false}
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
handleFormChange();
|
||||
}}
|
||||
ref={editorRef}
|
||||
tabIndex={getTabIndex("description_html")}
|
||||
placeholder={(isFocused) => {
|
||||
if (isFocused) return "Press '/' for commands...";
|
||||
else return "Click to add description";
|
||||
}}
|
||||
placeholder={getDescriptionPlaceholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user