mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
commit
da957e06b6
84
.github/workflows/auto-merge.yml
vendored
84
.github/workflows/auto-merge.yml
vendored
@ -1,84 +0,0 @@
|
|||||||
name: Auto Merge or Create PR on Push
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "sync/**"
|
|
||||||
|
|
||||||
env:
|
|
||||||
CURRENT_BRANCH: ${{ github.ref_name }}
|
|
||||||
SOURCE_BRANCH: ${{ secrets.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
|
|
||||||
TARGET_BRANCH: ${{ secrets.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: ${{ secrets.SYNC_PR_REVIEWER }}
|
|
||||||
|
|
||||||
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 "GitHub Actions"
|
|
||||||
git config user.email "actions@github.com"
|
|
||||||
|
|
||||||
- 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: Check for merge conflicts
|
|
||||||
id: conflicts
|
|
||||||
run: |
|
|
||||||
git fetch origin $TARGET_BRANCH
|
|
||||||
git checkout $TARGET_BRANCH
|
|
||||||
# Attempt to merge the main branch into the current branch
|
|
||||||
if $(git merge --no-commit --no-ff $SOURCE_BRANCH); then
|
|
||||||
echo "No merge conflicts detected."
|
|
||||||
echo "HAS_CONFLICTS=false" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "Merge conflicts detected."
|
|
||||||
echo "HAS_CONFLICTS=true" >> $GITHUB_ENV
|
|
||||||
git merge --abort
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Merge Change to Target Branch
|
|
||||||
if: env.HAS_CONFLICTS == 'false'
|
|
||||||
run: |
|
|
||||||
git commit -m "Merge branch '$SOURCE_BRANCH' into $TARGET_BRANCH"
|
|
||||||
git push origin $TARGET_BRANCH
|
|
||||||
|
|
||||||
- name: Create PR to Target Branch
|
|
||||||
if: env.HAS_CONFLICTS == 'true'
|
|
||||||
run: |
|
|
||||||
# Replace 'username' with the actual GitHub username of the reviewer.
|
|
||||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "" --reviewer $REVIEWER)
|
|
||||||
echo "Pull Request created: $PR_URL"
|
|
74
.github/workflows/create-sync-pr.yml
vendored
74
.github/workflows/create-sync-pr.yml
vendored
@ -1,28 +1,53 @@
|
|||||||
name: Create Sync Action
|
name: Create PR on Sync
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- preview
|
- "sync/**"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
|
CURRENT_BRANCH: ${{ github.ref_name }}
|
||||||
|
SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
|
||||||
|
TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
|
||||||
|
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
|
||||||
|
REVIEWER: ${{ vars.SYNC_PR_REVIEWER }}
|
||||||
|
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
|
||||||
|
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync_changes:
|
Check_Branch:
|
||||||
|
runs-on: ubuntu-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
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: read
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.1.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
fetch-depth: 0 # Fetch all history for all branches and tags
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup GH CLI
|
- name: Setup Git
|
||||||
|
run: |
|
||||||
|
git config user.name "$ACCOUNT_USER_NAME"
|
||||||
|
git config user.email "$ACCOUNT_USER_EMAIL"
|
||||||
|
|
||||||
|
- name: Setup GH CLI and Git Config
|
||||||
run: |
|
run: |
|
||||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||||
@ -31,25 +56,14 @@ jobs:
|
|||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install gh -y
|
sudo apt install gh -y
|
||||||
|
|
||||||
- name: Push Changes to Target Repo A
|
- name: Create PR to Target Branch
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
TARGET_REPO="${{ secrets.TARGET_REPO_A }}"
|
# get all pull requests and check if there is already a PR
|
||||||
TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}"
|
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number')
|
||||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
if [ -n "$PR_EXISTS" ]; then
|
||||||
|
echo "Pull Request already exists: $PR_EXISTS"
|
||||||
git checkout $SOURCE_BRANCH
|
else
|
||||||
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
echo "Creating new pull request"
|
||||||
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
|
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "")
|
||||||
|
echo "Pull Request created: $PR_URL"
|
||||||
- name: Push Changes to Target Repo B
|
fi
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
|
||||||
run: |
|
|
||||||
TARGET_REPO="${{ secrets.TARGET_REPO_B }}"
|
|
||||||
TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}"
|
|
||||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
|
||||||
|
|
||||||
git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
|
||||||
git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH
|
|
||||||
|
44
.github/workflows/repo-sync.yml
vendored
Normal file
44
.github/workflows/repo-sync.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
name: Sync Repositories
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- preview
|
||||||
|
|
||||||
|
env:
|
||||||
|
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync_changes:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup GH CLI
|
||||||
|
run: |
|
||||||
|
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||||
|
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||||
|
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install gh -y
|
||||||
|
|
||||||
|
- name: Push Changes to Target Repo
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
|
||||||
|
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
|
||||||
|
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||||
|
|
||||||
|
git checkout $SOURCE_BRANCH
|
||||||
|
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||||
|
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
|
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "plane-api",
|
"name": "plane-api",
|
||||||
"version": "0.18.0"
|
"version": "0.19.0"
|
||||||
}
|
}
|
||||||
|
@ -6,9 +6,15 @@ from plane.api.views import (
|
|||||||
IssueLinkAPIEndpoint,
|
IssueLinkAPIEndpoint,
|
||||||
IssueCommentAPIEndpoint,
|
IssueCommentAPIEndpoint,
|
||||||
IssueActivityAPIEndpoint,
|
IssueActivityAPIEndpoint,
|
||||||
|
WorkspaceIssueAPIEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/issues/<str:project__identifier>-<str:issue__identifier>/",
|
||||||
|
WorkspaceIssueAPIEndpoint.as_view(),
|
||||||
|
name="issue-by-identifier",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
|
||||||
IssueAPIEndpoint.as_view(),
|
IssueAPIEndpoint.as_view(),
|
||||||
|
@ -3,6 +3,7 @@ from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
|
|||||||
from .state import StateAPIEndpoint
|
from .state import StateAPIEndpoint
|
||||||
|
|
||||||
from .issue import (
|
from .issue import (
|
||||||
|
WorkspaceIssueAPIEndpoint,
|
||||||
IssueAPIEndpoint,
|
IssueAPIEndpoint,
|
||||||
LabelAPIEndpoint,
|
LabelAPIEndpoint,
|
||||||
IssueLinkAPIEndpoint,
|
IssueLinkAPIEndpoint,
|
||||||
|
@ -51,6 +51,65 @@ from plane.db.models import (
|
|||||||
from .base import BaseAPIView, WebhookMixin
|
from .base import BaseAPIView, WebhookMixin
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||||
|
"""
|
||||||
|
This viewset provides `retrieveByIssueId` on workspace level
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
model = Issue
|
||||||
|
webhook_event = "issue"
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission
|
||||||
|
]
|
||||||
|
serializer_class = IssueSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def project__identifier(self):
|
||||||
|
return self.kwargs.get("project__identifier", None)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return (
|
||||||
|
Issue.issue_objects.annotate(
|
||||||
|
sub_issues_count=Issue.issue_objects.filter(
|
||||||
|
parent=OuterRef("id")
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(project__identifier=self.kwargs.get("project__identifier"))
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("state")
|
||||||
|
.select_related("parent")
|
||||||
|
.prefetch_related("assignees")
|
||||||
|
.prefetch_related("labels")
|
||||||
|
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
def get(self, request, slug, project__identifier=None, issue__identifier=None):
|
||||||
|
if issue__identifier and project__identifier:
|
||||||
|
issue = Issue.issue_objects.annotate(
|
||||||
|
sub_issues_count=Issue.issue_objects.filter(
|
||||||
|
parent=OuterRef("id")
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
).get(workspace__slug=slug, project__identifier=project__identifier, sequence_id=issue__identifier)
|
||||||
|
return Response(
|
||||||
|
IssueSerializer(
|
||||||
|
issue,
|
||||||
|
fields=self.fields,
|
||||||
|
expand=self.expand,
|
||||||
|
).data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||||
"""
|
"""
|
||||||
This viewset automatically provides `list`, `create`, `retrieve`,
|
This viewset automatically provides `list`, `create`, `retrieve`,
|
||||||
@ -282,7 +341,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
)
|
)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
if (
|
if (
|
||||||
str(request.data.get("external_id"))
|
request.data.get("external_id")
|
||||||
and (issue.external_id != str(request.data.get("external_id")))
|
and (issue.external_id != str(request.data.get("external_id")))
|
||||||
and Issue.objects.filter(
|
and Issue.objects.filter(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
@ -79,6 +79,16 @@ class ProjectEntityPermission(BasePermission):
|
|||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Handle requests based on project__identifier
|
||||||
|
if hasattr(view, "project__identifier") and view.project__identifier:
|
||||||
|
if request.method in SAFE_METHODS:
|
||||||
|
return ProjectMember.objects.filter(
|
||||||
|
workspace__slug=view.workspace_slug,
|
||||||
|
member=request.user,
|
||||||
|
project__identifier=view.project__identifier,
|
||||||
|
is_active=True,
|
||||||
|
).exists()
|
||||||
|
|
||||||
## Safe Methods -> Handle the filtering logic in queryset
|
## Safe Methods -> Handle the filtering logic in queryset
|
||||||
if request.method in SAFE_METHODS:
|
if request.method in SAFE_METHODS:
|
||||||
return ProjectMember.objects.filter(
|
return ProjectMember.objects.filter(
|
||||||
|
@ -210,6 +210,7 @@ class ModuleSerializer(DynamicBaseSerializer):
|
|||||||
"backlog_issues",
|
"backlog_issues",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
|
"archived_at",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ from .workspace.base import (
|
|||||||
WorkSpaceAvailabilityCheckEndpoint,
|
WorkSpaceAvailabilityCheckEndpoint,
|
||||||
UserWorkspaceDashboardEndpoint,
|
UserWorkspaceDashboardEndpoint,
|
||||||
WorkspaceThemeViewSet,
|
WorkspaceThemeViewSet,
|
||||||
ExportWorkspaceUserActivityEndpoint
|
ExportWorkspaceUserActivityEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .workspace.member import (
|
from .workspace.member import (
|
||||||
@ -91,12 +91,14 @@ from .cycle.base import (
|
|||||||
CycleDateCheckEndpoint,
|
CycleDateCheckEndpoint,
|
||||||
CycleFavoriteViewSet,
|
CycleFavoriteViewSet,
|
||||||
TransferCycleIssueEndpoint,
|
TransferCycleIssueEndpoint,
|
||||||
CycleArchiveUnarchiveEndpoint,
|
|
||||||
CycleUserPropertiesEndpoint,
|
CycleUserPropertiesEndpoint,
|
||||||
)
|
)
|
||||||
from .cycle.issue import (
|
from .cycle.issue import (
|
||||||
CycleIssueViewSet,
|
CycleIssueViewSet,
|
||||||
)
|
)
|
||||||
|
from .cycle.archive import (
|
||||||
|
CycleArchiveUnarchiveEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||||
from .issue.base import (
|
from .issue.base import (
|
||||||
@ -170,7 +172,6 @@ from .module.base import (
|
|||||||
ModuleViewSet,
|
ModuleViewSet,
|
||||||
ModuleLinkViewSet,
|
ModuleLinkViewSet,
|
||||||
ModuleFavoriteViewSet,
|
ModuleFavoriteViewSet,
|
||||||
ModuleArchiveUnarchiveEndpoint,
|
|
||||||
ModuleUserPropertiesEndpoint,
|
ModuleUserPropertiesEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -178,6 +179,10 @@ from .module.issue import (
|
|||||||
ModuleIssueViewSet,
|
ModuleIssueViewSet,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .module.archive import (
|
||||||
|
ModuleArchiveUnarchiveEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
from .api import ApiTokenEndpoint
|
from .api import ApiTokenEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
409
apiserver/plane/app/views/cycle/archive.py
Normal file
409
apiserver/plane/app/views/cycle/archive.py
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
# Django imports
|
||||||
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
from django.db.models import (
|
||||||
|
Case,
|
||||||
|
CharField,
|
||||||
|
Count,
|
||||||
|
Exists,
|
||||||
|
F,
|
||||||
|
Func,
|
||||||
|
OuterRef,
|
||||||
|
Prefetch,
|
||||||
|
Q,
|
||||||
|
UUIDField,
|
||||||
|
Value,
|
||||||
|
When,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
|
from plane.db.models import (
|
||||||
|
Cycle,
|
||||||
|
CycleFavorite,
|
||||||
|
Issue,
|
||||||
|
Label,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .. import BaseAPIView
|
||||||
|
|
||||||
|
|
||||||
|
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||||
|
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
favorite_subquery = CycleFavorite.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
cycle_id=OuterRef("pk"),
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
|
.filter(archived_at__isnull=False)
|
||||||
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
|
.filter(project__archived_at__isnull=True)
|
||||||
|
.select_related("project", "workspace", "owned_by")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_cycle__issue__assignees",
|
||||||
|
queryset=User.objects.only(
|
||||||
|
"avatar", "first_name", "id"
|
||||||
|
).distinct(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_cycle__issue__labels",
|
||||||
|
queryset=Label.objects.only(
|
||||||
|
"name", "color", "id"
|
||||||
|
).distinct(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(is_favorite=Exists(favorite_subquery))
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="completed",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="cancelled",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="started",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="unstarted",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="backlog",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
status=Case(
|
||||||
|
When(
|
||||||
|
Q(start_date__lte=timezone.now())
|
||||||
|
& Q(end_date__gte=timezone.now()),
|
||||||
|
then=Value("CURRENT"),
|
||||||
|
),
|
||||||
|
When(
|
||||||
|
start_date__gt=timezone.now(), then=Value("UPCOMING")
|
||||||
|
),
|
||||||
|
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||||
|
When(
|
||||||
|
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||||
|
then=Value("DRAFT"),
|
||||||
|
),
|
||||||
|
default=Value("DRAFT"),
|
||||||
|
output_field=CharField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue_cycle__issue__assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(
|
||||||
|
issue_cycle__issue__assignees__id__isnull=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "name")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id, pk=None):
|
||||||
|
if pk is None:
|
||||||
|
queryset = (
|
||||||
|
self.get_queryset()
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_cycle",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
# necessary fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
# meta fields
|
||||||
|
"total_issues",
|
||||||
|
"is_favorite",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
"archived_at",
|
||||||
|
)
|
||||||
|
).order_by("-is_favorite", "-created_at")
|
||||||
|
return Response(queryset, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
queryset = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(archived_at__isnull=False)
|
||||||
|
.filter(pk=pk)
|
||||||
|
)
|
||||||
|
data = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(pk=pk)
|
||||||
|
.annotate(
|
||||||
|
sub_issues=Issue.issue_objects.filter(
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
parent__isnull=False,
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
# necessary fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
"sub_issues",
|
||||||
|
# meta fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
queryset = queryset.first()
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return Response(
|
||||||
|
{"error": "Cycle does not exist"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assignee Distribution
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(first_name=F("assignees__first_name"))
|
||||||
|
.annotate(last_name=F("assignees__last_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.values(
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"assignee_id",
|
||||||
|
"avatar",
|
||||||
|
"display_name",
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("first_name", "last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Label Distribution
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
data["distribution"] = {
|
||||||
|
"assignees": assignee_distribution,
|
||||||
|
"labels": label_distribution,
|
||||||
|
"completion_chart": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if queryset.start_date and queryset.end_date:
|
||||||
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
|
queryset=queryset,
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
cycle_id=pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, cycle_id):
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
|
||||||
|
if cycle.end_date >= timezone.now().date():
|
||||||
|
return Response(
|
||||||
|
{"error": "Only completed cycles can be archived"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
cycle.archived_at = timezone.now()
|
||||||
|
cycle.save()
|
||||||
|
return Response(
|
||||||
|
{"archived_at": str(cycle.archived_at)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, request, slug, project_id, cycle_id):
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
cycle.archived_at = None
|
||||||
|
cycle.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
@ -1,10 +1,9 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
# Django imports
|
||||||
from django.contrib.postgres.aggregates import ArrayAgg
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
|
||||||
# Django imports
|
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Case,
|
Case,
|
||||||
CharField,
|
CharField,
|
||||||
@ -25,7 +24,6 @@ from django.utils import timezone
|
|||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
ProjectLitePermission,
|
ProjectLitePermission,
|
||||||
@ -686,380 +684,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
ProjectEntityPermission,
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
favorite_subquery = CycleFavorite.objects.filter(
|
|
||||||
user=self.request.user,
|
|
||||||
cycle_id=OuterRef("pk"),
|
|
||||||
project_id=self.kwargs.get("project_id"),
|
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
|
||||||
.filter(archived_at__isnull=False)
|
|
||||||
.filter(
|
|
||||||
project__project_projectmember__member=self.request.user,
|
|
||||||
project__project_projectmember__is_active=True,
|
|
||||||
)
|
|
||||||
.filter(project__archived_at__isnull=True)
|
|
||||||
.select_related("project", "workspace", "owned_by")
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_cycle__issue__assignees",
|
|
||||||
queryset=User.objects.only(
|
|
||||||
"avatar", "first_name", "id"
|
|
||||||
).distinct(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_cycle__issue__labels",
|
|
||||||
queryset=Label.objects.only(
|
|
||||||
"name", "color", "id"
|
|
||||||
).distinct(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(is_favorite=Exists(favorite_subquery))
|
|
||||||
.annotate(
|
|
||||||
total_issues=Count(
|
|
||||||
"issue_cycle__issue__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
completed_issues=Count(
|
|
||||||
"issue_cycle__issue__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__state__group="completed",
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
cancelled_issues=Count(
|
|
||||||
"issue_cycle__issue__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__state__group="cancelled",
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
started_issues=Count(
|
|
||||||
"issue_cycle__issue__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__state__group="started",
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
unstarted_issues=Count(
|
|
||||||
"issue_cycle__issue__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__state__group="unstarted",
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
backlog_issues=Count(
|
|
||||||
"issue_cycle__issue__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__state__group="backlog",
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
status=Case(
|
|
||||||
When(
|
|
||||||
Q(start_date__lte=timezone.now())
|
|
||||||
& Q(end_date__gte=timezone.now()),
|
|
||||||
then=Value("CURRENT"),
|
|
||||||
),
|
|
||||||
When(
|
|
||||||
start_date__gt=timezone.now(), then=Value("UPCOMING")
|
|
||||||
),
|
|
||||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
|
||||||
When(
|
|
||||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
|
||||||
then=Value("DRAFT"),
|
|
||||||
),
|
|
||||||
default=Value("DRAFT"),
|
|
||||||
output_field=CharField(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
assignee_ids=Coalesce(
|
|
||||||
ArrayAgg(
|
|
||||||
"issue_cycle__issue__assignees__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=~Q(
|
|
||||||
issue_cycle__issue__assignees__id__isnull=True
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Value([], output_field=ArrayField(UUIDField())),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by("-is_favorite", "name")
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, request, slug, project_id, pk=None):
|
|
||||||
if pk is None:
|
|
||||||
queryset = (
|
|
||||||
self.get_queryset()
|
|
||||||
.annotate(
|
|
||||||
total_issues=Count(
|
|
||||||
"issue_cycle",
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.values(
|
|
||||||
# necessary fields
|
|
||||||
"id",
|
|
||||||
"workspace_id",
|
|
||||||
"project_id",
|
|
||||||
# model fields
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"start_date",
|
|
||||||
"end_date",
|
|
||||||
"owned_by_id",
|
|
||||||
"view_props",
|
|
||||||
"sort_order",
|
|
||||||
"external_source",
|
|
||||||
"external_id",
|
|
||||||
"progress_snapshot",
|
|
||||||
# meta fields
|
|
||||||
"total_issues",
|
|
||||||
"is_favorite",
|
|
||||||
"cancelled_issues",
|
|
||||||
"completed_issues",
|
|
||||||
"started_issues",
|
|
||||||
"unstarted_issues",
|
|
||||||
"backlog_issues",
|
|
||||||
"assignee_ids",
|
|
||||||
"status",
|
|
||||||
"archived_at",
|
|
||||||
)
|
|
||||||
).order_by("-is_favorite", "-created_at")
|
|
||||||
return Response(queryset, status=status.HTTP_200_OK)
|
|
||||||
else:
|
|
||||||
queryset = (
|
|
||||||
self.get_queryset()
|
|
||||||
.filter(archived_at__isnull=False)
|
|
||||||
.filter(pk=pk)
|
|
||||||
)
|
|
||||||
data = (
|
|
||||||
self.get_queryset()
|
|
||||||
.filter(pk=pk)
|
|
||||||
.annotate(
|
|
||||||
sub_issues=Issue.issue_objects.filter(
|
|
||||||
project_id=self.kwargs.get("project_id"),
|
|
||||||
parent__isnull=False,
|
|
||||||
issue_cycle__cycle_id=pk,
|
|
||||||
)
|
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
|
||||||
.values(
|
|
||||||
# necessary fields
|
|
||||||
"id",
|
|
||||||
"workspace_id",
|
|
||||||
"project_id",
|
|
||||||
# model fields
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"start_date",
|
|
||||||
"end_date",
|
|
||||||
"owned_by_id",
|
|
||||||
"view_props",
|
|
||||||
"sort_order",
|
|
||||||
"external_source",
|
|
||||||
"external_id",
|
|
||||||
"progress_snapshot",
|
|
||||||
"sub_issues",
|
|
||||||
# meta fields
|
|
||||||
"is_favorite",
|
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
|
||||||
"completed_issues",
|
|
||||||
"started_issues",
|
|
||||||
"unstarted_issues",
|
|
||||||
"backlog_issues",
|
|
||||||
"assignee_ids",
|
|
||||||
"status",
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
queryset = queryset.first()
|
|
||||||
|
|
||||||
if data is None:
|
|
||||||
return Response(
|
|
||||||
{"error": "Cycle does not exist"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assignee Distribution
|
|
||||||
assignee_distribution = (
|
|
||||||
Issue.objects.filter(
|
|
||||||
issue_cycle__cycle_id=pk,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
.annotate(first_name=F("assignees__first_name"))
|
|
||||||
.annotate(last_name=F("assignees__last_name"))
|
|
||||||
.annotate(assignee_id=F("assignees__id"))
|
|
||||||
.annotate(avatar=F("assignees__avatar"))
|
|
||||||
.annotate(display_name=F("assignees__display_name"))
|
|
||||||
.values(
|
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"assignee_id",
|
|
||||||
"avatar",
|
|
||||||
"display_name",
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
total_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
completed_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(
|
|
||||||
completed_at__isnull=False,
|
|
||||||
archived_at__isnull=True,
|
|
||||||
is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
pending_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(
|
|
||||||
completed_at__isnull=True,
|
|
||||||
archived_at__isnull=True,
|
|
||||||
is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by("first_name", "last_name")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Label Distribution
|
|
||||||
label_distribution = (
|
|
||||||
Issue.objects.filter(
|
|
||||||
issue_cycle__cycle_id=pk,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
.annotate(label_name=F("labels__name"))
|
|
||||||
.annotate(color=F("labels__color"))
|
|
||||||
.annotate(label_id=F("labels__id"))
|
|
||||||
.values("label_name", "color", "label_id")
|
|
||||||
.annotate(
|
|
||||||
total_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
completed_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(
|
|
||||||
completed_at__isnull=False,
|
|
||||||
archived_at__isnull=True,
|
|
||||||
is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
pending_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(
|
|
||||||
completed_at__isnull=True,
|
|
||||||
archived_at__isnull=True,
|
|
||||||
is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by("label_name")
|
|
||||||
)
|
|
||||||
|
|
||||||
data["distribution"] = {
|
|
||||||
"assignees": assignee_distribution,
|
|
||||||
"labels": label_distribution,
|
|
||||||
"completion_chart": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
if queryset.start_date and queryset.end_date:
|
|
||||||
data["distribution"]["completion_chart"] = burndown_plot(
|
|
||||||
queryset=queryset,
|
|
||||||
slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
cycle_id=pk,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
data,
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
def post(self, request, slug, project_id, cycle_id):
|
|
||||||
cycle = Cycle.objects.get(
|
|
||||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
|
||||||
)
|
|
||||||
|
|
||||||
if cycle.end_date >= timezone.now().date():
|
|
||||||
return Response(
|
|
||||||
{"error": "Only completed cycles can be archived"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
cycle.archived_at = timezone.now()
|
|
||||||
cycle.save()
|
|
||||||
return Response(
|
|
||||||
{"archived_at": str(cycle.archived_at)},
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete(self, request, slug, project_id, cycle_id):
|
|
||||||
cycle = Cycle.objects.get(
|
|
||||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
|
||||||
)
|
|
||||||
cycle.archived_at = None
|
|
||||||
cycle.save()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
class CycleDateCheckEndpoint(BaseAPIView):
|
class CycleDateCheckEndpoint(BaseAPIView):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
|
@ -38,7 +38,7 @@ from plane.db.models import (
|
|||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||||
|
|
||||||
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||||
serializer_class = CycleIssueSerializer
|
serializer_class = CycleIssueSerializer
|
||||||
@ -191,6 +191,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"is_draft",
|
"is_draft",
|
||||||
"archived_at",
|
"archived_at",
|
||||||
)
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
issues = user_timezone_converter(
|
||||||
|
issues, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
|
|
||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def create(self, request, slug, project_id, cycle_id):
|
def create(self, request, slug, project_id, cycle_id):
|
||||||
|
@ -571,14 +571,16 @@ def dashboard_recent_collaborators(self, request, slug):
|
|||||||
return self.paginate(
|
return self.paginate(
|
||||||
request=request,
|
request=request,
|
||||||
queryset=project_members_with_activities,
|
queryset=project_members_with_activities,
|
||||||
controller=self.get_results_controller,
|
controller=lambda qs: self.get_results_controller(qs, slug),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DashboardEndpoint(BaseAPIView):
|
class DashboardEndpoint(BaseAPIView):
|
||||||
def get_results_controller(self, project_members_with_activities):
|
def get_results_controller(self, project_members_with_activities, slug):
|
||||||
user_active_issue_counts = (
|
user_active_issue_counts = (
|
||||||
User.objects.filter(id__in=project_members_with_activities)
|
User.objects.filter(
|
||||||
|
id__in=project_members_with_activities,
|
||||||
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
active_issue_count=Count(
|
active_issue_count=Count(
|
||||||
Case(
|
Case(
|
||||||
@ -587,10 +589,13 @@ class DashboardEndpoint(BaseAPIView):
|
|||||||
"unstarted",
|
"unstarted",
|
||||||
"started",
|
"started",
|
||||||
],
|
],
|
||||||
then=1,
|
issue_assignee__issue__workspace__slug=slug,
|
||||||
|
issue_assignee__issue__project__project_projectmember__is_active=True,
|
||||||
|
then=F("issue_assignee__issue__id"),
|
||||||
),
|
),
|
||||||
output_field=IntegerField(),
|
output_field=IntegerField(),
|
||||||
)
|
),
|
||||||
|
distinct=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.values("active_issue_count", user_id=F("id"))
|
.values("active_issue_count", user_id=F("id"))
|
||||||
|
@ -47,7 +47,7 @@ from plane.db.models import (
|
|||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||||
|
|
||||||
class IssueArchiveViewSet(BaseViewSet):
|
class IssueArchiveViewSet(BaseViewSet):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
@ -239,6 +239,11 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||||||
"is_draft",
|
"is_draft",
|
||||||
"archived_at",
|
"archived_at",
|
||||||
)
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
issues = user_timezone_converter(
|
||||||
|
issue_queryset, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
|
|
||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk=None):
|
def retrieve(self, request, slug, project_id, pk=None):
|
||||||
|
@ -50,6 +50,7 @@ from plane.db.models import (
|
|||||||
Project,
|
Project,
|
||||||
)
|
)
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
||||||
@ -241,6 +242,10 @@ class IssueListEndpoint(BaseAPIView):
|
|||||||
"is_draft",
|
"is_draft",
|
||||||
"archived_at",
|
"archived_at",
|
||||||
)
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
issues = user_timezone_converter(
|
||||||
|
issues, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
@ -440,6 +445,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"is_draft",
|
"is_draft",
|
||||||
"archived_at",
|
"archived_at",
|
||||||
)
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
issues = user_timezone_converter(
|
||||||
|
issue_queryset, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def create(self, request, slug, project_id):
|
def create(self, request, slug, project_id):
|
||||||
@ -503,6 +512,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
issue = user_timezone_converter(
|
||||||
|
issue, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
return Response(issue, status=status.HTTP_201_CREATED)
|
return Response(issue, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
# Django imports
|
||||||
from django.contrib.postgres.aggregates import ArrayAgg
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
@ -19,14 +20,12 @@ from django.db.models import (
|
|||||||
When,
|
When,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
# Django imports
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
from rest_framework import status
|
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plane.app.permissions import ProjectEntityPermission
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
@ -46,6 +45,7 @@ from plane.db.models import (
|
|||||||
Project,
|
Project,
|
||||||
)
|
)
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .. import BaseViewSet
|
from .. import BaseViewSet
|
||||||
@ -230,6 +230,10 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
"is_draft",
|
"is_draft",
|
||||||
"archived_at",
|
"archived_at",
|
||||||
)
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
issues = user_timezone_converter(
|
||||||
|
issue_queryset, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def create(self, request, slug, project_id):
|
def create(self, request, slug, project_id):
|
||||||
|
@ -31,6 +31,7 @@ from plane.db.models import (
|
|||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
|
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
@ -132,6 +133,10 @@ class SubIssuesEndpoint(BaseAPIView):
|
|||||||
"is_draft",
|
"is_draft",
|
||||||
"archived_at",
|
"archived_at",
|
||||||
)
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
sub_issues = user_timezone_converter(
|
||||||
|
sub_issues, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"sub_issues": sub_issues,
|
"sub_issues": sub_issues,
|
||||||
|
362
apiserver/plane/app/views/module/archive.py
Normal file
362
apiserver/plane/app/views/module/archive.py
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
from django.db.models import (
|
||||||
|
Count,
|
||||||
|
Exists,
|
||||||
|
F,
|
||||||
|
Func,
|
||||||
|
IntegerField,
|
||||||
|
OuterRef,
|
||||||
|
Prefetch,
|
||||||
|
Q,
|
||||||
|
Subquery,
|
||||||
|
UUIDField,
|
||||||
|
Value,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from plane.app.permissions import (
|
||||||
|
ProjectEntityPermission,
|
||||||
|
)
|
||||||
|
from plane.app.serializers import (
|
||||||
|
ModuleDetailSerializer,
|
||||||
|
)
|
||||||
|
from plane.db.models import (
|
||||||
|
Issue,
|
||||||
|
Module,
|
||||||
|
ModuleFavorite,
|
||||||
|
ModuleLink,
|
||||||
|
)
|
||||||
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||||
|
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .. import BaseAPIView
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||||
|
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
favorite_subquery = ModuleFavorite.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
module_id=OuterRef("pk"),
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
)
|
||||||
|
cancelled_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="cancelled",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
completed_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="completed",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
started_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="started",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
unstarted_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="unstarted",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
backlog_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="backlog",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
total_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(archived_at__isnull=False)
|
||||||
|
.annotate(is_favorite=Exists(favorite_subquery))
|
||||||
|
.select_related("workspace", "project", "lead")
|
||||||
|
.prefetch_related("members")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"link_module",
|
||||||
|
queryset=ModuleLink.objects.select_related(
|
||||||
|
"module", "created_by"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Coalesce(
|
||||||
|
Subquery(completed_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Coalesce(
|
||||||
|
Subquery(cancelled_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Coalesce(
|
||||||
|
Subquery(started_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Coalesce(
|
||||||
|
Subquery(unstarted_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Coalesce(
|
||||||
|
Subquery(backlog_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Coalesce(
|
||||||
|
Subquery(total_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
member_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"members__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(members__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "-created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id, pk=None):
|
||||||
|
if pk is None:
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
modules = queryset.values( # Required fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# Model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"description_text",
|
||||||
|
"description_html",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"status",
|
||||||
|
"lead_id",
|
||||||
|
"member_ids",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
# computed fields
|
||||||
|
"total_issues",
|
||||||
|
"is_favorite",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"archived_at",
|
||||||
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
modules = user_timezone_converter(
|
||||||
|
modules, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
|
return Response(modules, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
queryset = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(pk=pk)
|
||||||
|
.annotate(
|
||||||
|
sub_issues=Issue.issue_objects.filter(
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
parent__isnull=False,
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(first_name=F("assignees__first_name"))
|
||||||
|
.annotate(last_name=F("assignees__last_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values(
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"assignee_id",
|
||||||
|
"avatar",
|
||||||
|
"display_name",
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("first_name", "last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
data = ModuleDetailSerializer(queryset.first()).data
|
||||||
|
data["distribution"] = {
|
||||||
|
"assignees": assignee_distribution,
|
||||||
|
"labels": label_distribution,
|
||||||
|
"completion_chart": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch the modules
|
||||||
|
modules = queryset.first()
|
||||||
|
if modules and modules.start_date and modules.target_date:
|
||||||
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
|
queryset=modules,
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
module_id=pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, module_id):
|
||||||
|
module = Module.objects.get(
|
||||||
|
pk=module_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
if module.status not in ["completed", "cancelled"]:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "Only completed or cancelled modules can be archived"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
module.archived_at = timezone.now()
|
||||||
|
module.save()
|
||||||
|
return Response(
|
||||||
|
{"archived_at": str(module.archived_at)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, request, slug, project_id, module_id):
|
||||||
|
module = Module.objects.get(
|
||||||
|
pk=module_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
module.archived_at = None
|
||||||
|
module.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
@ -48,6 +48,8 @@ from plane.db.models import (
|
|||||||
Project,
|
Project,
|
||||||
)
|
)
|
||||||
from plane.utils.analytics_plot import burndown_plot
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||||
|
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
||||||
@ -236,6 +238,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
module = user_timezone_converter(
|
||||||
|
module, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
return Response(module, status=status.HTTP_201_CREATED)
|
return Response(module, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@ -277,6 +283,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
modules = user_timezone_converter(
|
||||||
|
modules, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
return Response(modules, status=status.HTTP_200_OK)
|
return Response(modules, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk):
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
@ -454,6 +464,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
).first()
|
).first()
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
module = user_timezone_converter(
|
||||||
|
module, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
return Response(module, status=status.HTTP_200_OK)
|
return Response(module, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@ -515,325 +529,6 @@ class ModuleLinkViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
ProjectEntityPermission,
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
favorite_subquery = ModuleFavorite.objects.filter(
|
|
||||||
user=self.request.user,
|
|
||||||
module_id=OuterRef("pk"),
|
|
||||||
project_id=self.kwargs.get("project_id"),
|
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
|
||||||
)
|
|
||||||
cancelled_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
state__group="cancelled",
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
completed_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
state__group="completed",
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
started_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
state__group="started",
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
unstarted_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
state__group="unstarted",
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
backlog_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
state__group="backlog",
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
total_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
|
||||||
.filter(archived_at__isnull=False)
|
|
||||||
.annotate(is_favorite=Exists(favorite_subquery))
|
|
||||||
.select_related("workspace", "project", "lead")
|
|
||||||
.prefetch_related("members")
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"link_module",
|
|
||||||
queryset=ModuleLink.objects.select_related(
|
|
||||||
"module", "created_by"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
completed_issues=Coalesce(
|
|
||||||
Subquery(completed_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
cancelled_issues=Coalesce(
|
|
||||||
Subquery(cancelled_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
started_issues=Coalesce(
|
|
||||||
Subquery(started_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
unstarted_issues=Coalesce(
|
|
||||||
Subquery(unstarted_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
backlog_issues=Coalesce(
|
|
||||||
Subquery(backlog_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
total_issues=Coalesce(
|
|
||||||
Subquery(total_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
member_ids=Coalesce(
|
|
||||||
ArrayAgg(
|
|
||||||
"members__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=~Q(members__id__isnull=True),
|
|
||||||
),
|
|
||||||
Value([], output_field=ArrayField(UUIDField())),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by("-is_favorite", "-created_at")
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, request, slug, project_id, pk=None):
|
|
||||||
if pk is None:
|
|
||||||
queryset = self.get_queryset()
|
|
||||||
modules = queryset.values( # Required fields
|
|
||||||
"id",
|
|
||||||
"workspace_id",
|
|
||||||
"project_id",
|
|
||||||
# Model fields
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"description_text",
|
|
||||||
"description_html",
|
|
||||||
"start_date",
|
|
||||||
"target_date",
|
|
||||||
"status",
|
|
||||||
"lead_id",
|
|
||||||
"member_ids",
|
|
||||||
"view_props",
|
|
||||||
"sort_order",
|
|
||||||
"external_source",
|
|
||||||
"external_id",
|
|
||||||
# computed fields
|
|
||||||
"total_issues",
|
|
||||||
"is_favorite",
|
|
||||||
"cancelled_issues",
|
|
||||||
"completed_issues",
|
|
||||||
"started_issues",
|
|
||||||
"unstarted_issues",
|
|
||||||
"backlog_issues",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"archived_at",
|
|
||||||
)
|
|
||||||
return Response(modules, status=status.HTTP_200_OK)
|
|
||||||
else:
|
|
||||||
queryset = (
|
|
||||||
self.get_queryset()
|
|
||||||
.filter(pk=pk)
|
|
||||||
.annotate(
|
|
||||||
sub_issues=Issue.issue_objects.filter(
|
|
||||||
project_id=self.kwargs.get("project_id"),
|
|
||||||
parent__isnull=False,
|
|
||||||
issue_module__module_id=pk,
|
|
||||||
)
|
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assignee_distribution = (
|
|
||||||
Issue.objects.filter(
|
|
||||||
issue_module__module_id=pk,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
.annotate(first_name=F("assignees__first_name"))
|
|
||||||
.annotate(last_name=F("assignees__last_name"))
|
|
||||||
.annotate(assignee_id=F("assignees__id"))
|
|
||||||
.annotate(display_name=F("assignees__display_name"))
|
|
||||||
.annotate(avatar=F("assignees__avatar"))
|
|
||||||
.values(
|
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"assignee_id",
|
|
||||||
"avatar",
|
|
||||||
"display_name",
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
total_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(
|
|
||||||
archived_at__isnull=True,
|
|
||||||
is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
completed_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(
|
|
||||||
completed_at__isnull=False,
|
|
||||||
archived_at__isnull=True,
|
|
||||||
is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
pending_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(
|
|
||||||
completed_at__isnull=True,
|
|
||||||
archived_at__isnull=True,
|
|
||||||
is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by("first_name", "last_name")
|
|
||||||
)
|
|
||||||
|
|
||||||
label_distribution = (
|
|
||||||
Issue.objects.filter(
|
|
||||||
issue_module__module_id=pk,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
.annotate(label_name=F("labels__name"))
|
|
||||||
.annotate(color=F("labels__color"))
|
|
||||||
.annotate(label_id=F("labels__id"))
|
|
||||||
.values("label_name", "color", "label_id")
|
|
||||||
.annotate(
|
|
||||||
total_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(
|
|
||||||
archived_at__isnull=True,
|
|
||||||
is_draft=False,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
completed_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(
|
|
||||||
completed_at__isnull=False,
|
|
||||||
archived_at__isnull=True,
|
|
||||||
is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
pending_issues=Count(
|
|
||||||
"id",
|
|
||||||
filter=Q(
|
|
||||||
completed_at__isnull=True,
|
|
||||||
archived_at__isnull=True,
|
|
||||||
is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by("label_name")
|
|
||||||
)
|
|
||||||
|
|
||||||
data = ModuleDetailSerializer(queryset.first()).data
|
|
||||||
data["distribution"] = {
|
|
||||||
"assignees": assignee_distribution,
|
|
||||||
"labels": label_distribution,
|
|
||||||
"completion_chart": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fetch the modules
|
|
||||||
modules = queryset.first()
|
|
||||||
if modules and modules.start_date and modules.target_date:
|
|
||||||
data["distribution"]["completion_chart"] = burndown_plot(
|
|
||||||
queryset=modules,
|
|
||||||
slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
module_id=pk,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
data,
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
def post(self, request, slug, project_id, module_id):
|
|
||||||
module = Module.objects.get(
|
|
||||||
pk=module_id, project_id=project_id, workspace__slug=slug
|
|
||||||
)
|
|
||||||
if module.status not in ["completed", "cancelled"]:
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"error": "Only completed or cancelled modules can be archived"
|
|
||||||
},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
module.archived_at = timezone.now()
|
|
||||||
module.save()
|
|
||||||
return Response(
|
|
||||||
{"archived_at": str(module.archived_at)},
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete(self, request, slug, project_id, module_id):
|
|
||||||
module = Module.objects.get(
|
|
||||||
pk=module_id, project_id=project_id, workspace__slug=slug
|
|
||||||
)
|
|
||||||
module.archived_at = None
|
|
||||||
module.save()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleFavoriteViewSet(BaseViewSet):
|
class ModuleFavoriteViewSet(BaseViewSet):
|
||||||
serializer_class = ModuleFavoriteSerializer
|
serializer_class = ModuleFavoriteSerializer
|
||||||
model = ModuleFavorite
|
model = ModuleFavorite
|
||||||
|
@ -31,7 +31,7 @@ from plane.db.models import (
|
|||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||||
|
|
||||||
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||||
serializer_class = ModuleIssueSerializer
|
serializer_class = ModuleIssueSerializer
|
||||||
@ -150,6 +150,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"is_draft",
|
"is_draft",
|
||||||
"archived_at",
|
"archived_at",
|
||||||
)
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
issues = user_timezone_converter(
|
||||||
|
issues, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
|
|
||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
# create multiple issues inside a module
|
# create multiple issues inside a module
|
||||||
|
@ -185,7 +185,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Issue.issue_objects.filter(
|
total_issues=Issue.issue_objects.filter(
|
||||||
project_id=self.kwargs.get("pk"),
|
project_id=self.kwargs.get("pk"),
|
||||||
parent__isnull=True,
|
|
||||||
)
|
)
|
||||||
.order_by()
|
.order_by()
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
@ -204,7 +203,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
archived_issues=Issue.objects.filter(
|
archived_issues=Issue.objects.filter(
|
||||||
project_id=self.kwargs.get("pk"),
|
project_id=self.kwargs.get("pk"),
|
||||||
archived_at__isnull=False,
|
archived_at__isnull=False,
|
||||||
parent__isnull=True,
|
|
||||||
)
|
)
|
||||||
.order_by()
|
.order_by()
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
@ -224,7 +222,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
draft_issues=Issue.objects.filter(
|
draft_issues=Issue.objects.filter(
|
||||||
project_id=self.kwargs.get("pk"),
|
project_id=self.kwargs.get("pk"),
|
||||||
is_draft=True,
|
is_draft=True,
|
||||||
parent__isnull=True,
|
|
||||||
)
|
)
|
||||||
.order_by()
|
.order_by()
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
@ -49,7 +49,12 @@ class UserEndpoint(BaseViewSet):
|
|||||||
{"is_instance_admin": is_admin}, status=status.HTTP_200_OK
|
{"is_instance_admin": is_admin}, status=status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
@invalidate_cache(path="/api/users/me/")
|
@invalidate_cache(
|
||||||
|
path="/api/users/me/",
|
||||||
|
)
|
||||||
|
@invalidate_cache(
|
||||||
|
path="/api/users/me/settings/",
|
||||||
|
)
|
||||||
def partial_update(self, request, *args, **kwargs):
|
def partial_update(self, request, *args, **kwargs):
|
||||||
return super().partial_update(request, *args, **kwargs)
|
return super().partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ from plane.db.models import (
|
|||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
)
|
)
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||||
|
|
||||||
class GlobalViewViewSet(BaseViewSet):
|
class GlobalViewViewSet(BaseViewSet):
|
||||||
serializer_class = IssueViewSerializer
|
serializer_class = IssueViewSerializer
|
||||||
@ -255,6 +255,10 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
|||||||
"is_draft",
|
"is_draft",
|
||||||
"archived_at",
|
"archived_at",
|
||||||
)
|
)
|
||||||
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
|
issues = user_timezone_converter(
|
||||||
|
issues, datetime_fields, request.user.user_timezone
|
||||||
|
)
|
||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@ -151,8 +151,8 @@ class WorkSpaceViewSet(BaseViewSet):
|
|||||||
return super().partial_update(request, *args, **kwargs)
|
return super().partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||||
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True)
|
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False)
|
||||||
@invalidate_cache(path="/api/users/me/settings/", multiple=True)
|
@invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False)
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
return super().destroy(request, *args, **kwargs)
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
|
|||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("owned_by")
|
.select_related("owned_by")
|
||||||
.filter(archived_at__isnull=False)
|
.filter(archived_at__isnull=True)
|
||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"issue_cycle",
|
"issue_cycle",
|
||||||
|
@ -30,7 +30,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
|
|||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("lead")
|
.select_related("lead")
|
||||||
.prefetch_related("members")
|
.prefetch_related("members")
|
||||||
.filter(archived_at__isnull=False)
|
.filter(archived_at__isnull=True)
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"link_module",
|
"link_module",
|
||||||
|
@ -21,6 +21,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
|
|||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
project__archived_at__isnull=True,
|
project__archived_at__isnull=True,
|
||||||
|
is_triage=False,
|
||||||
)
|
)
|
||||||
serializer = StateSerializer(states, many=True).data
|
serializer = StateSerializer(states, many=True).data
|
||||||
return Response(serializer, status=status.HTTP_200_OK)
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
|
@ -15,7 +15,7 @@ class Command(BaseCommand):
|
|||||||
receiver_email = options.get("to_email")
|
receiver_email = options.get("to_email")
|
||||||
|
|
||||||
if not receiver_email:
|
if not receiver_email:
|
||||||
raise CommandError("Reciever email is required")
|
raise CommandError("Receiver email is required")
|
||||||
|
|
||||||
(
|
(
|
||||||
EMAIL_HOST,
|
EMAIL_HOST,
|
||||||
@ -54,7 +54,7 @@ class Command(BaseCommand):
|
|||||||
connection=connection,
|
connection=connection,
|
||||||
)
|
)
|
||||||
msg.send()
|
msg.send()
|
||||||
self.stdout.write(self.style.SUCCESS("Email succesfully sent"))
|
self.stdout.write(self.style.SUCCESS("Email successfully sent"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.ERROR(
|
self.style.ERROR(
|
||||||
|
@ -46,7 +46,7 @@ class Command(BaseCommand):
|
|||||||
}
|
}
|
||||||
|
|
||||||
instance = Instance.objects.create(
|
instance = Instance.objects.create(
|
||||||
instance_name="Plane Free",
|
instance_name="Plane Community Edition",
|
||||||
instance_id=secrets.token_hex(12),
|
instance_id=secrets.token_hex(12),
|
||||||
license_key=None,
|
license_key=None,
|
||||||
api_key=secrets.token_hex(8),
|
api_key=secrets.token_hex(8),
|
||||||
|
25
apiserver/plane/utils/user_timezone_converter.py
Normal file
25
apiserver/plane/utils/user_timezone_converter.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import pytz
|
||||||
|
|
||||||
|
def user_timezone_converter(queryset, datetime_fields, user_timezone):
|
||||||
|
# Create a timezone object for the user's timezone
|
||||||
|
user_tz = pytz.timezone(user_timezone)
|
||||||
|
|
||||||
|
# Check if queryset is a dictionary (single item) or a list of dictionaries
|
||||||
|
if isinstance(queryset, dict):
|
||||||
|
queryset_values = [queryset]
|
||||||
|
else:
|
||||||
|
queryset_values = list(queryset.values())
|
||||||
|
|
||||||
|
# Iterate over the dictionaries in the list
|
||||||
|
for item in queryset_values:
|
||||||
|
# Iterate over the datetime fields
|
||||||
|
for field in datetime_fields:
|
||||||
|
# Convert the datetime field to the user's timezone
|
||||||
|
if item[field]:
|
||||||
|
item[field] = item[field].astimezone(user_tz)
|
||||||
|
|
||||||
|
# If queryset was a single item, return a single item
|
||||||
|
if isinstance(queryset, dict):
|
||||||
|
return queryset_values[0]
|
||||||
|
else:
|
||||||
|
return queryset_values
|
@ -1,3 +1,3 @@
|
|||||||
-r base.txt
|
-r base.txt
|
||||||
|
|
||||||
gunicorn==21.2.0
|
gunicorn==22.0.0
|
||||||
|
@ -1 +1 @@
|
|||||||
python-3.11.8
|
python-3.11.9
|
@ -180,7 +180,7 @@ services:
|
|||||||
|
|
||||||
plane-redis:
|
plane-redis:
|
||||||
container_name: plane-redis
|
container_name: plane-redis
|
||||||
image: redis:6.2.7-alpine
|
image: redis:7.2.4-alpine
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
@ -10,7 +10,7 @@ volumes:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
plane-redis:
|
plane-redis:
|
||||||
image: redis:6.2.7-alpine
|
image: redis:7.2.4-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- dev_env
|
- dev_env
|
||||||
|
@ -90,7 +90,7 @@ services:
|
|||||||
|
|
||||||
plane-redis:
|
plane-redis:
|
||||||
container_name: plane-redis
|
container_name: plane-redis
|
||||||
image: redis:6.2.7-alpine
|
image: redis:7.2.4-alpine
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"repository": "https://github.com/makeplane/plane.git",
|
"repository": "https://github.com/makeplane/plane.git",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/editor-core",
|
"name": "@plane/editor-core",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"description": "Core Editor that powers Plane",
|
"description": "Core Editor that powers Plane",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -34,7 +34,7 @@ interface CustomEditorProps {
|
|||||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||||
};
|
};
|
||||||
handleEditorReady?: (value: boolean) => void;
|
handleEditorReady?: (value: boolean) => void;
|
||||||
placeholder?: string | ((isFocused: boolean) => string);
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,11 +142,11 @@ export const useEditor = ({
|
|||||||
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
|
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
|
||||||
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
|
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
|
||||||
|
|
||||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName);
|
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
|
||||||
|
|
||||||
const item = getEditorMenuItem(itemName);
|
const item = getEditorMenuItem(itemName);
|
||||||
if (item) {
|
if (item) {
|
||||||
if (item.name === "image") {
|
if (item.key === "image") {
|
||||||
item.command(savedSelection);
|
item.command(savedSelection);
|
||||||
} else {
|
} else {
|
||||||
item.command();
|
item.command();
|
||||||
@ -158,7 +158,7 @@ export const useEditor = ({
|
|||||||
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
|
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
|
||||||
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
|
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
|
||||||
|
|
||||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName);
|
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
|
||||||
const item = getEditorMenuItem(itemName);
|
const item = getEditorMenuItem(itemName);
|
||||||
return item ? item.isActive() : false;
|
return item ? item.isActive() : false;
|
||||||
},
|
},
|
||||||
|
@ -4,6 +4,11 @@ import { findTableAncestor } from "src/lib/utils";
|
|||||||
import { Selection } from "@tiptap/pm/state";
|
import { Selection } from "@tiptap/pm/state";
|
||||||
import { UploadImage } from "src/types/upload-image";
|
import { UploadImage } from "src/types/upload-image";
|
||||||
|
|
||||||
|
export const setText = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||||
|
else editor.chain().focus().clearNodes().run();
|
||||||
|
};
|
||||||
|
|
||||||
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
|
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||||
else editor.chain().focus().toggleHeading({ level: 1 }).run();
|
else editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||||
@ -19,6 +24,21 @@ export const toggleHeadingThree = (editor: Editor, range?: Range) => {
|
|||||||
else editor.chain().focus().toggleHeading({ level: 3 }).run();
|
else editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toggleHeadingFour = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run();
|
||||||
|
else editor.chain().focus().toggleHeading({ level: 4 }).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleHeadingFive = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run();
|
||||||
|
else editor.chain().focus().toggleHeading({ level: 5 }).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleHeadingSix = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run();
|
||||||
|
else editor.chain().focus().toggleHeading({ level: 6 }).run();
|
||||||
|
};
|
||||||
|
|
||||||
export const toggleBold = (editor: Editor, range?: Range) => {
|
export const toggleBold = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
|
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||||
else editor.chain().focus().toggleBold().run();
|
else editor.chain().focus().toggleBold().run();
|
||||||
|
@ -1,3 +1,14 @@
|
|||||||
|
.ProseMirror {
|
||||||
|
--font-size-h1: 1.5rem;
|
||||||
|
--font-size-h2: 1.3125rem;
|
||||||
|
--font-size-h3: 1.125rem;
|
||||||
|
--font-size-h4: 0.9375rem;
|
||||||
|
--font-size-h5: 0.8125rem;
|
||||||
|
--font-size-h6: 0.75rem;
|
||||||
|
--font-size-regular: 0.9375rem;
|
||||||
|
--font-size-list: var(--font-size-regular);
|
||||||
|
}
|
||||||
|
|
||||||
.ProseMirror p.is-editor-empty:first-child::before {
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
@ -56,7 +67,7 @@
|
|||||||
|
|
||||||
/* to-do list */
|
/* to-do list */
|
||||||
ul[data-type="taskList"] li {
|
ul[data-type="taskList"] li {
|
||||||
font-size: 1rem;
|
font-size: var(--font-size-list);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,7 +173,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: var(--font-size-regular);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
-moz-box-sizing: border-box;
|
-moz-box-sizing: border-box;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -310,15 +321,15 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
|
|||||||
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
font-size: 1.875rem;
|
font-size: var(--font-size-h1);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
margin-top: 1.4rem;
|
margin-top: 1.4rem;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
font-size: 1.5rem;
|
font-size: var(--font-size-h2);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
@ -326,21 +337,46 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
|
|||||||
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
font-size: 1.25rem;
|
font-size: var(--font-size-h3);
|
||||||
|
font-weight: 600;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
padding: 3px 2px;
|
padding: 3px 0;
|
||||||
font-size: 1rem;
|
font-size: var(--font-size-regular);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
|
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
|
||||||
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
|
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
|
||||||
font-size: 1rem;
|
font-size: var(--font-size-list);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@ type TArguments = {
|
|||||||
cancelUploadImage?: () => void;
|
cancelUploadImage?: () => void;
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
};
|
};
|
||||||
placeholder?: string | ((isFocused: boolean) => string);
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -147,7 +147,7 @@ export const CoreEditorExtensions = ({
|
|||||||
|
|
||||||
if (placeholder) {
|
if (placeholder) {
|
||||||
if (typeof placeholder === "string") return placeholder;
|
if (typeof placeholder === "string") return placeholder;
|
||||||
else return placeholder(editor.isFocused);
|
else return placeholder(editor.isFocused, editor.getHTML());
|
||||||
}
|
}
|
||||||
|
|
||||||
return "Press '/' for commands...";
|
return "Press '/' for commands...";
|
||||||
|
@ -13,16 +13,24 @@ import {
|
|||||||
UnderlineIcon,
|
UnderlineIcon,
|
||||||
StrikethroughIcon,
|
StrikethroughIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
|
Heading4,
|
||||||
|
Heading5,
|
||||||
|
Heading6,
|
||||||
|
CaseSensitive,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import {
|
import {
|
||||||
insertImageCommand,
|
insertImageCommand,
|
||||||
insertTableCommand,
|
insertTableCommand,
|
||||||
|
setText,
|
||||||
toggleBlockquote,
|
toggleBlockquote,
|
||||||
toggleBold,
|
toggleBold,
|
||||||
toggleBulletList,
|
toggleBulletList,
|
||||||
toggleCodeBlock,
|
toggleCodeBlock,
|
||||||
|
toggleHeadingFive,
|
||||||
|
toggleHeadingFour,
|
||||||
toggleHeadingOne,
|
toggleHeadingOne,
|
||||||
|
toggleHeadingSix,
|
||||||
toggleHeadingThree,
|
toggleHeadingThree,
|
||||||
toggleHeadingTwo,
|
toggleHeadingTwo,
|
||||||
toggleItalic,
|
toggleItalic,
|
||||||
@ -36,15 +44,26 @@ import { UploadImage } from "src/types/upload-image";
|
|||||||
import { Selection } from "@tiptap/pm/state";
|
import { Selection } from "@tiptap/pm/state";
|
||||||
|
|
||||||
export interface EditorMenuItem {
|
export interface EditorMenuItem {
|
||||||
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
isActive: () => boolean;
|
isActive: () => boolean;
|
||||||
command: () => void;
|
command: () => void;
|
||||||
icon: LucideIconType;
|
icon: LucideIconType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TextItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
|
key: "text",
|
||||||
|
name: "Text",
|
||||||
|
isActive: () => editor.isActive("paragraph"),
|
||||||
|
command: () => setText(editor),
|
||||||
|
icon: CaseSensitive,
|
||||||
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const HeadingOneItem = (editor: Editor) =>
|
export const HeadingOneItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "H1",
|
key: "h1",
|
||||||
|
name: "Heading 1",
|
||||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||||
command: () => toggleHeadingOne(editor),
|
command: () => toggleHeadingOne(editor),
|
||||||
icon: Heading1,
|
icon: Heading1,
|
||||||
@ -52,7 +71,8 @@ export const HeadingOneItem = (editor: Editor) =>
|
|||||||
|
|
||||||
export const HeadingTwoItem = (editor: Editor) =>
|
export const HeadingTwoItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "H2",
|
key: "h2",
|
||||||
|
name: "Heading 2",
|
||||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||||
command: () => toggleHeadingTwo(editor),
|
command: () => toggleHeadingTwo(editor),
|
||||||
icon: Heading2,
|
icon: Heading2,
|
||||||
@ -60,15 +80,44 @@ export const HeadingTwoItem = (editor: Editor) =>
|
|||||||
|
|
||||||
export const HeadingThreeItem = (editor: Editor) =>
|
export const HeadingThreeItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "H3",
|
key: "h3",
|
||||||
|
name: "Heading 3",
|
||||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||||
command: () => toggleHeadingThree(editor),
|
command: () => toggleHeadingThree(editor),
|
||||||
icon: Heading3,
|
icon: Heading3,
|
||||||
}) as const satisfies EditorMenuItem;
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
|
export const HeadingFourItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
|
key: "h4",
|
||||||
|
name: "Heading 4",
|
||||||
|
isActive: () => editor.isActive("heading", { level: 4 }),
|
||||||
|
command: () => toggleHeadingFour(editor),
|
||||||
|
icon: Heading4,
|
||||||
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
|
export const HeadingFiveItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
|
key: "h5",
|
||||||
|
name: "Heading 5",
|
||||||
|
isActive: () => editor.isActive("heading", { level: 5 }),
|
||||||
|
command: () => toggleHeadingFive(editor),
|
||||||
|
icon: Heading5,
|
||||||
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
|
export const HeadingSixItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
|
key: "h6",
|
||||||
|
name: "Heading 6",
|
||||||
|
isActive: () => editor.isActive("heading", { level: 6 }),
|
||||||
|
command: () => toggleHeadingSix(editor),
|
||||||
|
icon: Heading6,
|
||||||
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const BoldItem = (editor: Editor) =>
|
export const BoldItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "bold",
|
key: "bold",
|
||||||
|
name: "Bold",
|
||||||
isActive: () => editor?.isActive("bold"),
|
isActive: () => editor?.isActive("bold"),
|
||||||
command: () => toggleBold(editor),
|
command: () => toggleBold(editor),
|
||||||
icon: BoldIcon,
|
icon: BoldIcon,
|
||||||
@ -76,7 +125,8 @@ export const BoldItem = (editor: Editor) =>
|
|||||||
|
|
||||||
export const ItalicItem = (editor: Editor) =>
|
export const ItalicItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "italic",
|
key: "italic",
|
||||||
|
name: "Italic",
|
||||||
isActive: () => editor?.isActive("italic"),
|
isActive: () => editor?.isActive("italic"),
|
||||||
command: () => toggleItalic(editor),
|
command: () => toggleItalic(editor),
|
||||||
icon: ItalicIcon,
|
icon: ItalicIcon,
|
||||||
@ -84,7 +134,8 @@ export const ItalicItem = (editor: Editor) =>
|
|||||||
|
|
||||||
export const UnderLineItem = (editor: Editor) =>
|
export const UnderLineItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "underline",
|
key: "underline",
|
||||||
|
name: "Underline",
|
||||||
isActive: () => editor?.isActive("underline"),
|
isActive: () => editor?.isActive("underline"),
|
||||||
command: () => toggleUnderline(editor),
|
command: () => toggleUnderline(editor),
|
||||||
icon: UnderlineIcon,
|
icon: UnderlineIcon,
|
||||||
@ -92,7 +143,8 @@ export const UnderLineItem = (editor: Editor) =>
|
|||||||
|
|
||||||
export const StrikeThroughItem = (editor: Editor) =>
|
export const StrikeThroughItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "strike",
|
key: "strikethrough",
|
||||||
|
name: "Strikethrough",
|
||||||
isActive: () => editor?.isActive("strike"),
|
isActive: () => editor?.isActive("strike"),
|
||||||
command: () => toggleStrike(editor),
|
command: () => toggleStrike(editor),
|
||||||
icon: StrikethroughIcon,
|
icon: StrikethroughIcon,
|
||||||
@ -100,47 +152,53 @@ export const StrikeThroughItem = (editor: Editor) =>
|
|||||||
|
|
||||||
export const BulletListItem = (editor: Editor) =>
|
export const BulletListItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "bullet-list",
|
key: "bulleted-list",
|
||||||
|
name: "Bulleted list",
|
||||||
isActive: () => editor?.isActive("bulletList"),
|
isActive: () => editor?.isActive("bulletList"),
|
||||||
command: () => toggleBulletList(editor),
|
command: () => toggleBulletList(editor),
|
||||||
icon: ListIcon,
|
icon: ListIcon,
|
||||||
}) as const satisfies EditorMenuItem;
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const TodoListItem = (editor: Editor) =>
|
|
||||||
({
|
|
||||||
name: "To-do List",
|
|
||||||
isActive: () => editor.isActive("taskItem"),
|
|
||||||
command: () => toggleTaskList(editor),
|
|
||||||
icon: CheckSquare,
|
|
||||||
}) as const satisfies EditorMenuItem;
|
|
||||||
|
|
||||||
export const CodeItem = (editor: Editor) =>
|
|
||||||
({
|
|
||||||
name: "code",
|
|
||||||
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
|
||||||
command: () => toggleCodeBlock(editor),
|
|
||||||
icon: CodeIcon,
|
|
||||||
}) as const satisfies EditorMenuItem;
|
|
||||||
|
|
||||||
export const NumberedListItem = (editor: Editor) =>
|
export const NumberedListItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "ordered-list",
|
key: "numbered-list",
|
||||||
|
name: "Numbered list",
|
||||||
isActive: () => editor?.isActive("orderedList"),
|
isActive: () => editor?.isActive("orderedList"),
|
||||||
command: () => toggleOrderedList(editor),
|
command: () => toggleOrderedList(editor),
|
||||||
icon: ListOrderedIcon,
|
icon: ListOrderedIcon,
|
||||||
}) as const satisfies EditorMenuItem;
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
|
export const TodoListItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
|
key: "to-do-list",
|
||||||
|
name: "To-do list",
|
||||||
|
isActive: () => editor.isActive("taskItem"),
|
||||||
|
command: () => toggleTaskList(editor),
|
||||||
|
icon: CheckSquare,
|
||||||
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const QuoteItem = (editor: Editor) =>
|
export const QuoteItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "quote",
|
key: "quote",
|
||||||
|
name: "Quote",
|
||||||
isActive: () => editor?.isActive("blockquote"),
|
isActive: () => editor?.isActive("blockquote"),
|
||||||
command: () => toggleBlockquote(editor),
|
command: () => toggleBlockquote(editor),
|
||||||
icon: QuoteIcon,
|
icon: QuoteIcon,
|
||||||
}) as const satisfies EditorMenuItem;
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
|
export const CodeItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
|
key: "code",
|
||||||
|
name: "Code",
|
||||||
|
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
||||||
|
command: () => toggleCodeBlock(editor),
|
||||||
|
icon: CodeIcon,
|
||||||
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const TableItem = (editor: Editor) =>
|
export const TableItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
name: "table",
|
key: "table",
|
||||||
|
name: "Table",
|
||||||
isActive: () => editor?.isActive("table"),
|
isActive: () => editor?.isActive("table"),
|
||||||
command: () => insertTableCommand(editor),
|
command: () => insertTableCommand(editor),
|
||||||
icon: TableIcon,
|
icon: TableIcon,
|
||||||
@ -148,7 +206,8 @@ export const TableItem = (editor: Editor) =>
|
|||||||
|
|
||||||
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
|
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
|
||||||
({
|
({
|
||||||
name: "image",
|
key: "image",
|
||||||
|
name: "Image",
|
||||||
isActive: () => editor?.isActive("image"),
|
isActive: () => editor?.isActive("image"),
|
||||||
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
|
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
@ -159,9 +218,13 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
TextItem(editor),
|
||||||
HeadingOneItem(editor),
|
HeadingOneItem(editor),
|
||||||
HeadingTwoItem(editor),
|
HeadingTwoItem(editor),
|
||||||
HeadingThreeItem(editor),
|
HeadingThreeItem(editor),
|
||||||
|
HeadingFourItem(editor),
|
||||||
|
HeadingFiveItem(editor),
|
||||||
|
HeadingSixItem(editor),
|
||||||
BoldItem(editor),
|
BoldItem(editor),
|
||||||
ItalicItem(editor),
|
ItalicItem(editor),
|
||||||
UnderLineItem(editor),
|
UnderLineItem(editor),
|
||||||
@ -177,7 +240,7 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type EditorMenuItemNames = ReturnType<typeof getEditorMenuItems> extends (infer U)[]
|
export type EditorMenuItemNames = ReturnType<typeof getEditorMenuItems> extends (infer U)[]
|
||||||
? U extends { name: infer N }
|
? U extends { key: infer N }
|
||||||
? N
|
? N
|
||||||
: never
|
: never
|
||||||
: never;
|
: never;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/document-editor",
|
"name": "@plane/document-editor",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"description": "Package that powers Plane's Pages Editor",
|
"description": "Package that powers Plane's Pages Editor",
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
|
@ -31,7 +31,7 @@ interface IDocumentEditor {
|
|||||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||||
};
|
};
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
placeholder?: string | ((isFocused: boolean) => string);
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DocumentEditor = (props: IDocumentEditor) => {
|
const DocumentEditor = (props: IDocumentEditor) => {
|
||||||
|
@ -67,11 +67,13 @@ export default function BlockMenu(props: BlockMenuProps) {
|
|||||||
popup.current?.hide();
|
popup.current?.hide();
|
||||||
};
|
};
|
||||||
document.addEventListener("click", handleClickDragHandle);
|
document.addEventListener("click", handleClickDragHandle);
|
||||||
|
document.addEventListener("contextmenu", handleClickDragHandle);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
document.addEventListener("scroll", handleScroll, true); // Using capture phase
|
document.addEventListener("scroll", handleScroll, true); // Using capture phase
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("click", handleClickDragHandle);
|
document.removeEventListener("click", handleClickDragHandle);
|
||||||
|
document.removeEventListener("contextmenu", handleClickDragHandle);
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
document.removeEventListener("scroll", handleScroll, true);
|
document.removeEventListener("scroll", handleScroll, true);
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/editor-extensions",
|
"name": "@plane/editor-extensions",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"description": "Package that powers Plane's Editor with extensions",
|
"description": "Package that powers Plane's Editor with extensions",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -225,6 +225,9 @@ function DragHandle(options: DragHandleOptions) {
|
|||||||
dragHandleElement.addEventListener("click", (e) => {
|
dragHandleElement.addEventListener("click", (e) => {
|
||||||
handleClick(e, view);
|
handleClick(e, view);
|
||||||
});
|
});
|
||||||
|
dragHandleElement.addEventListener("contextmenu", (e) => {
|
||||||
|
handleClick(e, view);
|
||||||
|
});
|
||||||
|
|
||||||
dragHandleElement.addEventListener("drag", (e) => {
|
dragHandleElement.addEventListener("drag", (e) => {
|
||||||
hideDragHandle();
|
hideDragHandle();
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/lite-text-editor",
|
"name": "@plane/lite-text-editor",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"description": "Package that powers Plane's Comment Editor",
|
"description": "Package that powers Plane's Comment Editor",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -32,7 +32,8 @@ export interface ILiteTextEditor {
|
|||||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||||
};
|
};
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
placeholder?: string | ((isFocused: boolean) => string);
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||||
@ -48,12 +49,14 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
|
|||||||
tabIndex,
|
tabIndex,
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
placeholder = "Add comment...",
|
placeholder = "Add comment...",
|
||||||
|
id = "",
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
onChange,
|
onChange,
|
||||||
initialValue,
|
initialValue,
|
||||||
value,
|
value,
|
||||||
|
id,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
restoreFile: fileHandler.restore,
|
restoreFile: fileHandler.restore,
|
||||||
uploadFile: fileHandler.upload,
|
uploadFile: fileHandler.upload,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/rich-text-editor",
|
"name": "@plane/rich-text-editor",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"description": "Rich Text Editor that powers Plane",
|
"description": "Rich Text Editor that powers Plane",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -35,7 +35,7 @@ export type IRichTextEditor = {
|
|||||||
highlights: () => Promise<IMentionHighlight[]>;
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||||
};
|
};
|
||||||
placeholder?: string | ((isFocused: boolean) => string);
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
} from "@plane/editor-core";
|
} from "@plane/editor-core";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
isActive: () => boolean;
|
isActive: () => boolean;
|
||||||
command: () => void;
|
command: () => void;
|
||||||
|
@ -8,9 +8,13 @@ import {
|
|||||||
QuoteItem,
|
QuoteItem,
|
||||||
CodeItem,
|
CodeItem,
|
||||||
TodoListItem,
|
TodoListItem,
|
||||||
|
TextItem,
|
||||||
|
HeadingFourItem,
|
||||||
|
HeadingFiveItem,
|
||||||
|
HeadingSixItem,
|
||||||
} from "@plane/editor-core";
|
} from "@plane/editor-core";
|
||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { Check, ChevronDown, TextIcon } from "lucide-react";
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
import { Dispatch, FC, SetStateAction } from "react";
|
import { Dispatch, FC, SetStateAction } from "react";
|
||||||
|
|
||||||
import { BubbleMenuItem } from ".";
|
import { BubbleMenuItem } from ".";
|
||||||
@ -23,18 +27,16 @@ interface NodeSelectorProps {
|
|||||||
|
|
||||||
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
|
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
|
||||||
const items: BubbleMenuItem[] = [
|
const items: BubbleMenuItem[] = [
|
||||||
{
|
TextItem(editor),
|
||||||
name: "Text",
|
|
||||||
icon: TextIcon,
|
|
||||||
command: () => editor.chain().focus().clearNodes().run(),
|
|
||||||
isActive: () => editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"),
|
|
||||||
},
|
|
||||||
HeadingOneItem(editor),
|
HeadingOneItem(editor),
|
||||||
HeadingTwoItem(editor),
|
HeadingTwoItem(editor),
|
||||||
HeadingThreeItem(editor),
|
HeadingThreeItem(editor),
|
||||||
TodoListItem(editor),
|
HeadingFourItem(editor),
|
||||||
|
HeadingFiveItem(editor),
|
||||||
|
HeadingSixItem(editor),
|
||||||
BulletListItem(editor),
|
BulletListItem(editor),
|
||||||
NumberedListItem(editor),
|
NumberedListItem(editor),
|
||||||
|
TodoListItem(editor),
|
||||||
QuoteItem(editor),
|
QuoteItem(editor),
|
||||||
CodeItem(editor),
|
CodeItem(editor),
|
||||||
];
|
];
|
||||||
@ -58,7 +60,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
|
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.name}
|
key={item.name}
|
||||||
@ -69,19 +71,17 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
|
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
|
||||||
{
|
{
|
||||||
"bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name,
|
"bg-custom-background-80": activeItem.name === item.name,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="rounded-sm border border-custom-border-300 p-1">
|
<item.icon className="size-3 flex-shrink-0" />
|
||||||
<item.icon className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<span>{item.name}</span>
|
<span>{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
{activeItem.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "eslint-config-custom",
|
"name": "eslint-config-custom",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {},
|
"devDependencies": {},
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tailwind-config-custom",
|
"name": "tailwind-config-custom",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"description": "common tailwind configuration across monorepo",
|
"description": "common tailwind configuration across monorepo",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tsconfig",
|
"name": "tsconfig",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/types",
|
"name": "@plane/types",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.d.ts"
|
"main": "./src/index.d.ts"
|
||||||
}
|
}
|
||||||
|
2
packages/types/src/inbox.d.ts
vendored
2
packages/types/src/inbox.d.ts
vendored
@ -20,7 +20,7 @@ export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
|
|||||||
export type TInboxIssueStatus = EInboxIssueStatus;
|
export type TInboxIssueStatus = EInboxIssueStatus;
|
||||||
|
|
||||||
// filters
|
// filters
|
||||||
export type TInboxIssueFilterMemberKeys = "assignee" | "created_by";
|
export type TInboxIssueFilterMemberKeys = "assignees" | "created_by";
|
||||||
|
|
||||||
export type TInboxIssueFilterDateKeys = "created_at" | "updated_at";
|
export type TInboxIssueFilterDateKeys = "created_at" | "updated_at";
|
||||||
|
|
||||||
|
5
packages/types/src/pages.d.ts
vendored
5
packages/types/src/pages.d.ts
vendored
@ -16,14 +16,9 @@ export type TPage = {
|
|||||||
project: string | undefined;
|
project: string | undefined;
|
||||||
updated_at: Date | undefined;
|
updated_at: Date | undefined;
|
||||||
updated_by: string | undefined;
|
updated_by: string | undefined;
|
||||||
view_props: TPageViewProps | undefined;
|
|
||||||
workspace: string | undefined;
|
workspace: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TPageViewProps = {
|
|
||||||
full_width?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// page filters
|
// page filters
|
||||||
export type TPageNavigationTabs = "public" | "private" | "archived";
|
export type TPageNavigationTabs = "public" | "private" | "archived";
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"name": "@plane/ui",
|
"name": "@plane/ui",
|
||||||
"description": "UI components shared across multiple apps internally",
|
"description": "UI components shared across multiple apps internally",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
// ui
|
// ui
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "../../helpers";
|
||||||
// types
|
// types
|
||||||
import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar";
|
import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar";
|
||||||
|
|
||||||
@ -55,7 +57,7 @@ export const AvatarGroup: React.FC<Props> = (props) => {
|
|||||||
const sizeInfo = getSizeInfo(size);
|
const sizeInfo = getSizeInfo(size);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex ${sizeInfo.spacing}`}>
|
<div className={cn("flex", sizeInfo.spacing)}>
|
||||||
{avatarsWithUpdatedProps.map((avatar, index) => (
|
{avatarsWithUpdatedProps.map((avatar, index) => (
|
||||||
<div key={index} className="rounded-full ring-1 ring-custom-background-100">
|
<div key={index} className="rounded-full ring-1 ring-custom-background-100">
|
||||||
{avatar}
|
{avatar}
|
||||||
@ -64,9 +66,12 @@ export const AvatarGroup: React.FC<Props> = (props) => {
|
|||||||
{maxAvatarsToRender < totalAvatars && (
|
{maxAvatarsToRender < totalAvatars && (
|
||||||
<Tooltip tooltipContent={`${totalAvatars} total`} disabled={!showTooltip}>
|
<Tooltip tooltipContent={`${totalAvatars} total`} disabled={!showTooltip}>
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={cn(
|
||||||
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
|
"grid place-items-center rounded-full bg-custom-primary-10 text-[9px] text-custom-primary-100 ring-1 ring-custom-background-100",
|
||||||
} grid place-items-center rounded-full bg-custom-primary-10 text-[9px] text-custom-primary-100 ring-1 ring-custom-background-100`}
|
{
|
||||||
|
[sizeInfo.avatarSize]: !isAValidNumber(size),
|
||||||
|
}
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
isAValidNumber(size)
|
isAValidNumber(size)
|
||||||
? {
|
? {
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
// ui
|
// ui
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "../../helpers";
|
||||||
|
|
||||||
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
|
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
|
||||||
|
|
||||||
@ -130,9 +132,9 @@ export const Avatar: React.FC<Props> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<Tooltip tooltipContent={fallbackText ?? name ?? "?"} disabled={!showTooltip}>
|
<Tooltip tooltipContent={fallbackText ?? name ?? "?"} disabled={!showTooltip}>
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={cn("grid place-items-center overflow-hidden", getBorderRadius(shape), {
|
||||||
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
|
[sizeInfo.avatarSize]: !isAValidNumber(size),
|
||||||
} grid place-items-center overflow-hidden ${getBorderRadius(shape)}`}
|
})}
|
||||||
style={
|
style={
|
||||||
isAValidNumber(size)
|
isAValidNumber(size)
|
||||||
? {
|
? {
|
||||||
@ -144,12 +146,15 @@ export const Avatar: React.FC<Props> = (props) => {
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{src ? (
|
{src ? (
|
||||||
<img src={src} className={`h-full w-full ${getBorderRadius(shape)} ${className}`} alt={name} />
|
<img src={src} className={cn("h-full w-full", getBorderRadius(shape), className)} alt={name} />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`${sizeInfo.fontSize} grid h-full w-full place-items-center ${getBorderRadius(
|
className={cn(
|
||||||
shape
|
sizeInfo.fontSize,
|
||||||
)} ${className}`}
|
"grid h-full w-full place-items-center",
|
||||||
|
getBorderRadius(shape),
|
||||||
|
className
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",
|
backgroundColor: fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",
|
||||||
color: fallbackTextColor ?? "#ffffff",
|
color: fallbackTextColor ?? "#ffffff",
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
// helpers
|
||||||
import { getIconStyling, getBadgeStyling, TBadgeVariant, TBadgeSizes } from "./helper";
|
import { getIconStyling, getBadgeStyling, TBadgeVariant, TBadgeSizes } from "./helper";
|
||||||
|
import { cn } from "../../helpers";
|
||||||
|
|
||||||
export interface BadgeProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface BadgeProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: TBadgeVariant;
|
variant?: TBadgeVariant;
|
||||||
@ -31,7 +32,7 @@ const Badge = React.forwardRef<HTMLButtonElement, BadgeProps>((props, ref) => {
|
|||||||
const buttonIconStyle = getIconStyling(size);
|
const buttonIconStyle = getIconStyling(size);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button ref={ref} type={type} className={`${buttonStyle} ${className}`} disabled={disabled || loading} {...rest}>
|
<button ref={ref} type={type} className={cn(buttonStyle, className)} disabled={disabled || loading} {...rest}>
|
||||||
{prependIcon && <div className={buttonIconStyle}>{React.cloneElement(prependIcon, { strokeWidth: 2 })}</div>}
|
{prependIcon && <div className={buttonIconStyle}>{React.cloneElement(prependIcon, { strokeWidth: 2 })}</div>}
|
||||||
{children}
|
{children}
|
||||||
{appendIcon && <div className={buttonIconStyle}>{React.cloneElement(appendIcon, { strokeWidth: 2 })}</div>}
|
{appendIcon && <div className={buttonIconStyle}>{React.cloneElement(appendIcon, { strokeWidth: 2 })}</div>}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Switch } from "@headlessui/react";
|
import { Switch } from "@headlessui/react";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "../../helpers";
|
||||||
|
|
||||||
interface IToggleSwitchProps {
|
interface IToggleSwitchProps {
|
||||||
value: boolean;
|
value: boolean;
|
||||||
@ -19,22 +20,32 @@ const ToggleSwitch: React.FC<IToggleSwitchProps> = (props) => {
|
|||||||
checked={value}
|
checked={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className={`relative inline-flex flex-shrink-0 ${
|
className={cn(
|
||||||
size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
|
"relative inline-flex flex-shrink-0 h-6 w-10 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none bg-gray-700",
|
||||||
} flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
|
{
|
||||||
value ? "bg-custom-primary-100" : "bg-gray-700"
|
"h-4 w-6": size === "sm",
|
||||||
} ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`}
|
"h-5 w-8": size === "md",
|
||||||
|
"bg-custom-primary-100": value,
|
||||||
|
"cursor-not-allowed": disabled,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span className="sr-only">{label}</span>
|
<span className="sr-only">{label}</span>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`inline-block self-center ${
|
className={cn(
|
||||||
size === "sm" ? "h-2 w-2" : size === "md" ? "h-3 w-3" : "h-4 w-4"
|
"inline-block self-center h-4 w-4 transform rounded-full shadow ring-0 transition duration-200 ease-in-out",
|
||||||
} transform rounded-full shadow ring-0 transition duration-200 ease-in-out ${
|
{
|
||||||
value
|
"translate-x-5 bg-white": value,
|
||||||
? (size === "sm" ? "translate-x-3" : size === "md" ? "translate-x-4" : "translate-x-5") + " bg-white"
|
"h-2 w-2": size === "sm",
|
||||||
: "translate-x-0.5 bg-custom-background-90"
|
"h-3 w-3": size === "md",
|
||||||
} ${disabled ? "cursor-not-allowed" : ""}`}
|
"translate-x-3": value && size === "sm",
|
||||||
|
"translate-x-4": value && size === "md",
|
||||||
|
"translate-x-0.5 bg-custom-background-90": !value,
|
||||||
|
"cursor-not-allowed": disabled,
|
||||||
|
}
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
@ -6,10 +6,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
target?: string;
|
target?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ControlLink: React.FC<TControlLink> = (props) => {
|
export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((props, ref) => {
|
||||||
const { href, onClick, children, target = "_self", disabled = false, ...rest } = props;
|
const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props;
|
||||||
const LEFT_CLICK_EVENT_CODE = 0;
|
const LEFT_CLICK_EVENT_CODE = 0;
|
||||||
|
|
||||||
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||||
@ -20,11 +21,20 @@ export const ControlLink: React.FC<TControlLink> = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (disabled) return <>{children}</>;
|
// if disabled but still has a ref or a className then it has to be rendered without a href
|
||||||
|
if (disabled && (ref || className))
|
||||||
return (
|
return (
|
||||||
<a href={href} target={target} onClick={handleOnClick} {...rest}>
|
<a ref={ref} className={className}>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
// else if just disabled return without the parent wrapper
|
||||||
|
if (disabled) return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={href} target={target} onClick={handleOnClick} {...rest} ref={ref} className={className}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
2
packages/ui/src/dropdowns/context-menu/index.ts
Normal file
2
packages/ui/src/dropdowns/context-menu/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./item";
|
||||||
|
export * from "./root";
|
54
packages/ui/src/dropdowns/context-menu/item.tsx
Normal file
54
packages/ui/src/dropdowns/context-menu/item.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import React from "react";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "../../../helpers";
|
||||||
|
// types
|
||||||
|
import { TContextMenuItem } from "./root";
|
||||||
|
|
||||||
|
type ContextMenuItemProps = {
|
||||||
|
handleActiveItem: () => void;
|
||||||
|
handleClose: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
item: TContextMenuItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||||
|
const { handleActiveItem, handleClose, isActive, item } = props;
|
||||||
|
|
||||||
|
if (item.shouldRender === false) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
|
||||||
|
{
|
||||||
|
"bg-custom-background-90": isActive,
|
||||||
|
"text-custom-text-400": item.disabled,
|
||||||
|
},
|
||||||
|
item.className
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
item.action();
|
||||||
|
if (item.closeOnClick !== false) handleClose();
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleActiveItem}
|
||||||
|
disabled={item.disabled}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
|
<div>
|
||||||
|
<h5>{item.title}</h5>
|
||||||
|
{item.description && (
|
||||||
|
<p
|
||||||
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
"text-custom-text-400": item.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
157
packages/ui/src/dropdowns/context-menu/root.tsx
Normal file
157
packages/ui/src/dropdowns/context-menu/root.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
// components
|
||||||
|
import { ContextMenuItem } from "./item";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "../../../helpers";
|
||||||
|
// hooks
|
||||||
|
import useOutsideClickDetector from "../../hooks/use-outside-click-detector";
|
||||||
|
|
||||||
|
export type TContextMenuItem = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.FC<any>;
|
||||||
|
action: () => void;
|
||||||
|
shouldRender?: boolean;
|
||||||
|
closeOnClick?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContextMenuProps = {
|
||||||
|
parentRef: React.RefObject<HTMLElement>;
|
||||||
|
items: TContextMenuItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
||||||
|
const { parentRef, items } = props;
|
||||||
|
// states
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [position, setPosition] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
|
||||||
|
// refs
|
||||||
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
// derived values
|
||||||
|
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setActiveItemIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// calculate position of context menu
|
||||||
|
useEffect(() => {
|
||||||
|
const parentElement = parentRef.current;
|
||||||
|
const contextMenu = contextMenuRef.current;
|
||||||
|
if (!parentElement || !contextMenu) return;
|
||||||
|
|
||||||
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const contextMenuWidth = contextMenu.clientWidth;
|
||||||
|
const contextMenuHeight = contextMenu.clientHeight;
|
||||||
|
|
||||||
|
const clickX = e?.pageX || 0;
|
||||||
|
const clickY = e?.pageY || 0;
|
||||||
|
|
||||||
|
// check if there's enough space at the bottom, otherwise show at the top
|
||||||
|
let top = clickY;
|
||||||
|
if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight;
|
||||||
|
|
||||||
|
// check if there's enough space on the right, otherwise show on the left
|
||||||
|
let left = clickX;
|
||||||
|
if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth;
|
||||||
|
|
||||||
|
setPosition({ x: left, y: top });
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideContextMenu = (e: KeyboardEvent) => {
|
||||||
|
if (isOpen && e.key === "Escape") handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
parentElement.addEventListener("contextmenu", handleContextMenu);
|
||||||
|
window.addEventListener("keydown", hideContextMenu);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
parentElement.removeEventListener("contextmenu", handleContextMenu);
|
||||||
|
window.removeEventListener("keydown", hideContextMenu);
|
||||||
|
};
|
||||||
|
}, [contextMenuRef, isOpen, parentRef, setIsOpen, setPosition]);
|
||||||
|
|
||||||
|
// handle keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveItemIndex((prev) => (prev + 1) % renderedItems.length);
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length);
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const item = renderedItems[activeItemIndex];
|
||||||
|
if (!item.disabled) {
|
||||||
|
renderedItems[activeItemIndex].action();
|
||||||
|
if (item.closeOnClick !== false) handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
|
||||||
|
|
||||||
|
// close on clicking outside
|
||||||
|
useOutsideClickDetector(contextMenuRef, handleClose);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed h-screen w-screen top-0 left-0 cursor-default z-20 opacity-0 pointer-events-none transition-opacity",
|
||||||
|
{
|
||||||
|
"opacity-100 pointer-events-auto": isOpen,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={contextMenuRef}
|
||||||
|
className="fixed border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg rounded-md px-2 py-2.5 max-h-72 min-w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
|
||||||
|
style={{
|
||||||
|
top: position.y,
|
||||||
|
left: position.x,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderedItems.map((item, index) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={item.key}
|
||||||
|
handleActiveItem={() => setActiveItemIndex(index)}
|
||||||
|
handleClose={handleClose}
|
||||||
|
isActive={index === activeItemIndex}
|
||||||
|
item={item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||||
|
let contextMenu = <ContextMenuWithoutPortal {...props} />;
|
||||||
|
const portal = document.querySelector("#context-menu-portal");
|
||||||
|
if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal);
|
||||||
|
return contextMenu;
|
||||||
|
};
|
@ -1,3 +1,4 @@
|
|||||||
|
export * from "./context-menu";
|
||||||
export * from "./custom-menu";
|
export * from "./custom-menu";
|
||||||
export * from "./custom-select";
|
export * from "./custom-select";
|
||||||
export * from "./custom-search-select";
|
export * from "./custom-search-select";
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "../../helpers";
|
||||||
|
|
||||||
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
intermediate?: boolean;
|
intermediate?: boolean;
|
||||||
@ -9,32 +11,30 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref)
|
|||||||
const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props;
|
const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative w-full flex gap-2 ${className}`}>
|
<div className={cn("relative w-full flex gap-2", className)}>
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={name}
|
name={name}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
className={`
|
className={cn(
|
||||||
appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50
|
"appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50",
|
||||||
${
|
{
|
||||||
disabled
|
"border-custom-border-200 bg-custom-background-80 cursor-not-allowed": disabled,
|
||||||
? "border-custom-border-200 bg-custom-background-80 cursor-not-allowed"
|
"cursor-pointer border-custom-border-300 hover:border-custom-border-400 bg-white": !disabled,
|
||||||
: `cursor-pointer ${
|
"border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200":
|
||||||
checked || intermediate
|
!disabled && (checked || intermediate),
|
||||||
? "border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200"
|
|
||||||
: "border-custom-border-300 hover:border-custom-border-400 bg-white"
|
|
||||||
}`
|
|
||||||
}
|
}
|
||||||
`}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
<svg
|
<svg
|
||||||
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
|
className={cn("absolute w-4 h-4 p-0.5 pointer-events-none outline-none hidden stroke-white", {
|
||||||
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
|
block: checked,
|
||||||
} ${checked ? "block" : "hidden"}`}
|
"stroke-custom-text-400 opacity-40": disabled,
|
||||||
|
})}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -46,9 +46,10 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref)
|
|||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
|
className={cn("absolute w-4 h-4 p-0.5 pointer-events-none outline-none stroke-white hidden", {
|
||||||
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
|
"stroke-custom-text-400 opacity-40": disabled,
|
||||||
} ${intermediate && !checked ? "block" : "hidden"}`}
|
block: intermediate && !checked,
|
||||||
|
})}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 8 8"
|
viewBox="0 0 8 8"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
@ -5,6 +5,8 @@ import { ColorResult, SketchPicker } from "react-color";
|
|||||||
import { Input } from "./input";
|
import { Input } from "./input";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "../../helpers";
|
||||||
|
|
||||||
export interface InputColorPickerProps {
|
export interface InputColorPickerProps {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
@ -45,7 +47,7 @@ export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
hasError={hasError}
|
hasError={hasError}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={`border-[0.5px] border-custom-border-200 ${className}`}
|
className={cn("border-[0.5px] border-custom-border-200", className)}
|
||||||
style={style}
|
style={style}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -19,17 +19,16 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
|||||||
type={type}
|
type={type}
|
||||||
name={name}
|
name={name}
|
||||||
className={cn(
|
className={cn(
|
||||||
`block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none ${
|
"block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none",
|
||||||
mode === "primary"
|
{
|
||||||
? "rounded-md border-[0.5px] border-custom-border-200"
|
"rounded-md border-[0.5px] border-custom-border-200": mode === "primary",
|
||||||
: mode === "transparent"
|
"rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary":
|
||||||
? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary"
|
mode === "transparent",
|
||||||
: mode === "true-transparent"
|
"rounded border-none bg-transparent ring-0": mode === "true-transparent",
|
||||||
? "rounded border-none bg-transparent ring-0"
|
"border-red-500": hasError,
|
||||||
: ""
|
"px-3 py-2": inputSize === "sm",
|
||||||
} ${hasError ? "border-red-500" : ""} ${hasError && mode === "primary" ? "bg-red-500/20" : ""} ${
|
"p-3": inputSize === "md",
|
||||||
inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : ""
|
},
|
||||||
}`,
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
@ -11,21 +11,11 @@ export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextArea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
|
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
|
||||||
const {
|
const { id, name, value = "", mode = "primary", hasError = false, className = "", ...rest } = props;
|
||||||
id,
|
|
||||||
name,
|
|
||||||
value = "",
|
|
||||||
rows = 1,
|
|
||||||
cols = 1,
|
|
||||||
mode = "primary",
|
|
||||||
hasError = false,
|
|
||||||
className = "",
|
|
||||||
...rest
|
|
||||||
} = props;
|
|
||||||
// refs
|
// refs
|
||||||
const textAreaRef = useRef<any>(ref);
|
const textAreaRef = useRef<any>(ref);
|
||||||
// auto re-size
|
// auto re-size
|
||||||
useAutoResizeTextArea(textAreaRef);
|
useAutoResizeTextArea(textAreaRef, value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
@ -33,8 +23,6 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, re
|
|||||||
name={name}
|
name={name}
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
value={value}
|
value={value}
|
||||||
rows={rows}
|
|
||||||
cols={cols}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none",
|
"no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none",
|
||||||
{
|
{
|
||||||
|
@ -1,24 +1,16 @@
|
|||||||
import { useEffect } from "react";
|
import { useLayoutEffect } from "react";
|
||||||
|
|
||||||
export const useAutoResizeTextArea = (textAreaRef: React.RefObject<HTMLTextAreaElement>) => {
|
export const useAutoResizeTextArea = (
|
||||||
useEffect(() => {
|
textAreaRef: React.RefObject<HTMLTextAreaElement>,
|
||||||
|
value: string | number | readonly string[]
|
||||||
|
) => {
|
||||||
|
useLayoutEffect(() => {
|
||||||
const textArea = textAreaRef.current;
|
const textArea = textAreaRef.current;
|
||||||
if (!textArea) return;
|
if (!textArea) return;
|
||||||
|
|
||||||
const resizeTextArea = () => {
|
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
|
||||||
textArea.style.height = "auto";
|
textArea.style.height = "0px";
|
||||||
const computedHeight = textArea.scrollHeight + "px";
|
const scrollHeight = textArea.scrollHeight;
|
||||||
textArea.style.height = computedHeight;
|
textArea.style.height = scrollHeight + "px";
|
||||||
};
|
}, [textAreaRef, value]);
|
||||||
|
|
||||||
const handleInput = () => resizeTextArea();
|
|
||||||
|
|
||||||
// resize on mount
|
|
||||||
resizeTextArea();
|
|
||||||
|
|
||||||
textArea.addEventListener("input", handleInput);
|
|
||||||
return () => {
|
|
||||||
textArea.removeEventListener("input", handleInput);
|
|
||||||
};
|
|
||||||
}, [textAreaRef]);
|
|
||||||
};
|
};
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { ISvgIcons } from "./type";
|
|
||||||
|
|
||||||
export const AdminProfileIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className={`${className} stroke-2`}
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path
|
|
||||||
d="M8 19V18C8 16.9391 8.42143 15.9217 9.17157 15.1716C9.92172 14.4214 10.9391 14 12 14C13.0609 14 14.0783 14.4214 14.8284 15.1716C15.5786 15.9217 16 16.9391 16 18V19"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 14C13.6569 14 15 12.6569 15 11C15 9.34315 13.6569 8 12 8C10.3431 8 9 9.34315 9 11C9 12.6569 10.3431 14 12 14Z"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
@ -1,25 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { ISvgIcons } from "./type";
|
|
||||||
|
|
||||||
export const CopyIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className={`${className} stroke-2`}
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M20 8H10C8.89543 8 8 8.89543 8 10V20C8 21.1046 8.89543 22 10 22H20C21.1046 22 22 21.1046 22 20V10C22 8.89543 21.1046 8 20 8Z"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M4 16C2.9 16 2 15.1 2 14V4C2 2.9 2.9 2 4 2H14C15.1 2 16 2.9 16 4"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
@ -1,22 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { ISvgIcons } from "./type";
|
|
||||||
|
|
||||||
export const ExternalLinkIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className={`${className} stroke-[1.5]`}
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M18 13V19C18 19.5304 17.7893 20.0391 17.4142 20.4142C17.0391 20.7893 16.5304 21 16 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V8C3 7.46957 3.21071 6.96086 3.58579 6.58579C3.96086 6.21071 4.46957 6 5 6H11"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path d="M15 3H21V9" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path d="M10 14L21 3" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
@ -1,27 +1,22 @@
|
|||||||
export * from "./user-group-icon";
|
export * from "./cycle";
|
||||||
export * from "./dice-icon";
|
export * from "./module";
|
||||||
export * from "./layers-icon";
|
|
||||||
export * from "./photo-filter-icon";
|
|
||||||
export * from "./archive-icon";
|
|
||||||
export * from "./admin-profile-icon";
|
|
||||||
export * from "./create-icon";
|
|
||||||
export * from "./subscribe-icon";
|
|
||||||
export * from "./external-link-icon";
|
|
||||||
export * from "./copy-icon";
|
|
||||||
export * from "./layer-stack";
|
|
||||||
export * from "./side-panel-icon";
|
|
||||||
export * from "./center-panel-icon";
|
|
||||||
export * from "./full-screen-panel-icon";
|
|
||||||
export * from "./priority-icon";
|
|
||||||
export * from "./state";
|
export * from "./state";
|
||||||
|
export * from "./archive-icon";
|
||||||
export * from "./blocked-icon";
|
export * from "./blocked-icon";
|
||||||
export * from "./blocker-icon";
|
export * from "./blocker-icon";
|
||||||
export * from "./related-icon";
|
|
||||||
export * from "./module";
|
|
||||||
export * from "./cycle";
|
|
||||||
export * from "./github-icon";
|
|
||||||
export * from "./discord-icon";
|
|
||||||
export * from "./transfer-icon";
|
|
||||||
export * from "./running-icon";
|
|
||||||
export * from "./calendar-before-icon";
|
|
||||||
export * from "./calendar-after-icon";
|
export * from "./calendar-after-icon";
|
||||||
|
export * from "./calendar-before-icon";
|
||||||
|
export * from "./center-panel-icon";
|
||||||
|
export * from "./create-icon";
|
||||||
|
export * from "./dice-icon";
|
||||||
|
export * from "./discord-icon";
|
||||||
|
export * from "./full-screen-panel-icon";
|
||||||
|
export * from "./github-icon";
|
||||||
|
export * from "./layer-stack";
|
||||||
|
export * from "./layers-icon";
|
||||||
|
export * from "./photo-filter-icon";
|
||||||
|
export * from "./priority-icon";
|
||||||
|
export * from "./related-icon";
|
||||||
|
export * from "./side-panel-icon";
|
||||||
|
export * from "./transfer-icon";
|
||||||
|
export * from "./user-group-icon";
|
||||||
|
@ -1,9 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { ISvgIcons } from "./type";
|
|
||||||
|
|
||||||
export const RunningIcon: React.FC<ISvgIcons> = ({ className = "fill-current", ...rest }) => (
|
|
||||||
<svg viewBox="0 0 24 24" className={`${className} `} fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}>
|
|
||||||
<path d="M4.05 12L3.2625 11.2125L10.9125 3.5625H8.25V5.0625H7.125V2.4375H11.3063C11.4813 2.4375 11.65 2.46875 11.8125 2.53125C11.975 2.59375 12.1188 2.6875 12.2438 2.8125L14.4938 5.04375C14.8563 5.40625 15.275 5.68125 15.75 5.86875C16.225 6.05625 16.725 6.1625 17.25 6.1875V7.3125C16.6 7.2875 15.975 7.16563 15.375 6.94688C14.775 6.72813 14.25 6.3875 13.8 5.925L12.9375 5.0625L10.8 7.2L12.4125 8.8125L7.8375 11.4563L7.275 10.4813L10.575 8.56875L9.0375 7.03125L4.05 12ZM2.25 6.75V5.625H6V6.75H2.25ZM0.75 4.3125V3.1875H4.5V4.3125H0.75ZM14.8125 2.8125C14.45 2.8125 14.1406 2.68438 13.8844 2.42813C13.6281 2.17188 13.5 1.8625 13.5 1.5C13.5 1.1375 13.6281 0.828125 13.8844 0.571875C14.1406 0.315625 14.45 0.1875 14.8125 0.1875C15.175 0.1875 15.4844 0.315625 15.7406 0.571875C15.9969 0.828125 16.125 1.1375 16.125 1.5C16.125 1.8625 15.9969 2.17188 15.7406 2.42813C15.4844 2.68438 15.175 2.8125 14.8125 2.8125ZM2.25 1.875V0.75H6V1.875H2.25Z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
@ -1,30 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { ISvgIcons } from "./type";
|
|
||||||
|
|
||||||
export const SubscribeIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className={`${className} stroke-2`}
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M21 12V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H12"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M15 5.1C15 4.54305 15.2107 4.0089 15.5858 3.61508C15.9609 3.22125 16.4696 3 17 3C17.5304 3 18.0391 3.22125 18.4142 3.61508C18.7893 4.0089 19 4.54305 19 5.1C19 7.55 20 8.25 20 8.25H14C14 8.25 15 7.55 15 5.1Z"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M16.25 11C16.3238 11 16.4324 11 16.5643 11C16.6963 11 16.8467 11 17 11C17.1533 11 17.3037 11 17.4357 11C17.5676 11 17.6762 11 17.75 11"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
@ -1,4 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "../helpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -6,7 +8,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Loader = ({ children, className = "" }: Props) => (
|
const Loader = ({ children, className = "" }: Props) => (
|
||||||
<div className={`${className} animate-pulse`} role="status">
|
<div className={cn("animate-pulse", className)} role="status">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "../../helpers";
|
||||||
|
|
||||||
export interface ISpinner extends React.SVGAttributes<SVGElement> {
|
export interface ISpinner extends React.SVGAttributes<SVGElement> {
|
||||||
height?: string;
|
height?: string;
|
||||||
@ -12,7 +14,7 @@ export const Spinner: React.FC<ISpinner> = ({ height = "32px", width = "32px", c
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
height={height}
|
height={height}
|
||||||
width={width}
|
width={width}
|
||||||
className={`animate-spin fill-blue-600 text-custom-text-200 ${className}`}
|
className={cn("animate-spin fill-blue-600 text-custom-text-200", className)}
|
||||||
viewBox="0 0 100 101"
|
viewBox="0 0 100 101"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
// next-themes
|
|
||||||
import { Tooltip2 } from "@blueprintjs/popover2";
|
import { Tooltip2 } from "@blueprintjs/popover2";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "../../helpers";
|
||||||
|
|
||||||
export type TPosition =
|
export type TPosition =
|
||||||
| "top"
|
| "top"
|
||||||
@ -49,9 +49,13 @@ export const Tooltip: React.FC<ITooltipProps> = ({
|
|||||||
hoverCloseDelay={closeDelay}
|
hoverCloseDelay={closeDelay}
|
||||||
content={
|
content={
|
||||||
<div
|
<div
|
||||||
className={`relative ${
|
className={cn(
|
||||||
isMobile ? "hidden" : "block"
|
"relative block z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md",
|
||||||
} z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md ${className}`}
|
{
|
||||||
|
hidden: isMobile,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>}
|
{tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>}
|
||||||
{tooltipContent}
|
{tooltipContent}
|
||||||
|
@ -34,10 +34,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
|||||||
return !!ref && typeof ref === "object" && "current" in ref;
|
return !!ref && typeof ref === "object" && "current" in ref;
|
||||||
}
|
}
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
props.initialValue === "" ||
|
|
||||||
props.initialValue?.trim() === "" ||
|
props.initialValue?.trim() === "" ||
|
||||||
props.initialValue === "<p></p>" ||
|
props.initialValue === "<p></p>" ||
|
||||||
isEmptyHtmlString(props.initialValue ?? "");
|
(isEmptyHtmlString(props.initialValue ?? "") && !props.initialValue?.includes("mention-component"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-custom-border-200 rounded p-3 space-y-3">
|
<div className="border border-custom-border-200 rounded p-3 space-y-3">
|
||||||
|
@ -18,7 +18,7 @@ export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Ric
|
|||||||
mentionHandler={{ highlights: mentionHighlights }}
|
mentionHandler={{ highlights: mentionHighlights }}
|
||||||
{...props}
|
{...props}
|
||||||
// overriding the customClassName to add relative class passed
|
// overriding the customClassName to add relative class passed
|
||||||
containerClassName={cn(props.containerClassName, "relative border border-custom-border-200 p-3")}
|
containerClassName={cn("relative p-0 border-none", props.containerClassName)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,9 @@ import {
|
|||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
Heading3,
|
Heading3,
|
||||||
|
Heading4,
|
||||||
|
Heading5,
|
||||||
|
Heading6,
|
||||||
Image,
|
Image,
|
||||||
Italic,
|
Italic,
|
||||||
List,
|
List,
|
||||||
@ -29,14 +32,17 @@ export type ToolbarMenuItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [
|
export const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [
|
||||||
{ key: "H1", name: "Heading 1", icon: Heading1, editors: ["document"] },
|
{ key: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] },
|
||||||
{ key: "H2", name: "Heading 2", icon: Heading2, editors: ["document"] },
|
{ key: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] },
|
||||||
{ key: "H3", name: "Heading 3", icon: Heading3, editors: ["document"] },
|
{ key: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] },
|
||||||
|
{ key: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] },
|
||||||
|
{ key: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] },
|
||||||
|
{ key: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] },
|
||||||
{ key: "bold", name: "Bold", icon: Bold, shortcut: ["Cmd", "B"], editors: ["lite", "document"] },
|
{ key: "bold", name: "Bold", icon: Bold, shortcut: ["Cmd", "B"], editors: ["lite", "document"] },
|
||||||
{ key: "italic", name: "Italic", icon: Italic, shortcut: ["Cmd", "I"], editors: ["lite", "document"] },
|
{ key: "italic", name: "Italic", icon: Italic, shortcut: ["Cmd", "I"], editors: ["lite", "document"] },
|
||||||
{ key: "underline", name: "Underline", icon: Underline, shortcut: ["Cmd", "U"], editors: ["lite", "document"] },
|
{ key: "underline", name: "Underline", icon: Underline, shortcut: ["Cmd", "U"], editors: ["lite", "document"] },
|
||||||
{
|
{
|
||||||
key: "strike",
|
key: "strikethrough",
|
||||||
name: "Strikethrough",
|
name: "Strikethrough",
|
||||||
icon: Strikethrough,
|
icon: Strikethrough,
|
||||||
shortcut: ["Cmd", "Shift", "S"],
|
shortcut: ["Cmd", "Shift", "S"],
|
||||||
@ -46,21 +52,21 @@ export const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [
|
|||||||
|
|
||||||
export const LIST_ITEMS: ToolbarMenuItem[] = [
|
export const LIST_ITEMS: ToolbarMenuItem[] = [
|
||||||
{
|
{
|
||||||
key: "bullet-list",
|
key: "bulleted-list",
|
||||||
name: "Bulleted list",
|
name: "Bulleted list",
|
||||||
icon: List,
|
icon: List,
|
||||||
shortcut: ["Cmd", "Shift", "7"],
|
shortcut: ["Cmd", "Shift", "7"],
|
||||||
editors: ["lite", "document"],
|
editors: ["lite", "document"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "ordered-list",
|
key: "numbered-list",
|
||||||
name: "Numbered list",
|
name: "Numbered list",
|
||||||
icon: ListOrdered,
|
icon: ListOrdered,
|
||||||
shortcut: ["Cmd", "Shift", "8"],
|
shortcut: ["Cmd", "Shift", "8"],
|
||||||
editors: ["lite", "document"],
|
editors: ["lite", "document"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "To-do List",
|
key: "to-do-list",
|
||||||
name: "To-do list",
|
name: "To-do list",
|
||||||
icon: ListTodo,
|
icon: ListTodo,
|
||||||
shortcut: ["Cmd", "Shift", "9"],
|
shortcut: ["Cmd", "Shift", "9"],
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space",
|
"name": "space",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run develop",
|
"dev": "turbo run develop",
|
||||||
|
@ -6,6 +6,7 @@ class MyDocument extends Document {
|
|||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<body className="w-100 bg-custom-background-100 antialiased">
|
<body className="w-100 bg-custom-background-100 antialiased">
|
||||||
|
<div id="context-menu-portal" />
|
||||||
<Main />
|
<Main />
|
||||||
<NextScript />
|
<NextScript />
|
||||||
</body>
|
</body>
|
||||||
|
@ -111,7 +111,7 @@ export const CreateApiTokenModal: React.FC<Props> = (props) => {
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 px-4 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
|
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 px-4 text-left shadow-custom-shadow-md transition-all w-full sm:max-w-2xl">
|
||||||
{generatedToken ? (
|
{generatedToken ? (
|
||||||
<GeneratedTokenDetails handleClose={handleClose} tokenDetails={generatedToken} />
|
<GeneratedTokenDetails handleClose={handleClose} tokenDetails={generatedToken} />
|
||||||
) : (
|
) : (
|
||||||
|
@ -146,7 +146,7 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasError={Boolean(errors.description)}
|
hasError={Boolean(errors.description)}
|
||||||
placeholder="Token description"
|
placeholder="Token description"
|
||||||
className="h-24 w-full text-sm"
|
className="min-h-24 w-full text-sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -28,8 +28,8 @@ export const GeneratedTokenDetails: React.FC<Props> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-full">
|
||||||
<div className="space-y-3">
|
<div className="w-full space-y-3 text-wrap">
|
||||||
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Key created</h3>
|
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Key created</h3>
|
||||||
<p className="text-sm text-custom-text-400">
|
<p className="text-sm text-custom-text-400">
|
||||||
Copy and save this secret key in Plane Pages. You can{"'"}t see this key after you hit Close. A CSV file
|
Copy and save this secret key in Plane Pages. You can{"'"}t see this key after you hit Close. A CSV file
|
||||||
@ -39,11 +39,11 @@ export const GeneratedTokenDetails: React.FC<Props> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyApiToken(tokenDetails.token ?? "")}
|
onClick={() => copyApiToken(tokenDetails.token ?? "")}
|
||||||
className="mt-4 flex w-full items-center justify-between rounded-md border-[0.5px] border-custom-border-200 px-3 py-2 text-sm font-medium outline-none"
|
className="mt-4 flex truncate w-full items-center justify-between rounded-md border-[0.5px] border-custom-border-200 px-3 py-2 text-sm font-medium outline-none"
|
||||||
>
|
>
|
||||||
{tokenDetails.token}
|
<span className="truncate pr-2">{tokenDetails.token}</span>
|
||||||
<Tooltip tooltipContent="Copy secret key" isMobile={isMobile}>
|
<Tooltip tooltipContent="Copy secret key" isMobile={isMobile}>
|
||||||
<Copy className="h-4 w-4 text-custom-text-400" />
|
<Copy className="h-4 w-4 text-custom-text-400 flex-shrink-0" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-6 flex items-center justify-between">
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
@ -178,7 +178,9 @@ export const CommandModal: React.FC = observer(() => {
|
|||||||
return 0;
|
return 0;
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// when search is empty and page is undefined
|
// when search term is not empty, esc should clear the search term
|
||||||
|
if (e.key === "Escape" && searchTerm) setSearchTerm("");
|
||||||
|
|
||||||
// when user tries to close the modal with esc
|
// when user tries to close the modal with esc
|
||||||
if (e.key === "Escape" && !page && !searchTerm) closePalette();
|
if (e.key === "Escape" && !page && !searchTerm) closePalette();
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ import {
|
|||||||
SignalMediumIcon,
|
SignalMediumIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
Inbox,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IIssueActivity } from "@plane/types";
|
import { IIssueActivity } from "@plane/types";
|
||||||
import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } from "@plane/ui";
|
import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } from "@plane/ui";
|
||||||
@ -45,10 +46,10 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
|
|||||||
}`}`}
|
}`}`}
|
||||||
target={activity.issue === null ? "_self" : "_blank"}
|
target={activity.issue === null ? "_self" : "_blank"}
|
||||||
rel={activity.issue === null ? "" : "noopener noreferrer"}
|
rel={activity.issue === null ? "" : "noopener noreferrer"}
|
||||||
className="font-medium text-custom-text-100 hover:underline"
|
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||||
>
|
>
|
||||||
<span className="whitespace-nowrap">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</span>{" "}
|
<span className="whitespace-nowrap">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</span>{" "}
|
||||||
<span className="font-normal">{activity.issue_detail?.name}</span>
|
<span className="font-normal break-all">{activity.issue_detail?.name}</span>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1 font-medium text-custom-text-100 whitespace-nowrap">
|
<span className="inline-flex items-center gap-1 font-medium text-custom-text-100 whitespace-nowrap">
|
||||||
@ -104,7 +105,7 @@ const EstimatePoint = observer((props: { point: string }) => {
|
|||||||
const estimateValue = getEstimatePointValue(Number(point), null);
|
const estimateValue = getEstimatePointValue(Number(point), null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="font-medium text-custom-text-100">
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">
|
||||||
{areEstimatesEnabledForCurrentProject
|
{areEstimatesEnabledForCurrentProject
|
||||||
? estimateValue
|
? estimateValue
|
||||||
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
|
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
|
||||||
@ -112,6 +113,40 @@ const EstimatePoint = observer((props: { point: string }) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const inboxActivityMessage = {
|
||||||
|
declined: {
|
||||||
|
showIssue: "declined issue",
|
||||||
|
noIssue: "declined this issue from inbox.",
|
||||||
|
},
|
||||||
|
snoozed: {
|
||||||
|
showIssue: "snoozed issue",
|
||||||
|
noIssue: "snoozed this issue.",
|
||||||
|
},
|
||||||
|
accepted: {
|
||||||
|
showIssue: "accepted issue",
|
||||||
|
noIssue: "accepted this issue from inbox.",
|
||||||
|
},
|
||||||
|
markedDuplicate: {
|
||||||
|
showIssue: "declined issue",
|
||||||
|
noIssue: "declined this issue from inbox by marking a duplicate issue.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInboxUserActivityMessage = (activity: IIssueActivity, showIssue: boolean) => {
|
||||||
|
switch (activity.verb) {
|
||||||
|
case "-1":
|
||||||
|
return showIssue ? inboxActivityMessage.declined.showIssue : inboxActivityMessage.declined.noIssue;
|
||||||
|
case "0":
|
||||||
|
return showIssue ? inboxActivityMessage.snoozed.showIssue : inboxActivityMessage.snoozed.noIssue;
|
||||||
|
case "1":
|
||||||
|
return showIssue ? inboxActivityMessage.accepted.showIssue : inboxActivityMessage.accepted.noIssue;
|
||||||
|
case "2":
|
||||||
|
return showIssue ? inboxActivityMessage.markedDuplicate.showIssue : inboxActivityMessage.markedDuplicate.noIssue;
|
||||||
|
default:
|
||||||
|
return "updated inbox issue status.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const activityDetails: {
|
const activityDetails: {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
message: (activity: IIssueActivity, showIssue: boolean, workspaceSlug: string) => React.ReactNode;
|
message: (activity: IIssueActivity, showIssue: boolean, workspaceSlug: string) => React.ReactNode;
|
||||||
@ -265,11 +300,13 @@ const activityDetails: {
|
|||||||
message: (activity, showIssue, workspaceSlug) => {
|
message: (activity, showIssue, workspaceSlug) => {
|
||||||
if (activity.old_value === "")
|
if (activity.old_value === "")
|
||||||
return (
|
return (
|
||||||
<>
|
<span className="overflow-hidden">
|
||||||
added a new label{" "}
|
added a new label{" "}
|
||||||
<span className="inline-flex w-min items-center gap-2 truncate whitespace-nowrap rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||||
<LabelPill labelId={activity.new_identifier ?? ""} workspaceSlug={workspaceSlug} />
|
<LabelPill labelId={activity.new_identifier ?? ""} workspaceSlug={workspaceSlug} />
|
||||||
<span className="flex-shrink truncate font-medium text-custom-text-100">{activity.new_value}</span>
|
<span className="flex-shrink font-medium text-custom-text-100 break-all line-clamp-1">
|
||||||
|
{activity.new_value}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{showIssue && (
|
{showIssue && (
|
||||||
<span className="">
|
<span className="">
|
||||||
@ -277,15 +314,17 @@ const activityDetails: {
|
|||||||
to <IssueLink activity={activity} />
|
to <IssueLink activity={activity} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</span>
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
removed the label{" "}
|
removed the label{" "}
|
||||||
<span className="inline-flex w-min items-center gap-2 truncate whitespace-nowrap rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||||
<LabelPill labelId={activity.old_identifier ?? ""} workspaceSlug={workspaceSlug} />
|
<LabelPill labelId={activity.old_identifier ?? ""} workspaceSlug={workspaceSlug} />
|
||||||
<span className="flex-shrink truncate font-medium text-custom-text-100">{activity.old_value}</span>
|
<span className="flex-shrink font-medium text-custom-text-100 break-all line-clamp-1">
|
||||||
|
{activity.old_value}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{showIssue && (
|
{showIssue && (
|
||||||
<span>
|
<span>
|
||||||
@ -369,29 +408,30 @@ const activityDetails: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="flex-shrink-0">
|
<span className="flex-shrink-0">
|
||||||
added {showIssue ? <IssueLink activity={activity} /> : "this issue"} to the cycle{" "}
|
added {showIssue ? <IssueLink activity={activity} /> : "this issue"}{" "}
|
||||||
|
<span className="whitespace-nowrap">to the cycle</span>{" "}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||||
>
|
>
|
||||||
<span className="truncate">{activity.new_value}</span>
|
<span className="break-all">{activity.new_value}</span>
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
else if (activity.verb === "updated")
|
else if (activity.verb === "updated")
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="flex-shrink-0">set the cycle to </span>
|
<span className="flex-shrink-0 whitespace-nowrap">set the cycle to </span>
|
||||||
<a
|
<a
|
||||||
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||||
>
|
>
|
||||||
<span className="truncate">{activity.new_value}</span>
|
<span className="break-all">{activity.new_value}</span>
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -403,9 +443,9 @@ const activityDetails: {
|
|||||||
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.old_identifier}`}
|
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.old_identifier}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||||
>
|
>
|
||||||
<span className="truncate">{activity.old_value}</span>
|
<span className="break-all">{activity.old_value}</span>
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -422,9 +462,9 @@ const activityDetails: {
|
|||||||
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||||
>
|
>
|
||||||
<span className="truncate">{activity.new_value}</span>
|
<span className="break-all">{activity.new_value}</span>
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -436,9 +476,9 @@ const activityDetails: {
|
|||||||
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||||
>
|
>
|
||||||
<span className="truncate">{activity.new_value}</span>
|
<span className="break-all">{activity.new_value}</span>
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -450,9 +490,9 @@ const activityDetails: {
|
|||||||
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.old_identifier}`}
|
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.old_identifier}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||||
>
|
>
|
||||||
<span className="truncate">{activity.old_value}</span>
|
<span className="break-all">{activity.old_value}</span>
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -462,7 +502,7 @@ const activityDetails: {
|
|||||||
name: {
|
name: {
|
||||||
message: (activity, showIssue) => (
|
message: (activity, showIssue) => (
|
||||||
<>
|
<>
|
||||||
<span className="truncate">set the name to {activity.new_value}</span>
|
set the name to <span className="break-all">{activity.new_value}</span>
|
||||||
{showIssue && (
|
{showIssue && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
@ -478,7 +518,8 @@ const activityDetails: {
|
|||||||
if (!activity.new_value)
|
if (!activity.new_value)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
removed the parent <span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
removed the parent{" "}
|
||||||
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>
|
||||||
{showIssue && (
|
{showIssue && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
@ -490,7 +531,8 @@ const activityDetails: {
|
|||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
set the parent to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
set the parent to{" "}
|
||||||
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>
|
||||||
{showIssue && (
|
{showIssue && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
@ -525,13 +567,14 @@ const activityDetails: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
marked that {showIssue ? <IssueLink activity={activity} /> : "this issue"} relates to{" "}
|
marked that {showIssue ? <IssueLink activity={activity} /> : "this issue"} relates to{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
removed the relation from <span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
removed the relation from{" "}
|
||||||
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -543,13 +586,14 @@ const activityDetails: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} is blocking issue{" "}
|
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} is blocking issue{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
removed the blocking issue <span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
removed the blocking issue{" "}
|
||||||
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -561,14 +605,14 @@ const activityDetails: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} is being blocked by{" "}
|
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} is being blocked by{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
removed {showIssue ? <IssueLink activity={activity} /> : "this issue"} being blocked by issue{" "}
|
removed {showIssue ? <IssueLink activity={activity} /> : "this issue"} being blocked by issue{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -580,14 +624,14 @@ const activityDetails: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} as duplicate of{" "}
|
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} as duplicate of{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
removed {showIssue ? <IssueLink activity={activity} /> : "this issue"} as a duplicate of{" "}
|
removed {showIssue ? <IssueLink activity={activity} /> : "this issue"} as a duplicate of{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -596,7 +640,7 @@ const activityDetails: {
|
|||||||
state: {
|
state: {
|
||||||
message: (activity, showIssue) => (
|
message: (activity, showIssue) => (
|
||||||
<>
|
<>
|
||||||
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
set the state to <span className="font-medium text-custom-text-100 break-all">{activity.new_value}</span>
|
||||||
{showIssue && (
|
{showIssue && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
@ -625,7 +669,9 @@ const activityDetails: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
set the start date to{" "}
|
set the start date to{" "}
|
||||||
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">
|
||||||
|
{renderFormattedDate(activity.new_value)}
|
||||||
|
</span>
|
||||||
{showIssue && (
|
{showIssue && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
@ -655,11 +701,12 @@ const activityDetails: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
set the due date to{" "}
|
set the due date to{" "}
|
||||||
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
|
<span className="font-medium text-custom-text-100 whitespace-nowrap">
|
||||||
|
{renderFormattedDate(activity.new_value)}
|
||||||
|
</span>
|
||||||
{showIssue && (
|
{showIssue && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
<IssueLink activity={activity} />
|
||||||
for <IssueLink activity={activity} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -667,6 +714,20 @@ const activityDetails: {
|
|||||||
},
|
},
|
||||||
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
|
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
inbox: {
|
||||||
|
message: (activity, showIssue) => (
|
||||||
|
<>
|
||||||
|
{getInboxUserActivityMessage(activity, showIssue)}
|
||||||
|
{showIssue && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<IssueLink activity={activity} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
icon: <Inbox size={12} color="#6b7280" aria-hidden="true" />,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ActivityIcon = ({ activity }: { activity: IIssueActivity }) => (
|
export const ActivityIcon = ({ activity }: { activity: IIssueActivity }) => (
|
||||||
|
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>;
|
||||||
|
};
|
@ -36,6 +36,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
|||||||
workspaceLevelToggle = false,
|
workspaceLevelToggle = false,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
|
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
|
||||||
const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]);
|
const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]);
|
||||||
@ -72,7 +73,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !workspaceSlug || !projectId) return;
|
if (!isOpen || !workspaceSlug || !projectId) return;
|
||||||
|
setIsLoading(true);
|
||||||
projectService
|
projectService
|
||||||
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
|
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
|
||||||
search: debouncedSearchTerm,
|
search: debouncedSearchTerm,
|
||||||
@ -80,8 +81,11 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
|||||||
workspace_search: isWorkspaceLevel,
|
workspace_search: isWorkspaceLevel,
|
||||||
})
|
})
|
||||||
.then((res) => setIssues(res))
|
.then((res) => setIssues(res))
|
||||||
.finally(() => setIsSearching(false));
|
.finally(() => {
|
||||||
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
|
setIsSearching(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, workspaceSlug]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -194,20 +198,22 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
|||||||
</h5>
|
</h5>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IssueSearchModalEmptyState
|
{isSearching || isLoading ? (
|
||||||
debouncedSearchTerm={debouncedSearchTerm}
|
|
||||||
isSearching={isSearching}
|
|
||||||
issues={issues}
|
|
||||||
searchTerm={searchTerm}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isSearching ? (
|
|
||||||
<Loader className="space-y-3 p-3">
|
<Loader className="space-y-3 p-3">
|
||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
</Loader>
|
</Loader>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{issues.length === 0 ? (
|
||||||
|
<IssueSearchModalEmptyState
|
||||||
|
debouncedSearchTerm={debouncedSearchTerm}
|
||||||
|
isSearching={isSearching}
|
||||||
|
issues={issues}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul className={`text-sm text-custom-text-100 ${issues.length > 0 ? "p-2" : ""}`}>
|
<ul className={`text-sm text-custom-text-100 ${issues.length > 0 ? "p-2" : ""}`}>
|
||||||
{issues.map((issue) => {
|
{issues.map((issue) => {
|
||||||
@ -252,6 +258,8 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
<div className="flex items-center justify-end gap-2 p-3">
|
<div className="flex items-center justify-end gap-2 p-3">
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// icons
|
// icons
|
||||||
import { Pencil, Trash2, LinkIcon } from "lucide-react";
|
import { Pencil, Trash2, LinkIcon, ExternalLink } from "lucide-react";
|
||||||
import { ILinkDetails, UserAuth } from "@plane/types";
|
import { ILinkDetails, UserAuth } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { ExternalLinkIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
|
import { Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||||
// hooks
|
// hooks
|
||||||
@ -19,7 +19,8 @@ type Props = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth, disabled }) => {
|
export const LinksList: React.FC<Props> = observer(
|
||||||
|
({ links, handleDeleteLink, handleEditLink, userAuth, disabled }) => {
|
||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||||
@ -73,7 +74,7 @@ export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, h
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
<ExternalLink className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -96,7 +97,9 @@ export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, h
|
|||||||
{createdByDetails && (
|
{createdByDetails && (
|
||||||
<>
|
<>
|
||||||
by{" "}
|
by{" "}
|
||||||
{createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
|
{createdByDetails?.is_bot
|
||||||
|
? createdByDetails?.first_name + " Bot"
|
||||||
|
: createdByDetails?.display_name}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@ -106,4 +109,5 @@ export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, h
|
|||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
@ -4,6 +4,8 @@ import Image from "next/image";
|
|||||||
// headless ui
|
// headless ui
|
||||||
import { Tab } from "@headlessui/react";
|
import { Tab } from "@headlessui/react";
|
||||||
import {
|
import {
|
||||||
|
IIssueFilterOptions,
|
||||||
|
IIssueFilters,
|
||||||
IModule,
|
IModule,
|
||||||
TAssigneesDistribution,
|
TAssigneesDistribution,
|
||||||
TCompletionChartDistribution,
|
TCompletionChartDistribution,
|
||||||
@ -37,6 +39,9 @@ type Props = {
|
|||||||
roundedTab?: boolean;
|
roundedTab?: boolean;
|
||||||
noBackground?: boolean;
|
noBackground?: boolean;
|
||||||
isPeekView?: boolean;
|
isPeekView?: boolean;
|
||||||
|
isCompleted?: boolean;
|
||||||
|
filters?: IIssueFilters | undefined;
|
||||||
|
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SidebarProgressStats: React.FC<Props> = ({
|
export const SidebarProgressStats: React.FC<Props> = ({
|
||||||
@ -47,6 +52,9 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
roundedTab,
|
roundedTab,
|
||||||
noBackground,
|
noBackground,
|
||||||
isPeekView = false,
|
isPeekView = false,
|
||||||
|
isCompleted = false,
|
||||||
|
filters,
|
||||||
|
handleFiltersUpdate,
|
||||||
}) => {
|
}) => {
|
||||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
|
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
|
||||||
|
|
||||||
@ -145,19 +153,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
completed={assignee.completed_issues}
|
completed={assignee.completed_issues}
|
||||||
total={assignee.total_issues}
|
total={assignee.total_issues}
|
||||||
{...(!isPeekView && {
|
{...(!isPeekView &&
|
||||||
onClick: () => {
|
!isCompleted && {
|
||||||
// TODO: set filters here
|
onClick: () => handleFiltersUpdate("assignees", assignee.assignee_id ?? ""),
|
||||||
// if (filters?.assignees?.includes(assignee.assignee_id ?? ""))
|
selected: filters?.filters?.assignees?.includes(assignee.assignee_id ?? ""),
|
||||||
// setFilters({
|
|
||||||
// assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id),
|
|
||||||
// });
|
|
||||||
// else
|
|
||||||
// setFilters({
|
|
||||||
// assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""],
|
|
||||||
// });
|
|
||||||
},
|
|
||||||
// selected: filters?.assignees?.includes(assignee.assignee_id ?? ""),
|
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -192,9 +191,11 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
||||||
>
|
>
|
||||||
{distribution && distribution?.labels.length > 0 ? (
|
{distribution && distribution?.labels.length > 0 ? (
|
||||||
distribution.labels.map((label, index) => (
|
distribution.labels.map((label, index) => {
|
||||||
|
if (label.label_id) {
|
||||||
|
return (
|
||||||
<SingleProgressStats
|
<SingleProgressStats
|
||||||
key={label.label_id ?? `no-label-${index}`}
|
key={label.label_id}
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@ -208,19 +209,34 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
completed={label.completed_issues}
|
completed={label.completed_issues}
|
||||||
total={label.total_issues}
|
total={label.total_issues}
|
||||||
{...(!isPeekView && {
|
{...(!isPeekView &&
|
||||||
// TODO: set filters here
|
!isCompleted && {
|
||||||
onClick: () => {
|
onClick: () => handleFiltersUpdate("labels", label.label_id ?? ""),
|
||||||
// if (filters.labels?.includes(label.label_id ?? ""))
|
selected: filters?.filters?.labels?.includes(label.label_id ?? `no-label-${index}`),
|
||||||
// setFilters({
|
|
||||||
// labels: filters?.labels?.filter((l) => l !== label.label_id),
|
|
||||||
// });
|
|
||||||
// else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] });
|
|
||||||
},
|
|
||||||
// selected: filters?.labels?.includes(label.label_id ?? ""),
|
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
))
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={`no-label-${index}`}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="block h-3 w-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label.color ?? "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={label.completed_issues}
|
||||||
|
total={label.total_issues}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user