mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into feat/pagination
This commit is contained in:
commit
8ea0528c7d
30
.github/workflows/build-branch.yml
vendored
30
.github/workflows/build-branch.yml
vendored
@ -2,6 +2,27 @@ name: Branch Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
build-web:
|
||||||
|
required: false
|
||||||
|
description: "Build Web"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
build-space:
|
||||||
|
required: false
|
||||||
|
description: "Build Space"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
build-api:
|
||||||
|
required: false
|
||||||
|
description: "Build API"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
build-proxy:
|
||||||
|
required: false
|
||||||
|
description: "Build Proxy"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
@ -74,7 +95,7 @@ jobs:
|
|||||||
- nginx/**
|
- nginx/**
|
||||||
|
|
||||||
branch_build_push_frontend:
|
branch_build_push_frontend:
|
||||||
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -126,7 +147,7 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
branch_build_push_space:
|
branch_build_push_space:
|
||||||
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event.inputs.build-space=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -178,7 +199,7 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
branch_build_push_backend:
|
branch_build_push_backend:
|
||||||
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event.inputs.build-api=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -230,7 +251,7 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
branch_build_push_proxy:
|
branch_build_push_proxy:
|
||||||
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -280,4 +301,3 @@ jobs:
|
|||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
188
.github/workflows/feature-deployment.yml
vendored
188
.github/workflows/feature-deployment.yml
vendored
@ -4,70 +4,196 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
web-build:
|
web-build:
|
||||||
required: true
|
required: false
|
||||||
|
description: 'Build Web'
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
space-build:
|
space-build:
|
||||||
required: true
|
required: false
|
||||||
|
description: 'Build Space'
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
BUILD_WEB: ${{ github.event.inputs.web-build }}
|
||||||
|
BUILD_SPACE: ${{ github.event.inputs.space-build }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
setup-feature-build:
|
||||||
|
name: Feature Build Setup
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
echo "BUILD_WEB=$BUILD_WEB"
|
||||||
|
echo "BUILD_SPACE=$BUILD_SPACE"
|
||||||
|
outputs:
|
||||||
|
web-build: ${{ env.BUILD_WEB}}
|
||||||
|
space-build: ${{env.BUILD_SPACE}}
|
||||||
|
|
||||||
|
feature-build-web:
|
||||||
|
if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }}
|
||||||
|
needs: setup-feature-build
|
||||||
|
name: Feature Build Web
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
|
||||||
|
steps:
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
- name: Install AWS cli
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y python3-pip
|
||||||
|
pip3 install awscli
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: plane
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
cd $GITHUB_WORKSPACE/plane
|
||||||
|
yarn install
|
||||||
|
- name: Build Web
|
||||||
|
id: build-web
|
||||||
|
run: |
|
||||||
|
cd $GITHUB_WORKSPACE/plane
|
||||||
|
yarn build --filter=web
|
||||||
|
cd $GITHUB_WORKSPACE
|
||||||
|
|
||||||
|
TAR_NAME="web.tar.gz"
|
||||||
|
tar -czf $TAR_NAME ./plane
|
||||||
|
|
||||||
|
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
|
||||||
|
|
||||||
|
feature-build-space:
|
||||||
|
if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }}
|
||||||
|
needs: setup-feature-build
|
||||||
|
name: Feature Build Space
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
|
||||||
|
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
|
||||||
|
outputs:
|
||||||
|
do-build: ${{ needs.setup-feature-build.outputs.space-build }}
|
||||||
|
s3-url: ${{ steps.build-space.outputs.S3_PRESIGNED_URL }}
|
||||||
|
steps:
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
- name: Install AWS cli
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y python3-pip
|
||||||
|
pip3 install awscli
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: plane
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
cd $GITHUB_WORKSPACE/plane
|
||||||
|
yarn install
|
||||||
|
- name: Build Space
|
||||||
|
id: build-space
|
||||||
|
run: |
|
||||||
|
cd $GITHUB_WORKSPACE/plane
|
||||||
|
yarn build --filter=space
|
||||||
|
cd $GITHUB_WORKSPACE
|
||||||
|
|
||||||
|
TAR_NAME="space.tar.gz"
|
||||||
|
tar -czf $TAR_NAME ./plane
|
||||||
|
|
||||||
|
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
|
||||||
|
|
||||||
feature-deploy:
|
feature-deploy:
|
||||||
|
if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }}
|
||||||
|
needs: [feature-build-web, feature-build-space]
|
||||||
name: Feature Deploy
|
name: Feature Deploy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
KUBE_CONFIG_FILE: ${{ secrets.KUBE_CONFIG }}
|
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
|
||||||
BUILD_WEB: ${{ (github.event.inputs.web-build == '' && true) || github.event.inputs.web-build }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
|
||||||
BUILD_SPACE: ${{ (github.event.inputs.space-build == '' && false) || github.event.inputs.space-build }}
|
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
|
||||||
|
KUBE_CONFIG_FILE: ${{ secrets.FEATURE_PREVIEW_KUBE_CONFIG }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install AWS cli
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y python3-pip
|
||||||
|
pip3 install awscli
|
||||||
- name: Tailscale
|
- name: Tailscale
|
||||||
uses: tailscale/github-action@v2
|
uses: tailscale/github-action@v2
|
||||||
with:
|
with:
|
||||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||||
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
|
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
|
||||||
tags: tag:ci
|
tags: tag:ci
|
||||||
|
|
||||||
- name: Kubectl Setup
|
- name: Kubectl Setup
|
||||||
run: |
|
run: |
|
||||||
curl -LO "https://dl.k8s.io/release/${{secrets.KUBE_VERSION}}/bin/linux/amd64/kubectl"
|
curl -LO "https://dl.k8s.io/release/${{ vars.FEATURE_PREVIEW_KUBE_VERSION }}/bin/linux/amd64/kubectl"
|
||||||
chmod +x kubectl
|
chmod +x kubectl
|
||||||
|
|
||||||
mkdir -p ~/.kube
|
mkdir -p ~/.kube
|
||||||
echo "$KUBE_CONFIG_FILE" > ~/.kube/config
|
echo "$KUBE_CONFIG_FILE" > ~/.kube/config
|
||||||
chmod 600 ~/.kube/config
|
chmod 600 ~/.kube/config
|
||||||
|
|
||||||
- name: HELM Setup
|
- name: HELM Setup
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
|
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
|
||||||
chmod 700 get_helm.sh
|
chmod 700 get_helm.sh
|
||||||
./get_helm.sh
|
./get_helm.sh
|
||||||
|
|
||||||
- name: App Deploy
|
- name: App Deploy
|
||||||
run: |
|
run: |
|
||||||
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ secrets.FEATURE_PREVIEW_HELM_CHART_URL }}
|
WEB_S3_URL=""
|
||||||
GIT_BRANCH=${{ github.ref_name }}
|
if [ ${{ env.BUILD_WEB }} == true ]; then
|
||||||
APP_NAMESPACE=${{ secrets.FEATURE_PREVIEW_NAMESPACE }}
|
WEB_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/web.tar.gz --expires-in 3600)
|
||||||
|
fi
|
||||||
|
|
||||||
METADATA=$(helm install feature-preview/${{ secrets.FEATURE_PREVIEW_HELM_CHART_NAME }} \
|
SPACE_S3_URL=""
|
||||||
--kube-insecure-skip-tls-verify \
|
if [ ${{ env.BUILD_SPACE }} == true ]; then
|
||||||
--generate-name \
|
SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
|
||||||
--namespace $APP_NAMESPACE \
|
fi
|
||||||
--set shared_config.git_repo=${{github.server_url}}/${{ github.repository }}.git \
|
|
||||||
--set shared_config.git_branch="$GIT_BRANCH" \
|
|
||||||
--set web.enabled=${{ env.BUILD_WEB }} \
|
|
||||||
--set space.enabled=${{ env.BUILD_SPACE }} \
|
|
||||||
--output json \
|
|
||||||
--timeout 1000s)
|
|
||||||
|
|
||||||
APP_NAME=$(echo $METADATA | jq -r '.name')
|
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then
|
||||||
|
|
||||||
INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \
|
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
|
||||||
-o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \
|
|
||||||
jq -r '.spec.rules[0].host')
|
|
||||||
|
|
||||||
echo "****************************************"
|
APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}"
|
||||||
echo "APP NAME ::: $APP_NAME"
|
DEPLOY_SCRIPT_URL="${{ vars.FEATURE_PREVIEW_DEPLOY_SCRIPT_URL }}"
|
||||||
echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
|
|
||||||
echo "****************************************"
|
METADATA=$(helm --kube-insecure-skip-tls-verify install feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} \
|
||||||
|
--generate-name \
|
||||||
|
--namespace $APP_NAMESPACE \
|
||||||
|
--set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \
|
||||||
|
--set web.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
|
||||||
|
--set web.enabled=${{ env.BUILD_WEB || false }} \
|
||||||
|
--set web.artifact_url=$WEB_S3_URL \
|
||||||
|
--set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
|
||||||
|
--set space.enabled=${{ env.BUILD_SPACE || false }} \
|
||||||
|
--set space.artifact_url=$SPACE_S3_URL \
|
||||||
|
--set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \
|
||||||
|
--set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \
|
||||||
|
--output json \
|
||||||
|
--timeout 1000s)
|
||||||
|
|
||||||
|
APP_NAME=$(echo $METADATA | jq -r '.name')
|
||||||
|
|
||||||
|
INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \
|
||||||
|
-o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \
|
||||||
|
jq -r '.spec.rules[0].host')
|
||||||
|
|
||||||
|
echo "****************************************"
|
||||||
|
echo "APP NAME ::: $APP_NAME"
|
||||||
|
echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
|
||||||
|
echo "****************************************"
|
||||||
|
fi
|
||||||
|
@ -14,10 +14,6 @@ POSTGRES_HOST="plane-db"
|
|||||||
POSTGRES_DB="plane"
|
POSTGRES_DB="plane"
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
|
||||||
|
|
||||||
# Oauth variables
|
|
||||||
GOOGLE_CLIENT_ID=""
|
|
||||||
GITHUB_CLIENT_ID=""
|
|
||||||
GITHUB_CLIENT_SECRET=""
|
|
||||||
|
|
||||||
# Redis Settings
|
# Redis Settings
|
||||||
REDIS_HOST="plane-redis"
|
REDIS_HOST="plane-redis"
|
||||||
@ -34,11 +30,6 @@ AWS_S3_BUCKET_NAME="uploads"
|
|||||||
# Maximum file upload limit
|
# Maximum file upload limit
|
||||||
FILE_SIZE_LIMIT=5242880
|
FILE_SIZE_LIMIT=5242880
|
||||||
|
|
||||||
# GPT settings
|
|
||||||
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
|
|
||||||
OPENAI_API_KEY="sk-" # deprecated
|
|
||||||
GPT_ENGINE="gpt-3.5-turbo" # deprecated
|
|
||||||
|
|
||||||
# Settings related to Docker
|
# Settings related to Docker
|
||||||
DOCKERIZED=1 # deprecated
|
DOCKERIZED=1 # deprecated
|
||||||
|
|
||||||
@ -48,16 +39,6 @@ USE_MINIO=1
|
|||||||
# Nginx Configuration
|
# Nginx Configuration
|
||||||
NGINX_PORT=80
|
NGINX_PORT=80
|
||||||
|
|
||||||
|
|
||||||
# SignUps
|
|
||||||
ENABLE_SIGNUP="1"
|
|
||||||
|
|
||||||
# Enable Email/Password Signup
|
|
||||||
ENABLE_EMAIL_PASSWORD="1"
|
|
||||||
|
|
||||||
# Enable Magic link Login
|
|
||||||
ENABLE_MAGIC_LINK_LOGIN="0"
|
|
||||||
|
|
||||||
# Email redirections and minio domain settings
|
# Email redirections and minio domain settings
|
||||||
WEB_URL="http://localhost"
|
WEB_URL="http://localhost"
|
||||||
|
|
||||||
|
@ -21,11 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
|
|||||||
export MACHINE_SIGNATURE=$SIGNATURE
|
export MACHINE_SIGNATURE=$SIGNATURE
|
||||||
|
|
||||||
# Register instance
|
# Register instance
|
||||||
python manage.py register_instance $MACHINE_SIGNATURE
|
python manage.py register_instance "$MACHINE_SIGNATURE"
|
||||||
|
|
||||||
# Load the configuration variable
|
# Load the configuration variable
|
||||||
python manage.py configure_instance
|
python manage.py configure_instance
|
||||||
|
|
||||||
# Create the default bucket
|
# Create the default bucket
|
||||||
python manage.py create_bucket
|
python manage.py create_bucket
|
||||||
|
|
||||||
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
# Clear Cache before starting to remove stale values
|
||||||
|
python manage.py clear_cache
|
||||||
|
|
||||||
|
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||||
|
@ -21,12 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
|
|||||||
export MACHINE_SIGNATURE=$SIGNATURE
|
export MACHINE_SIGNATURE=$SIGNATURE
|
||||||
|
|
||||||
# Register instance
|
# Register instance
|
||||||
python manage.py register_instance $MACHINE_SIGNATURE
|
python manage.py register_instance "$MACHINE_SIGNATURE"
|
||||||
# Load the configuration variable
|
# Load the configuration variable
|
||||||
python manage.py configure_instance
|
python manage.py configure_instance
|
||||||
|
|
||||||
# Create the default bucket
|
# Create the default bucket
|
||||||
python manage.py create_bucket
|
python manage.py create_bucket
|
||||||
|
|
||||||
|
# Clear Cache before starting to remove stale values
|
||||||
|
python manage.py clear_cache
|
||||||
|
|
||||||
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
|
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
|
||||||
|
|
||||||
|
@ -215,9 +215,10 @@ class ModuleSerializer(DynamicBaseSerializer):
|
|||||||
|
|
||||||
class ModuleDetailSerializer(ModuleSerializer):
|
class ModuleDetailSerializer(ModuleSerializer):
|
||||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||||
|
sub_issues = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta(ModuleSerializer.Meta):
|
class Meta(ModuleSerializer.Meta):
|
||||||
fields = ModuleSerializer.Meta.fields + ["link_module"]
|
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
|
||||||
|
|
||||||
|
|
||||||
class ModuleFavoriteSerializer(BaseSerializer):
|
class ModuleFavoriteSerializer(BaseSerializer):
|
||||||
|
@ -102,6 +102,12 @@ class ProjectLiteSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ProjectListSerializer(DynamicBaseSerializer):
|
class ProjectListSerializer(DynamicBaseSerializer):
|
||||||
|
total_issues = serializers.IntegerField(read_only=True)
|
||||||
|
archived_issues = serializers.IntegerField(read_only=True)
|
||||||
|
archived_sub_issues = serializers.IntegerField(read_only=True)
|
||||||
|
draft_issues = serializers.IntegerField(read_only=True)
|
||||||
|
draft_sub_issues = serializers.IntegerField(read_only=True)
|
||||||
|
sub_issues = serializers.IntegerField(read_only=True)
|
||||||
is_favorite = serializers.BooleanField(read_only=True)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
total_members = serializers.IntegerField(read_only=True)
|
total_members = serializers.IntegerField(read_only=True)
|
||||||
total_cycles = serializers.IntegerField(read_only=True)
|
total_cycles = serializers.IntegerField(read_only=True)
|
||||||
|
@ -114,15 +114,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(is_favorite=Exists(favorite_subquery))
|
.annotate(is_favorite=Exists(favorite_subquery))
|
||||||
.annotate(
|
|
||||||
total_issues=Count(
|
|
||||||
"issue_cycle",
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"issue_cycle__issue__state__group",
|
||||||
@ -209,7 +200,15 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def list(self, request, slug, project_id):
|
def list(self, request, slug, project_id):
|
||||||
queryset = self.get_queryset()
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
cycle_view = request.GET.get("cycle_view", "all")
|
cycle_view = request.GET.get("cycle_view", "all")
|
||||||
|
|
||||||
# Update the order by
|
# Update the order by
|
||||||
@ -364,7 +363,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"progress_snapshot",
|
"progress_snapshot",
|
||||||
# meta fields
|
# meta fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
"cancelled_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"started_issues",
|
"started_issues",
|
||||||
@ -410,7 +408,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"progress_snapshot",
|
"progress_snapshot",
|
||||||
# meta fields
|
# meta fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
"cancelled_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"started_issues",
|
"started_issues",
|
||||||
@ -482,7 +479,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"progress_snapshot",
|
"progress_snapshot",
|
||||||
# meta fields
|
# meta fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
"cancelled_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"started_issues",
|
"started_issues",
|
||||||
@ -495,10 +491,42 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk):
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
queryset = self.get_queryset().filter(pk=pk)
|
queryset = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(pk=pk)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_cycle",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
data = (
|
data = (
|
||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.filter(pk=pk)
|
.filter(pk=pk)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Issue.issue_objects.filter(
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
parent__isnull=True,
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.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(
|
.values(
|
||||||
# necessary fields
|
# necessary fields
|
||||||
"id",
|
"id",
|
||||||
@ -515,6 +543,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"external_source",
|
"external_source",
|
||||||
"external_id",
|
"external_id",
|
||||||
"progress_snapshot",
|
"progress_snapshot",
|
||||||
|
"sub_issues",
|
||||||
# meta fields
|
# meta fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
"total_issues",
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
# 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.db.models import (
|
from django.db.models import (
|
||||||
@ -16,6 +15,8 @@ from django.db.models import (
|
|||||||
Value,
|
Value,
|
||||||
)
|
)
|
||||||
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
|
||||||
@ -102,15 +103,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
|
||||||
total_issues=Count(
|
|
||||||
"issue_module",
|
|
||||||
filter=Q(
|
|
||||||
issue_module__issue__archived_at__isnull=True,
|
|
||||||
issue_module__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"issue_module__issue__state__group",
|
"issue_module__issue__state__group",
|
||||||
@ -206,7 +198,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"external_id",
|
"external_id",
|
||||||
# computed fields
|
# computed fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
"cancelled_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"started_issues",
|
"started_issues",
|
||||||
@ -248,7 +239,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"external_id",
|
"external_id",
|
||||||
# computed fields
|
# computed fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
"cancelled_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"started_issues",
|
"started_issues",
|
||||||
@ -260,7 +250,30 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
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):
|
||||||
queryset = self.get_queryset().filter(pk=pk)
|
queryset = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(pk=pk)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Issue.issue_objects.filter(
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
parent__isnull=True,
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.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 = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
@ -403,7 +416,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"external_id",
|
"external_id",
|
||||||
# computed fields
|
# computed fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
"cancelled_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"started_issues",
|
"started_issues",
|
||||||
|
@ -46,9 +46,11 @@ from plane.db.models import (
|
|||||||
Inbox,
|
Inbox,
|
||||||
ProjectDeployBoard,
|
ProjectDeployBoard,
|
||||||
IssueProperty,
|
IssueProperty,
|
||||||
|
Issue,
|
||||||
)
|
)
|
||||||
from plane.utils.cache import cache_response
|
from plane.utils.cache import cache_response
|
||||||
|
|
||||||
|
|
||||||
class ProjectViewSet(WebhookMixin, BaseViewSet):
|
class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||||
serializer_class = ProjectListSerializer
|
serializer_class = ProjectListSerializer
|
||||||
model = Project
|
model = Project
|
||||||
@ -171,6 +173,73 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
).data
|
).data
|
||||||
return Response(projects, status=status.HTTP_200_OK)
|
return Response(projects, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def retrieve(self, request, slug, pk):
|
||||||
|
project = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(pk=pk)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Issue.issue_objects.filter(
|
||||||
|
project_id=self.kwargs.get("pk"),
|
||||||
|
parent__isnull=True,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
sub_issues=Issue.issue_objects.filter(
|
||||||
|
project_id=self.kwargs.get("pk"),
|
||||||
|
parent__isnull=False,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
archived_issues=Issue.objects.filter(
|
||||||
|
project_id=self.kwargs.get("pk"),
|
||||||
|
archived_at__isnull=False,
|
||||||
|
parent__isnull=True,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
archived_sub_issues=Issue.objects.filter(
|
||||||
|
project_id=self.kwargs.get("pk"),
|
||||||
|
archived_at__isnull=False,
|
||||||
|
parent__isnull=False,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
draft_issues=Issue.objects.filter(
|
||||||
|
project_id=self.kwargs.get("pk"),
|
||||||
|
is_draft=True,
|
||||||
|
parent__isnull=True,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
draft_sub_issues=Issue.objects.filter(
|
||||||
|
project_id=self.kwargs.get("pk"),
|
||||||
|
is_draft=True,
|
||||||
|
parent__isnull=False,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
serializer = ProjectListSerializer(project)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def create(self, request, slug):
|
def create(self, request, slug):
|
||||||
try:
|
try:
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
@ -471,6 +540,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
|||||||
permission_classes = [
|
permission_classes = [
|
||||||
AllowAny,
|
AllowAny,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Cache the below api for 24 hours
|
# Cache the below api for 24 hours
|
||||||
@cache_response(60 * 60 * 24, user=False)
|
@cache_response(60 * 60 * 24, user=False)
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
17
apiserver/plane/db/management/commands/clear_cache.py
Normal file
17
apiserver/plane/db/management/commands/clear_cache.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Django imports
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.core.management import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Clear Cache before starting the server to remove stale values"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
try:
|
||||||
|
cache.clear()
|
||||||
|
self.stdout.write(self.style.SUCCESS("Cache Cleared"))
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
# Another ClientError occurred
|
||||||
|
self.stdout.write(self.style.ERROR("Failed to clear cache"))
|
||||||
|
return
|
61
apiserver/plane/db/management/commands/test_email.py
Normal file
61
apiserver/plane/db/management/commands/test_email.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
|
from django.core.management import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from plane.license.utils.instance_value import get_email_configuration
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Django command to pause execution until db is available"""
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
# Positional argument
|
||||||
|
parser.add_argument("to_email", type=str, help="receiver's email")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
receiver_email = options.get("to_email")
|
||||||
|
|
||||||
|
if not receiver_email:
|
||||||
|
raise CommandError("Reciever email is required")
|
||||||
|
|
||||||
|
(
|
||||||
|
EMAIL_HOST,
|
||||||
|
EMAIL_HOST_USER,
|
||||||
|
EMAIL_HOST_PASSWORD,
|
||||||
|
EMAIL_PORT,
|
||||||
|
EMAIL_USE_TLS,
|
||||||
|
EMAIL_FROM,
|
||||||
|
) = get_email_configuration()
|
||||||
|
|
||||||
|
connection = get_connection(
|
||||||
|
host=EMAIL_HOST,
|
||||||
|
port=int(EMAIL_PORT),
|
||||||
|
username=EMAIL_HOST_USER,
|
||||||
|
password=EMAIL_HOST_PASSWORD,
|
||||||
|
use_tls=EMAIL_USE_TLS == "1",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
# Prepare email details
|
||||||
|
subject = "Email Notification from Plane"
|
||||||
|
message = (
|
||||||
|
"This is a sample email notification sent from Plane application."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("Trying to send test email..."))
|
||||||
|
|
||||||
|
# Send the email
|
||||||
|
try:
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=message,
|
||||||
|
from_email=EMAIL_FROM,
|
||||||
|
to=[receiver_email],
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
msg.send()
|
||||||
|
self.stdout.write(self.style.SUCCESS("Email succesfully sent"))
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"Error: Email could not be delivered due to {e}"
|
||||||
|
)
|
||||||
|
)
|
@ -1,8 +1,11 @@
|
|||||||
# from django.utils.encoding import force_bytes
|
# Python imports
|
||||||
# import hashlib
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
|
||||||
@ -36,7 +39,7 @@ def cache_response(timeout=60 * 60, path=None, user=True):
|
|||||||
)
|
)
|
||||||
response = view_func(instance, request, *args, **kwargs)
|
response = view_func(instance, request, *args, **kwargs)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200 and not settings.DEBUG:
|
||||||
cache.set(
|
cache.set(
|
||||||
key,
|
key,
|
||||||
{"data": response.data, "status": response.status_code},
|
{"data": response.data, "status": response.status_code},
|
||||||
|
@ -495,13 +495,6 @@ function install() {
|
|||||||
|
|
||||||
update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')"
|
update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')"
|
||||||
|
|
||||||
if command -v crontab &> /dev/null; then
|
|
||||||
sudo touch /etc/cron.daily/makeplane
|
|
||||||
sudo chmod +x /etc/cron.daily/makeplane
|
|
||||||
sudo echo "0 2 * * * root /usr/local/bin/plane-app --upgrade" > /etc/cron.daily/makeplane
|
|
||||||
sudo crontab /etc/cron.daily/makeplane
|
|
||||||
fi
|
|
||||||
|
|
||||||
show_message "Plane Installed Successfully ✅"
|
show_message "Plane Installed Successfully ✅"
|
||||||
show_message ""
|
show_message ""
|
||||||
else
|
else
|
||||||
@ -607,11 +600,6 @@ function uninstall() {
|
|||||||
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
||||||
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
||||||
|
|
||||||
if command -v crontab &> /dev/null; then
|
|
||||||
sudo crontab -r &> /dev/null
|
|
||||||
sudo rm /etc/cron.daily/makeplane &> /dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
||||||
show_message "- Configuration Cleaned ✅"
|
show_message "- Configuration Cleaned ✅"
|
||||||
|
|
||||||
|
@ -1,18 +1,12 @@
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
x-app-env : &app-env
|
x-app-env: &app-env
|
||||||
environment:
|
environment:
|
||||||
- NGINX_PORT=${NGINX_PORT:-80}
|
- NGINX_PORT=${NGINX_PORT:-80}
|
||||||
- WEB_URL=${WEB_URL:-http://localhost}
|
- WEB_URL=${WEB_URL:-http://localhost}
|
||||||
- DEBUG=${DEBUG:-0}
|
- DEBUG=${DEBUG:-0}
|
||||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated
|
|
||||||
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} # deprecated
|
|
||||||
- SENTRY_DSN=${SENTRY_DSN:-""}
|
- SENTRY_DSN=${SENTRY_DSN:-""}
|
||||||
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
|
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-""}
|
|
||||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-""}
|
|
||||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
|
|
||||||
- DOCKERIZED=${DOCKERIZED:-1} # deprecated
|
|
||||||
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""}
|
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""}
|
||||||
# Gunicorn Workers
|
# Gunicorn Workers
|
||||||
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
|
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
|
||||||
@ -28,20 +22,6 @@ x-app-env : &app-env
|
|||||||
- REDIS_HOST=${REDIS_HOST:-plane-redis}
|
- REDIS_HOST=${REDIS_HOST:-plane-redis}
|
||||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||||
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
|
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
|
||||||
# EMAIL SETTINGS - Deprecated can be configured through admin panel
|
|
||||||
- EMAIL_HOST=${EMAIL_HOST:-""}
|
|
||||||
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
|
|
||||||
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
|
|
||||||
- EMAIL_PORT=${EMAIL_PORT:-587}
|
|
||||||
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"}
|
|
||||||
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
|
|
||||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
|
|
||||||
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
|
|
||||||
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
|
||||||
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
|
|
||||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
|
||||||
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
|
|
||||||
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
|
|
||||||
# Application secret
|
# Application secret
|
||||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||||
# DATA STORE SETTINGS
|
# DATA STORE SETTINGS
|
||||||
@ -122,8 +102,8 @@ services:
|
|||||||
pull_policy: ${PULL_POLICY:-always}
|
pull_policy: ${PULL_POLICY:-always}
|
||||||
restart: no
|
restart: no
|
||||||
command: >
|
command: >
|
||||||
sh -c "python manage.py wait_for_db &&
|
sh -c "python manage.py wait_for_db &&
|
||||||
python manage.py migrate"
|
python manage.py migrate"
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
@ -159,7 +139,7 @@ services:
|
|||||||
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-latest}
|
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-latest}
|
||||||
pull_policy: ${PULL_POLICY:-always}
|
pull_policy: ${PULL_POLICY:-always}
|
||||||
ports:
|
ports:
|
||||||
- ${NGINX_PORT}:80
|
- ${NGINX_PORT}:80
|
||||||
depends_on:
|
depends_on:
|
||||||
- web
|
- web
|
||||||
- api
|
- api
|
||||||
|
@ -7,13 +7,8 @@ API_REPLICAS=1
|
|||||||
NGINX_PORT=80
|
NGINX_PORT=80
|
||||||
WEB_URL=http://localhost
|
WEB_URL=http://localhost
|
||||||
DEBUG=0
|
DEBUG=0
|
||||||
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
SENTRY_ENVIRONMENT=production
|
SENTRY_ENVIRONMENT=production
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GITHUB_CLIENT_ID=
|
|
||||||
GITHUB_CLIENT_SECRET=
|
|
||||||
DOCKERIZED=1 # deprecated
|
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost
|
CORS_ALLOWED_ORIGINS=http://localhost
|
||||||
|
|
||||||
#DB SETTINGS
|
#DB SETTINGS
|
||||||
@ -30,19 +25,7 @@ REDIS_HOST=plane-redis
|
|||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_URL=redis://${REDIS_HOST}:6379/
|
REDIS_URL=redis://${REDIS_HOST}:6379/
|
||||||
|
|
||||||
# EMAIL SETTINGS
|
# Secret Key
|
||||||
EMAIL_HOST=
|
|
||||||
EMAIL_HOST_USER=
|
|
||||||
EMAIL_HOST_PASSWORD=
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_FROM=Team Plane <team@mailer.plane.so>
|
|
||||||
EMAIL_USE_TLS=1
|
|
||||||
EMAIL_USE_SSL=0
|
|
||||||
|
|
||||||
# LOGIN/SIGNUP SETTINGS
|
|
||||||
ENABLE_SIGNUP=1
|
|
||||||
ENABLE_EMAIL_PASSWORD=1
|
|
||||||
ENABLE_MAGIC_LINK_LOGIN=0
|
|
||||||
SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
|
SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
|
||||||
|
|
||||||
# DATA STORE SETTINGS
|
# DATA STORE SETTINGS
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Selection } from "@tiptap/pm/state";
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
interface EditorClassNames {
|
interface EditorClassNames {
|
||||||
@ -18,6 +19,19 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to find the parent node of a specific type
|
||||||
|
export function findParentNodeOfType(selection: Selection, typeName: string) {
|
||||||
|
let depth = selection.$anchor.depth;
|
||||||
|
while (depth > 0) {
|
||||||
|
const node = selection.$anchor.node(depth);
|
||||||
|
if (node.type.name === typeName) {
|
||||||
|
return { node, pos: selection.$anchor.start(depth) - 1 };
|
||||||
|
}
|
||||||
|
depth--;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
|
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
|
||||||
while (node !== null && node.nodeName !== "TABLE") {
|
while (node !== null && node.nodeName !== "TABLE") {
|
||||||
node = node.parentNode;
|
node = node.parentNode;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { ReactNode } from "react";
|
import { FC, ReactNode } from "react";
|
||||||
|
|
||||||
interface EditorContainerProps {
|
interface EditorContainerProps {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
@ -8,17 +8,54 @@ interface EditorContainerProps {
|
|||||||
hideDragHandle?: () => void;
|
hideDragHandle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, children }: EditorContainerProps) => (
|
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||||
<div
|
const { editor, editorClassNames, hideDragHandle, children } = props;
|
||||||
id="editor-container"
|
|
||||||
onClick={() => {
|
const handleContainerClick = () => {
|
||||||
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
|
if (!editor) return;
|
||||||
}}
|
if (!editor.isEditable) return;
|
||||||
onMouseLeave={() => {
|
if (editor.isFocused) return; // If editor is already focused, do nothing
|
||||||
hideDragHandle?.();
|
|
||||||
}}
|
const { selection } = editor.state;
|
||||||
className={`cursor-text ${editorClassNames}`}
|
const currentNode = selection.$from.node();
|
||||||
>
|
|
||||||
{children}
|
editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end
|
||||||
</div>
|
|
||||||
);
|
if (
|
||||||
|
currentNode.content.size === 0 && // Check if the current node is empty
|
||||||
|
!(
|
||||||
|
editor.isActive("orderedList") ||
|
||||||
|
editor.isActive("bulletList") ||
|
||||||
|
editor.isActive("taskItem") ||
|
||||||
|
editor.isActive("table") ||
|
||||||
|
editor.isActive("blockquote") ||
|
||||||
|
editor.isActive("codeBlock")
|
||||||
|
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert a new paragraph at the end of the document
|
||||||
|
const endPosition = editor?.state.doc.content.size;
|
||||||
|
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
|
||||||
|
|
||||||
|
// Focus the newly added paragraph for immediate editing
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.setTextSelection(endPosition + 1)
|
||||||
|
.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="editor-container"
|
||||||
|
onClick={handleContainerClick}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
hideDragHandle?.();
|
||||||
|
}}
|
||||||
|
className={`cursor-text ${editorClassNames}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -1,17 +1,28 @@
|
|||||||
import { Editor, EditorContent } from "@tiptap/react";
|
import { Editor, EditorContent } from "@tiptap/react";
|
||||||
import { ReactNode } from "react";
|
import { FC, ReactNode } from "react";
|
||||||
import { ImageResizer } from "src/ui/extensions/image/image-resize";
|
import { ImageResizer } from "src/ui/extensions/image/image-resize";
|
||||||
|
|
||||||
interface EditorContentProps {
|
interface EditorContentProps {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
editorContentCustomClassNames: string | undefined;
|
editorContentCustomClassNames: string | undefined;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = "", children }: EditorContentProps) => (
|
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
|
||||||
<div className={`contentEditor ${editorContentCustomClassNames}`}>
|
const { editor, editorContentCustomClassNames = "", tabIndex, children } = props;
|
||||||
<EditorContent editor={editor} />
|
|
||||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
|
return (
|
||||||
{children}
|
<div
|
||||||
</div>
|
className={`contentEditor ${editorContentCustomClassNames}`}
|
||||||
);
|
tabIndex={tabIndex}
|
||||||
|
onFocus={() => {
|
||||||
|
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -5,6 +5,8 @@ import ImageExt from "@tiptap/extension-image";
|
|||||||
import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image";
|
import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image";
|
||||||
import { DeleteImage } from "src/types/delete-image";
|
import { DeleteImage } from "src/types/delete-image";
|
||||||
import { RestoreImage } from "src/types/restore-image";
|
import { RestoreImage } from "src/types/restore-image";
|
||||||
|
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
|
||||||
|
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
|
||||||
|
|
||||||
interface ImageNode extends ProseMirrorNode {
|
interface ImageNode extends ProseMirrorNode {
|
||||||
attrs: {
|
attrs: {
|
||||||
@ -18,6 +20,12 @@ const IMAGE_NODE_TYPE = "image";
|
|||||||
|
|
||||||
export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) =>
|
export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) =>
|
||||||
ImageExt.extend({
|
ImageExt.extend({
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
ArrowDown: insertLineBelowImageAction,
|
||||||
|
ArrowUp: insertLineAboveImageAction,
|
||||||
|
};
|
||||||
|
},
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [
|
||||||
UploadImagesPlugin(cancelUploadImage),
|
UploadImagesPlugin(cancelUploadImage),
|
||||||
|
@ -0,0 +1,45 @@
|
|||||||
|
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||||
|
|
||||||
|
export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||||
|
const { selection, doc } = editor.state;
|
||||||
|
const { $from, $to } = selection;
|
||||||
|
|
||||||
|
let imageNode: ProseMirrorNode | null = null;
|
||||||
|
let imagePos: number | null = null;
|
||||||
|
|
||||||
|
// Check if the selection itself is an image node
|
||||||
|
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||||
|
if (node.type.name === "image") {
|
||||||
|
imageNode = node;
|
||||||
|
imagePos = pos;
|
||||||
|
return false; // Stop iterating once an image node is found
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (imageNode === null || imagePos === null) return false;
|
||||||
|
|
||||||
|
// Since we want to insert above the image, we use the imagePos directly
|
||||||
|
const insertPos = imagePos;
|
||||||
|
|
||||||
|
if (insertPos < 0) return false;
|
||||||
|
|
||||||
|
// Check for an existing node immediately before the image
|
||||||
|
if (insertPos === 0) {
|
||||||
|
// If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there
|
||||||
|
editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run();
|
||||||
|
editor.chain().setTextSelection(insertPos).run();
|
||||||
|
} else {
|
||||||
|
const prevNode = doc.nodeAt(insertPos);
|
||||||
|
|
||||||
|
if (prevNode && prevNode.type.name === "paragraph") {
|
||||||
|
// If the previous node is a paragraph, move the cursor there
|
||||||
|
editor.chain().setTextSelection(insertPos).run();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
@ -0,0 +1,46 @@
|
|||||||
|
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||||
|
|
||||||
|
export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||||
|
const { selection, doc } = editor.state;
|
||||||
|
const { $from, $to } = selection;
|
||||||
|
|
||||||
|
let imageNode: ProseMirrorNode | null = null;
|
||||||
|
let imagePos: number | null = null;
|
||||||
|
|
||||||
|
// Check if the selection itself is an image node
|
||||||
|
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||||
|
if (node.type.name === "image") {
|
||||||
|
imageNode = node;
|
||||||
|
imagePos = pos;
|
||||||
|
return false; // Stop iterating once an image node is found
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (imageNode === null || imagePos === null) return false;
|
||||||
|
|
||||||
|
const guaranteedImageNode: ProseMirrorNode = imageNode;
|
||||||
|
const nextNodePos = imagePos + guaranteedImageNode.nodeSize;
|
||||||
|
|
||||||
|
// Check for an existing node immediately after the image
|
||||||
|
const nextNode = doc.nodeAt(nextNodePos);
|
||||||
|
|
||||||
|
if (nextNode && nextNode.type.name === "paragraph") {
|
||||||
|
// If the next node is a paragraph, move the cursor there
|
||||||
|
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
|
||||||
|
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||||
|
} else if (!nextNode) {
|
||||||
|
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
|
||||||
|
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.setTextSelection(nextNodePos + 1)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
// If the next node is not a paragraph, do not proceed
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
@ -27,7 +27,7 @@ import { RestoreImage } from "src/types/restore-image";
|
|||||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||||
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||||
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
||||||
import { CustomHorizontalRule } from "./horizontal-rule/horizontal-rule";
|
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
||||||
|
|
||||||
export const CoreEditorExtensions = (
|
export const CoreEditorExtensions = (
|
||||||
mentionConfig: {
|
mentionConfig: {
|
||||||
@ -66,7 +66,6 @@ export const CoreEditorExtensions = (
|
|||||||
CustomQuoteExtension.configure({
|
CustomQuoteExtension.configure({
|
||||||
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
CustomHorizontalRule.configure({
|
CustomHorizontalRule.configure({
|
||||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||||
}),
|
}),
|
||||||
|
@ -25,6 +25,8 @@ import { tableControls } from "src/ui/extensions/table/table/table-controls";
|
|||||||
import { TableView } from "src/ui/extensions/table/table/table-view";
|
import { TableView } from "src/ui/extensions/table/table/table-view";
|
||||||
import { createTable } from "src/ui/extensions/table/table/utilities/create-table";
|
import { createTable } from "src/ui/extensions/table/table/utilities/create-table";
|
||||||
import { deleteTableWhenAllCellsSelected } from "src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected";
|
import { deleteTableWhenAllCellsSelected } from "src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected";
|
||||||
|
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
|
||||||
|
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
|
||||||
|
|
||||||
export interface TableOptions {
|
export interface TableOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
@ -231,6 +233,8 @@ export const Table = Node.create({
|
|||||||
"Mod-Backspace": deleteTableWhenAllCellsSelected,
|
"Mod-Backspace": deleteTableWhenAllCellsSelected,
|
||||||
Delete: deleteTableWhenAllCellsSelected,
|
Delete: deleteTableWhenAllCellsSelected,
|
||||||
"Mod-Delete": deleteTableWhenAllCellsSelected,
|
"Mod-Delete": deleteTableWhenAllCellsSelected,
|
||||||
|
ArrowDown: insertLineBelowTableAction,
|
||||||
|
ArrowUp: insertLineAboveTableAction,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -0,0 +1,50 @@
|
|||||||
|
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||||
|
import { findParentNodeOfType } from "src/lib/utils";
|
||||||
|
|
||||||
|
export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||||
|
// Check if the current selection or the closest node is a table
|
||||||
|
if (!editor.isActive("table")) return false;
|
||||||
|
|
||||||
|
// Get the current selection
|
||||||
|
const { selection } = editor.state;
|
||||||
|
|
||||||
|
// Find the table node and its position
|
||||||
|
const tableNode = findParentNodeOfType(selection, "table");
|
||||||
|
if (!tableNode) return false;
|
||||||
|
|
||||||
|
const tablePos = tableNode.pos;
|
||||||
|
|
||||||
|
// Determine if the selection is in the first row of the table
|
||||||
|
const firstRow = tableNode.node.child(0);
|
||||||
|
const selectionPath = (selection.$anchor as any).path;
|
||||||
|
const selectionInFirstRow = selectionPath.includes(firstRow);
|
||||||
|
|
||||||
|
if (!selectionInFirstRow) return false;
|
||||||
|
|
||||||
|
// Check if the table is at the very start of the document or its parent node
|
||||||
|
if (tablePos === 0) {
|
||||||
|
// The table is at the start, so just insert a paragraph at the current position
|
||||||
|
editor.chain().insertContentAt(tablePos, { type: "paragraph" }).run();
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.setTextSelection(tablePos + 1)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
// The table is not at the start, check for the node immediately before the table
|
||||||
|
const prevNodePos = tablePos - 1;
|
||||||
|
|
||||||
|
if (prevNodePos <= 0) return false;
|
||||||
|
|
||||||
|
const prevNode = editor.state.doc.nodeAt(prevNodePos - 1);
|
||||||
|
|
||||||
|
if (prevNode && prevNode.type.name === "paragraph") {
|
||||||
|
// If there's a paragraph before the table, move the cursor to the end of that paragraph
|
||||||
|
const endOfParagraphPos = tablePos - prevNode.nodeSize;
|
||||||
|
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
@ -0,0 +1,48 @@
|
|||||||
|
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||||
|
import { findParentNodeOfType } from "src/lib/utils";
|
||||||
|
|
||||||
|
export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||||
|
// Check if the current selection or the closest node is a table
|
||||||
|
if (!editor.isActive("table")) return false;
|
||||||
|
|
||||||
|
// Get the current selection
|
||||||
|
const { selection } = editor.state;
|
||||||
|
|
||||||
|
// Find the table node and its position
|
||||||
|
const tableNode = findParentNodeOfType(selection, "table");
|
||||||
|
if (!tableNode) return false;
|
||||||
|
|
||||||
|
const tablePos = tableNode.pos;
|
||||||
|
const table = tableNode.node;
|
||||||
|
|
||||||
|
// Determine if the selection is in the last row of the table
|
||||||
|
const rowCount = table.childCount;
|
||||||
|
const lastRow = table.child(rowCount - 1);
|
||||||
|
const selectionPath = (selection.$anchor as any).path;
|
||||||
|
const selectionInLastRow = selectionPath.includes(lastRow);
|
||||||
|
|
||||||
|
if (!selectionInLastRow) return false;
|
||||||
|
|
||||||
|
// Calculate the position immediately after the table
|
||||||
|
const nextNodePos = tablePos + table.nodeSize;
|
||||||
|
|
||||||
|
// Check for an existing node immediately after the table
|
||||||
|
const nextNode = editor.state.doc.nodeAt(nextNodePos);
|
||||||
|
|
||||||
|
if (nextNode && nextNode.type.name === "paragraph") {
|
||||||
|
// If the next node is an paragraph, move the cursor there
|
||||||
|
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
|
||||||
|
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||||
|
} else if (!nextNode) {
|
||||||
|
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
|
||||||
|
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.setTextSelection(nextNodePos + 1)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
@ -5,7 +5,6 @@ import { Color } from "@tiptap/extension-color";
|
|||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import { Markdown } from "tiptap-markdown";
|
import { Markdown } from "tiptap-markdown";
|
||||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
|
||||||
|
|
||||||
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
|
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
|
||||||
import { Table } from "src/ui/extensions/table/table";
|
import { Table } from "src/ui/extensions/table/table";
|
||||||
@ -17,6 +16,11 @@ import { isValidHttpUrl } from "src/lib/utils";
|
|||||||
import { Mentions } from "src/ui/mentions";
|
import { Mentions } from "src/ui/mentions";
|
||||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||||
|
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
||||||
|
import { CustomQuoteExtension } from "src/ui/extensions/quote";
|
||||||
|
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
||||||
|
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
|
||||||
|
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||||
|
|
||||||
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||||
mentionSuggestions: IMentionSuggestion[];
|
mentionSuggestions: IMentionSuggestion[];
|
||||||
@ -38,36 +42,31 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
|||||||
class: "leading-normal -mb-2",
|
class: "leading-normal -mb-2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
blockquote: {
|
code: false,
|
||||||
HTMLAttributes: {
|
|
||||||
class: "border-l-4 border-custom-border-300",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
|
|
||||||
spellcheck: "false",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
horizontalRule: {
|
horizontalRule: false,
|
||||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
blockquote: false,
|
||||||
},
|
dropcursor: false,
|
||||||
dropcursor: {
|
|
||||||
color: "rgba(var(--color-text-100))",
|
|
||||||
width: 2,
|
|
||||||
},
|
|
||||||
gapcursor: false,
|
gapcursor: false,
|
||||||
}),
|
}),
|
||||||
Gapcursor,
|
CustomQuoteExtension.configure({
|
||||||
|
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
||||||
|
}),
|
||||||
|
CustomHorizontalRule.configure({
|
||||||
|
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||||
|
}),
|
||||||
CustomLinkExtension.configure({
|
CustomLinkExtension.configure({
|
||||||
|
openOnClick: true,
|
||||||
|
autolink: true,
|
||||||
|
linkOnPaste: true,
|
||||||
protocols: ["http", "https"],
|
protocols: ["http", "https"],
|
||||||
validate: (url) => isValidHttpUrl(url),
|
validate: (url: string) => isValidHttpUrl(url),
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class:
|
class:
|
||||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
CustomTypographyExtension,
|
||||||
ReadOnlyImageExtension.configure({
|
ReadOnlyImageExtension.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "rounded-lg border border-custom-border-300",
|
class: "rounded-lg border border-custom-border-300",
|
||||||
@ -87,6 +86,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
|||||||
},
|
},
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
|
CustomCodeBlockExtension,
|
||||||
|
CustomCodeInlineExtension,
|
||||||
Markdown.configure({
|
Markdown.configure({
|
||||||
html: true,
|
html: true,
|
||||||
transformCopiedText: true,
|
transformCopiedText: true,
|
||||||
|
@ -19,7 +19,7 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<h2 className="font-medium">Table of Contents</h2>
|
<h2 className="font-medium">Outline</h2>
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
{markings.length !== 0 ? (
|
{markings.length !== 0 ? (
|
||||||
markings.map((marking) =>
|
markings.map((marking) =>
|
||||||
|
@ -29,11 +29,13 @@ type IPageRenderer = {
|
|||||||
editorContentCustomClassNames?: string;
|
editorContentCustomClassNames?: string;
|
||||||
hideDragHandle?: () => void;
|
hideDragHandle?: () => void;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageRenderer = (props: IPageRenderer) => {
|
export const PageRenderer = (props: IPageRenderer) => {
|
||||||
const {
|
const {
|
||||||
documentDetails,
|
documentDetails,
|
||||||
|
tabIndex,
|
||||||
editor,
|
editor,
|
||||||
editorClassNames,
|
editorClassNames,
|
||||||
editorContentCustomClassNames,
|
editorContentCustomClassNames,
|
||||||
@ -152,7 +154,7 @@ export const PageRenderer = (props: IPageRenderer) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full pb-64 md:pl-7 pl-3 pt-5 page-renderer">
|
<div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
|
||||||
{!readonly ? (
|
{!readonly ? (
|
||||||
<input
|
<input
|
||||||
onChange={(e) => handlePageTitleChange(e.target.value)}
|
onChange={(e) => handlePageTitleChange(e.target.value)}
|
||||||
@ -169,7 +171,11 @@ export const PageRenderer = (props: IPageRenderer) => {
|
|||||||
)}
|
)}
|
||||||
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
|
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
|
||||||
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
|
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
|
||||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
<EditorContentWrapper
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
editor={editor}
|
||||||
|
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||||
|
/>
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && linkViewProps && coordinates && (
|
{isOpen && linkViewProps && coordinates && (
|
||||||
|
@ -47,6 +47,8 @@ interface IDocumentEditor {
|
|||||||
duplicationConfig?: IDuplicationConfig;
|
duplicationConfig?: IDuplicationConfig;
|
||||||
pageLockConfig?: IPageLockConfig;
|
pageLockConfig?: IPageLockConfig;
|
||||||
pageArchiveConfig?: IPageArchiveConfig;
|
pageArchiveConfig?: IPageArchiveConfig;
|
||||||
|
|
||||||
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
interface DocumentEditorProps extends IDocumentEditor {
|
interface DocumentEditorProps extends IDocumentEditor {
|
||||||
forwardedRef?: React.Ref<EditorHandle>;
|
forwardedRef?: React.Ref<EditorHandle>;
|
||||||
@ -79,6 +81,7 @@ const DocumentEditor = ({
|
|||||||
cancelUploadImage,
|
cancelUploadImage,
|
||||||
onActionCompleteHandler,
|
onActionCompleteHandler,
|
||||||
rerenderOnPropsChange,
|
rerenderOnPropsChange,
|
||||||
|
tabIndex,
|
||||||
}: IDocumentEditor) => {
|
}: IDocumentEditor) => {
|
||||||
const { markings, updateMarkings } = useEditorMarkings();
|
const { markings, updateMarkings } = useEditorMarkings();
|
||||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
||||||
@ -160,6 +163,7 @@ const DocumentEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||||
<PageRenderer
|
<PageRenderer
|
||||||
|
tabIndex={tabIndex}
|
||||||
onActionCompleteHandler={onActionCompleteHandler}
|
onActionCompleteHandler={onActionCompleteHandler}
|
||||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||||
readonly={false}
|
readonly={false}
|
||||||
|
@ -28,6 +28,7 @@ interface IDocumentReadOnlyEditor {
|
|||||||
message: string;
|
message: string;
|
||||||
type: "success" | "error" | "warning" | "info";
|
type: "success" | "error" | "warning" | "info";
|
||||||
}) => void;
|
}) => void;
|
||||||
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
||||||
@ -51,6 +52,7 @@ const DocumentReadOnlyEditor = ({
|
|||||||
pageArchiveConfig,
|
pageArchiveConfig,
|
||||||
rerenderOnPropsChange,
|
rerenderOnPropsChange,
|
||||||
onActionCompleteHandler,
|
onActionCompleteHandler,
|
||||||
|
tabIndex,
|
||||||
}: DocumentReadOnlyEditorProps) => {
|
}: DocumentReadOnlyEditorProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
||||||
@ -108,9 +110,10 @@ const DocumentReadOnlyEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||||
<PageRenderer
|
<PageRenderer
|
||||||
|
tabIndex={tabIndex}
|
||||||
onActionCompleteHandler={onActionCompleteHandler}
|
onActionCompleteHandler={onActionCompleteHandler}
|
||||||
updatePageTitle={() => Promise.resolve()}
|
updatePageTitle={() => Promise.resolve()}
|
||||||
readonly={true}
|
readonly
|
||||||
editor={editor}
|
editor={editor}
|
||||||
editorClassNames={editorClassNames}
|
editorClassNames={editorClassNames}
|
||||||
documentDetails={documentDetails}
|
documentDetails={documentDetails}
|
||||||
|
@ -42,6 +42,7 @@ interface ILiteTextEditor {
|
|||||||
mentionHighlights?: string[];
|
mentionHighlights?: string[];
|
||||||
mentionSuggestions?: IMentionSuggestion[];
|
mentionSuggestions?: IMentionSuggestion[];
|
||||||
submitButton?: React.ReactNode;
|
submitButton?: React.ReactNode;
|
||||||
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LiteTextEditorProps extends ILiteTextEditor {
|
interface LiteTextEditorProps extends ILiteTextEditor {
|
||||||
@ -74,6 +75,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
|
|||||||
mentionHighlights,
|
mentionHighlights,
|
||||||
mentionSuggestions,
|
mentionSuggestions,
|
||||||
submitButton,
|
submitButton,
|
||||||
|
tabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
@ -103,7 +105,11 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
|
|||||||
return (
|
return (
|
||||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
<EditorContentWrapper
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
editor={editor}
|
||||||
|
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||||
|
/>
|
||||||
<div className="mt-4 w-full">
|
<div className="mt-4 w-full">
|
||||||
<FixedMenu
|
<FixedMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
|
@ -8,6 +8,7 @@ interface ICoreReadOnlyEditor {
|
|||||||
borderOnFocus?: boolean;
|
borderOnFocus?: boolean;
|
||||||
customClassName?: string;
|
customClassName?: string;
|
||||||
mentionHighlights: string[];
|
mentionHighlights: string[];
|
||||||
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditorCoreProps extends ICoreReadOnlyEditor {
|
interface EditorCoreProps extends ICoreReadOnlyEditor {
|
||||||
@ -27,6 +28,7 @@ const LiteReadOnlyEditor = ({
|
|||||||
value,
|
value,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
mentionHighlights,
|
mentionHighlights,
|
||||||
|
tabIndex,
|
||||||
}: EditorCoreProps) => {
|
}: EditorCoreProps) => {
|
||||||
const editor = useReadOnlyEditor({
|
const editor = useReadOnlyEditor({
|
||||||
value,
|
value,
|
||||||
@ -45,7 +47,11 @@ const LiteReadOnlyEditor = ({
|
|||||||
return (
|
return (
|
||||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
<EditorContentWrapper
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
editor={editor}
|
||||||
|
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
);
|
);
|
||||||
|
@ -36,6 +36,7 @@ export type IRichTextEditor = {
|
|||||||
debouncedUpdatesEnabled?: boolean;
|
debouncedUpdatesEnabled?: boolean;
|
||||||
mentionHighlights?: string[];
|
mentionHighlights?: string[];
|
||||||
mentionSuggestions?: IMentionSuggestion[];
|
mentionSuggestions?: IMentionSuggestion[];
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface RichTextEditorProps extends IRichTextEditor {
|
export interface RichTextEditorProps extends IRichTextEditor {
|
||||||
@ -68,6 +69,7 @@ const RichTextEditor = ({
|
|||||||
mentionHighlights,
|
mentionHighlights,
|
||||||
rerenderOnPropsChange,
|
rerenderOnPropsChange,
|
||||||
mentionSuggestions,
|
mentionSuggestions,
|
||||||
|
tabIndex,
|
||||||
}: RichTextEditorProps) => {
|
}: RichTextEditorProps) => {
|
||||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
||||||
|
|
||||||
@ -100,17 +102,21 @@ const RichTextEditor = ({
|
|||||||
customClassName,
|
customClassName,
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
// React.useEffect(() => {
|
||||||
if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
|
// if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
|
||||||
}, [editor, initialValue]);
|
// }, [editor, initialValue]);
|
||||||
|
//
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}>
|
<EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}>
|
||||||
{editor && <EditorBubbleMenu editor={editor} />}
|
{editor && <EditorBubbleMenu editor={editor} />}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
<EditorContentWrapper
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
editor={editor}
|
||||||
|
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
);
|
);
|
||||||
|
@ -121,7 +121,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
|||||||
<button
|
<button
|
||||||
key={item.name}
|
key={item.name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={item.command}
|
onClick={(e) => {
|
||||||
|
item.command();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
|
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
|
||||||
{
|
{
|
||||||
|
@ -33,8 +33,9 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
||||||
{ "bg-custom-background-100": isOpen }
|
{ "bg-custom-background-100": isOpen }
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-base">↗</p>
|
<p className="text-base">↗</p>
|
||||||
@ -60,6 +61,9 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="Paste a link"
|
placeholder="Paste a link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 p-1 text-sm outline-none placeholder:text-custom-text-400"
|
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 p-1 text-sm outline-none placeholder:text-custom-text-400"
|
||||||
defaultValue={editor.getAttributes("link").href || ""}
|
defaultValue={editor.getAttributes("link").href || ""}
|
||||||
/>
|
/>
|
||||||
@ -67,9 +71,10 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
unsetLinkEditor(editor);
|
unsetLinkEditor(editor);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash className="h-4 w-4" />
|
<Trash className="h-4 w-4" />
|
||||||
@ -78,7 +83,8 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
<button
|
<button
|
||||||
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
|
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
onLinkSubmit();
|
onLinkSubmit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -47,7 +47,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={(e) => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
|
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
|
||||||
>
|
>
|
||||||
<span>{activeItem?.name}</span>
|
<span>{activeItem?.name}</span>
|
||||||
@ -60,9 +63,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
<button
|
<button
|
||||||
key={item.name}
|
key={item.name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
item.command();
|
item.command();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
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-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
|
||||||
|
@ -9,6 +9,7 @@ interface IRichTextReadOnlyEditor {
|
|||||||
borderOnFocus?: boolean;
|
borderOnFocus?: boolean;
|
||||||
customClassName?: string;
|
customClassName?: string;
|
||||||
mentionHighlights?: string[];
|
mentionHighlights?: string[];
|
||||||
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
|
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
|
||||||
|
@ -1,11 +1,7 @@
|
|||||||
import type { TIssue, IIssueFilterOptions } from "@plane/types";
|
import type { TIssue, IIssueFilterOptions } from "@plane/types";
|
||||||
|
|
||||||
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
|
|
||||||
|
|
||||||
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
|
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
|
||||||
|
|
||||||
export type TCycleLayout = "list" | "board" | "gantt";
|
|
||||||
|
|
||||||
export interface ICycle {
|
export interface ICycle {
|
||||||
backlog_issues: number;
|
backlog_issues: number;
|
||||||
cancelled_issues: number;
|
cancelled_issues: number;
|
||||||
@ -30,6 +26,7 @@ export interface ICycle {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
start_date: string | null;
|
start_date: string | null;
|
||||||
started_issues: number;
|
started_issues: number;
|
||||||
|
sub_issues: number;
|
||||||
total_issues: number;
|
total_issues: number;
|
||||||
unstarted_issues: number;
|
unstarted_issues: number;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
19
packages/types/src/cycle/cycle_filters.d.ts
vendored
Normal file
19
packages/types/src/cycle/cycle_filters.d.ts
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export type TCycleTabOptions = "active" | "all";
|
||||||
|
|
||||||
|
export type TCycleLayoutOptions = "list" | "board" | "gantt";
|
||||||
|
|
||||||
|
export type TCycleDisplayFilters = {
|
||||||
|
active_tab?: TCycleTabOptions;
|
||||||
|
layout?: TCycleLayoutOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCycleFilters = {
|
||||||
|
end_date?: string[] | null;
|
||||||
|
start_date?: string[] | null;
|
||||||
|
status?: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCycleStoredFilters = {
|
||||||
|
display_filters?: TCycleDisplayFilters;
|
||||||
|
filters?: TCycleFilters;
|
||||||
|
};
|
2
packages/types/src/cycle/index.ts
Normal file
2
packages/types/src/cycle/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./cycle_filters";
|
||||||
|
export * from "./cycle";
|
2
packages/types/src/index.d.ts
vendored
2
packages/types/src/index.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
export * from "./users";
|
export * from "./users";
|
||||||
export * from "./workspace";
|
export * from "./workspace";
|
||||||
export * from "./cycles";
|
export * from "./cycle";
|
||||||
export * from "./dashboard";
|
export * from "./dashboard";
|
||||||
export * from "./projects";
|
export * from "./projects";
|
||||||
export * from "./state";
|
export * from "./state";
|
||||||
|
1
packages/types/src/modules.d.ts
vendored
1
packages/types/src/modules.d.ts
vendored
@ -30,6 +30,7 @@ export interface IModule {
|
|||||||
name: string;
|
name: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
sub_issues: number;
|
||||||
start_date: string | null;
|
start_date: string | null;
|
||||||
started_issues: number;
|
started_issues: number;
|
||||||
status: TModuleStatus;
|
status: TModuleStatus;
|
||||||
|
6
packages/types/src/projects.d.ts
vendored
6
packages/types/src/projects.d.ts
vendored
@ -23,6 +23,8 @@ export type TProjectLogoProps = {
|
|||||||
|
|
||||||
export interface IProject {
|
export interface IProject {
|
||||||
archive_in: number;
|
archive_in: number;
|
||||||
|
archived_issues: number;
|
||||||
|
archived_sub_issues: number;
|
||||||
close_in: number;
|
close_in: number;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
@ -35,6 +37,8 @@ export interface IProject {
|
|||||||
default_assignee: IUser | string | null;
|
default_assignee: IUser | string | null;
|
||||||
default_state: string | null;
|
default_state: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
|
draft_issues: number;
|
||||||
|
draft_sub_issues: number;
|
||||||
estimate: string | null;
|
estimate: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
@ -48,7 +52,9 @@ export interface IProject {
|
|||||||
network: number;
|
network: number;
|
||||||
project_lead: IUserLite | string | null;
|
project_lead: IUserLite | string | null;
|
||||||
sort_order: number | null;
|
sort_order: number | null;
|
||||||
|
sub_issues: number;
|
||||||
total_cycles: number;
|
total_cycles: number;
|
||||||
|
total_issues: number;
|
||||||
total_members: number;
|
total_members: number;
|
||||||
total_modules: number;
|
total_modules: number;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
extends: ["custom"],
|
|
||||||
};
|
|
@ -14,7 +14,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format esm,cjs --dts --external react --minify",
|
"build": "tsup src/index.ts --format esm,cjs --dts --external react --minify",
|
||||||
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
|
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
|
||||||
"lint": "eslint src/",
|
|
||||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -4,7 +4,7 @@ import { ISvgIcons } from "../type";
|
|||||||
|
|
||||||
export const CircleDotFullIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
export const CircleDotFullIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
||||||
<svg viewBox="0 0 16 16" className={`${className} stroke-1`} fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}>
|
<svg viewBox="0 0 16 16" className={`${className} stroke-1`} fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}>
|
||||||
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke="currentColor" stroke-linecap="round" />
|
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke="currentColor" strokeLinecap="round" />
|
||||||
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
|
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
@ -44,7 +44,10 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
|
|||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isInvalid = watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false;
|
const date1 = watch("date1");
|
||||||
|
const date2 = watch("date2");
|
||||||
|
|
||||||
|
const isInvalid = watch("filterType") === "range" ? new Date(date1) > new Date(date2) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={isOpen} as={Fragment}>
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
@ -91,7 +94,10 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
|
|||||||
<DayPicker
|
<DayPicker
|
||||||
selected={value ? new Date(value) : undefined}
|
selected={value ? new Date(value) : undefined}
|
||||||
defaultMonth={value ? new Date(value) : undefined}
|
defaultMonth={value ? new Date(value) : undefined}
|
||||||
onSelect={(date) => onChange(date)}
|
onSelect={(date) => {
|
||||||
|
if (!date) return;
|
||||||
|
onChange(date);
|
||||||
|
}}
|
||||||
mode="single"
|
mode="single"
|
||||||
disabled={[{ after: new Date(watch("date2")) }]}
|
disabled={[{ after: new Date(watch("date2")) }]}
|
||||||
className="border border-custom-border-200 p-3 rounded-md"
|
className="border border-custom-border-200 p-3 rounded-md"
|
||||||
@ -106,7 +112,10 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
|
|||||||
<DayPicker
|
<DayPicker
|
||||||
selected={value ? new Date(value) : undefined}
|
selected={value ? new Date(value) : undefined}
|
||||||
defaultMonth={value ? new Date(value) : undefined}
|
defaultMonth={value ? new Date(value) : undefined}
|
||||||
onSelect={(date) => onChange(date)}
|
onSelect={(date) => {
|
||||||
|
if (!date) return;
|
||||||
|
onChange(date);
|
||||||
|
}}
|
||||||
mode="single"
|
mode="single"
|
||||||
disabled={[{ before: new Date(watch("date1")) }]}
|
disabled={[{ before: new Date(watch("date1")) }]}
|
||||||
className="border border-custom-border-200 p-3 rounded-md"
|
className="border border-custom-border-200 p-3 rounded-md"
|
||||||
|
@ -127,7 +127,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
|
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
|
||||||
<Tab.Panel
|
<Tab.Panel
|
||||||
as="div"
|
as="div"
|
||||||
className="flex h-44 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?.assignees.length > 0 ? (
|
{distribution?.assignees.length > 0 ? (
|
||||||
distribution.assignees.map((assignee, index) => {
|
distribution.assignees.map((assignee, index) => {
|
||||||
@ -187,7 +187,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel
|
<Tab.Panel
|
||||||
as="div"
|
as="div"
|
||||||
className="flex h-44 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?.labels.length > 0 ? (
|
{distribution?.labels.length > 0 ? (
|
||||||
distribution.labels.map((label, index) => (
|
distribution.labels.map((label, index) => (
|
||||||
@ -230,7 +230,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel
|
<Tab.Panel
|
||||||
as="div"
|
as="div"
|
||||||
className="flex h-44 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"
|
||||||
>
|
>
|
||||||
{Object.keys(groupedIssues).map((group, index) => (
|
{Object.keys(groupedIssues).map((group, index) => (
|
||||||
<SingleProgressStats
|
<SingleProgressStats
|
||||||
|
4
web/components/cycles/active-cycle/index.ts
Normal file
4
web/components/cycles/active-cycle/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./stats";
|
||||||
|
export * from "./upcoming-cycles-list-item";
|
||||||
|
export * from "./upcoming-cycles-list";
|
@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
// hooks
|
// hooks
|
||||||
import { useCycle, useIssues, useMember, useProject } from "hooks/store";
|
import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "hooks/store";
|
||||||
// ui
|
// ui
|
||||||
import { SingleProgressStats } from "components/core";
|
import { SingleProgressStats } from "components/core";
|
||||||
import {
|
import {
|
||||||
@ -17,10 +17,11 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
CycleGroupIcon,
|
CycleGroupIcon,
|
||||||
setPromiseToast,
|
setPromiseToast,
|
||||||
|
getButtonStyling,
|
||||||
} from "@plane/ui";
|
} from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import ProgressChart from "components/core/sidebar/progress-chart";
|
import ProgressChart from "components/core/sidebar/progress-chart";
|
||||||
import { ActiveCycleProgressStats } from "components/cycles";
|
import { ActiveCycleProgressStats, UpcomingCyclesList } from "components/cycles";
|
||||||
import { StateDropdown } from "components/dropdowns";
|
import { StateDropdown } from "components/dropdowns";
|
||||||
import { EmptyState } from "components/empty-state";
|
import { EmptyState } from "components/empty-state";
|
||||||
// icons
|
// icons
|
||||||
@ -28,6 +29,7 @@ import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-re
|
|||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
|
import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
|
||||||
import { truncateText } from "helpers/string.helper";
|
import { truncateText } from "helpers/string.helper";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
// types
|
// types
|
||||||
import { ICycle, TCycleGroups } from "@plane/types";
|
import { ICycle, TCycleGroups } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
@ -41,30 +43,34 @@ interface IActiveCycleDetails {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props) => {
|
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
|
||||||
// props
|
// props
|
||||||
const { workspaceSlug, projectId } = props;
|
const { workspaceSlug, projectId } = props;
|
||||||
|
// store hooks
|
||||||
const {
|
const {
|
||||||
issues: { fetchActiveCycleIssues },
|
issues: { fetchActiveCycleIssues },
|
||||||
} = useIssues(EIssuesStoreType.CYCLE);
|
} = useIssues(EIssuesStoreType.CYCLE);
|
||||||
const {
|
const {
|
||||||
fetchActiveCycle,
|
|
||||||
currentProjectActiveCycleId,
|
currentProjectActiveCycleId,
|
||||||
|
currentProjectUpcomingCycleIds,
|
||||||
|
fetchActiveCycle,
|
||||||
getActiveCycleById,
|
getActiveCycleById,
|
||||||
addCycleToFavorites,
|
addCycleToFavorites,
|
||||||
removeCycleFromFavorites,
|
removeCycleFromFavorites,
|
||||||
} = useCycle();
|
} = useCycle();
|
||||||
const { currentProjectDetails } = useProject();
|
const { currentProjectDetails } = useProject();
|
||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
|
// cycle filters hook
|
||||||
|
const { updateDisplayFilters } = useCycleFilter();
|
||||||
|
// derived values
|
||||||
|
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
||||||
|
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
|
||||||
|
// fetch active cycle details
|
||||||
const { isLoading } = useSWR(
|
const { isLoading } = useSWR(
|
||||||
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
|
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
|
||||||
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
|
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
|
||||||
);
|
);
|
||||||
|
// fetch active cycle issues
|
||||||
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
|
||||||
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
|
|
||||||
|
|
||||||
const { data: activeCycleIssues } = useSWR(
|
const { data: activeCycleIssues } = useSWR(
|
||||||
workspaceSlug && projectId && currentProjectActiveCycleId
|
workspaceSlug && projectId && currentProjectActiveCycleId
|
||||||
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
|
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
|
||||||
@ -73,7 +79,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId)
|
? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId)
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
// show loader if active cycle is loading
|
||||||
if (!activeCycle && isLoading)
|
if (!activeCycle && isLoading)
|
||||||
return (
|
return (
|
||||||
<Loader>
|
<Loader>
|
||||||
@ -81,10 +87,44 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
</Loader>
|
</Loader>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!activeCycle) return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />;
|
if (!activeCycle) {
|
||||||
|
// show empty state if no active cycle is present
|
||||||
|
if (currentProjectUpcomingCycleIds?.length === 0)
|
||||||
|
return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />;
|
||||||
|
// show upcoming cycles list, if present
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-52 w-full grid place-items-center mb-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h5 className="text-xl font-medium mb-1">No active cycle</h5>
|
||||||
|
<p className="text-custom-text-400 text-base">
|
||||||
|
Create new cycles to find them here or check
|
||||||
|
<br />
|
||||||
|
{"'"}All{"'"} cycles tab to see all cycles or{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-custom-primary-100 font-medium"
|
||||||
|
onClick={() =>
|
||||||
|
updateDisplayFilters(projectId, {
|
||||||
|
active_tab: "all",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
click here
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UpcomingCyclesList />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const endDate = new Date(activeCycle.end_date ?? "");
|
const endDate = new Date(activeCycle.end_date ?? "");
|
||||||
const startDate = new Date(activeCycle.start_date ?? "");
|
const startDate = new Date(activeCycle.start_date ?? "");
|
||||||
|
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
|
||||||
|
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
|
||||||
|
|
||||||
const groupedIssues: any = {
|
const groupedIssues: any = {
|
||||||
backlog: activeCycle.backlog_issues,
|
backlog: activeCycle.backlog_issues,
|
||||||
@ -94,8 +134,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
cancelled: activeCycle.cancelled_issues,
|
cancelled: activeCycle.cancelled_issues,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
|
|
||||||
|
|
||||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
@ -148,8 +186,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
color: group.color,
|
color: group.color,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
|
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
|
||||||
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0">
|
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0">
|
||||||
@ -203,27 +239,15 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2.5 text-custom-text-200">
|
<div className="flex items-center gap-2.5 text-custom-text-200">
|
||||||
{cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? (
|
<Avatar src={cycleOwnerDetails?.avatar} name={cycleOwnerDetails?.display_name} />
|
||||||
<img
|
|
||||||
src={cycleOwnerDetails?.avatar}
|
|
||||||
height={16}
|
|
||||||
width={16}
|
|
||||||
className="rounded-full"
|
|
||||||
alt={cycleOwnerDetails?.display_name}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
|
|
||||||
{cycleOwnerDetails?.display_name.charAt(0)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
|
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeCycle.assignee_ids.length > 0 && (
|
{activeCycle.assignee_ids.length > 0 && (
|
||||||
<div className="flex items-center gap-1 text-custom-text-200">
|
<div className="flex items-center gap-1 text-custom-text-200">
|
||||||
<AvatarGroup>
|
<AvatarGroup>
|
||||||
{activeCycle.assignee_ids.map((assigne_id) => {
|
{activeCycle.assignee_ids.map((assignee_id) => {
|
||||||
const member = getUserDetails(assigne_id);
|
const member = getUserDetails(assignee_id);
|
||||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||||
})}
|
})}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
@ -233,7 +257,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
|
|
||||||
<div className="flex items-center gap-4 text-custom-text-200">
|
<div className="flex items-center gap-4 text-custom-text-200">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<LayersIcon className="h-4 w-4 flex-shrink-0" />
|
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
{activeCycle.total_issues} issues
|
{activeCycle.total_issues} issues
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -244,9 +268,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`}
|
href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`}
|
||||||
className="w-min text-nowrap rounded-md bg-custom-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-custom-primary/90"
|
className={cn(getButtonStyling("primary", "lg"), "w-min whitespace-nowrap")}
|
||||||
>
|
>
|
||||||
View Cycle
|
View cycle
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -287,11 +311,11 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0">
|
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0">
|
||||||
<div className="flex max-h-60 flex-col gap-3 overflow-hidden p-4">
|
<div className="flex max-h-60 flex-col gap-3 overflow-hidden p-4">
|
||||||
<div className="text-custom-primary">High Priority Issues</div>
|
<div className="text-custom-primary">High priority issues</div>
|
||||||
<div className="flex h-full flex-col gap-2.5 overflow-y-scroll rounded-md">
|
<div className="flex h-full flex-col gap-2.5 overflow-y-scroll rounded-md">
|
||||||
{activeCycleIssues ? (
|
{activeCycleIssues ? (
|
||||||
activeCycleIssues.length > 0 ? (
|
activeCycleIssues.length > 0 ? (
|
||||||
activeCycleIssues.map((issue: any) => (
|
activeCycleIssues.map((issue) => (
|
||||||
<Link
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||||
@ -314,9 +338,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||||
<StateDropdown
|
<StateDropdown
|
||||||
value={issue.state_id ?? undefined}
|
value={issue.state_id}
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
projectId={projectId?.toString() ?? ""}
|
projectId={projectId}
|
||||||
disabled
|
disabled
|
||||||
buttonVariant="background-with-text"
|
buttonVariant="background-with-text"
|
||||||
/>
|
/>
|
||||||
@ -359,10 +383,10 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span>
|
<span>
|
||||||
<LayersIcon className="h-5 w-5 flex-shrink-0 text-custom-text-200" />
|
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Pending Issues -{" "}
|
Pending issues-{" "}
|
||||||
{activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
|
{activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
@ -134,7 +134,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
|
|||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 grid place-items-center text-center text-sm text-custom-text-200">
|
<div className="mt-4 grid place-items-center text-center text-sm text-custom-text-200">
|
||||||
There are no high priority issues present in this cycle.
|
There are no issues present in this cycle.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Tab.Group>
|
</Tab.Group>
|
135
web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx
Normal file
135
web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { Star, User2 } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useCycle, useEventTracker, useMember } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { CycleQuickActions } from "components/cycles";
|
||||||
|
// ui
|
||||||
|
import { Avatar, AvatarGroup, setPromiseToast } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { renderFormattedDate } from "helpers/date-time.helper";
|
||||||
|
// constants
|
||||||
|
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cycleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
|
||||||
|
const { cycleId } = props;
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
// store hooks
|
||||||
|
const { captureEvent } = useEventTracker();
|
||||||
|
const { addCycleToFavorites, getCycleById, removeCycleFromFavorites } = useCycle();
|
||||||
|
const { getUserDetails } = useMember();
|
||||||
|
// derived values
|
||||||
|
const cycle = getCycleById(cycleId);
|
||||||
|
|
||||||
|
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
|
||||||
|
() => {
|
||||||
|
captureEvent(CYCLE_FAVORITED, {
|
||||||
|
cycle_id: cycleId,
|
||||||
|
element: "List layout",
|
||||||
|
state: "SUCCESS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setPromiseToast(addToFavoritePromise, {
|
||||||
|
loading: "Adding cycle to favorites...",
|
||||||
|
success: {
|
||||||
|
title: "Success!",
|
||||||
|
message: () => "Cycle added to favorites.",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: "Error!",
|
||||||
|
message: () => "Couldn't add the cycle to favorites. Please try again.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
const removeFromFavoritePromise = removeCycleFromFavorites(
|
||||||
|
workspaceSlug?.toString(),
|
||||||
|
projectId.toString(),
|
||||||
|
cycleId
|
||||||
|
).then(() => {
|
||||||
|
captureEvent(CYCLE_UNFAVORITED, {
|
||||||
|
cycle_id: cycleId,
|
||||||
|
element: "List layout",
|
||||||
|
state: "SUCCESS",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setPromiseToast(removeFromFavoritePromise, {
|
||||||
|
loading: "Removing cycle from favorites...",
|
||||||
|
success: {
|
||||||
|
title: "Success!",
|
||||||
|
message: () => "Cycle removed from favorites.",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: "Error!",
|
||||||
|
message: () => "Couldn't remove the cycle from favorites. Please try again.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cycle) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`}
|
||||||
|
className="py-5 px-2 flex items-center justify-between gap-2 hover:bg-custom-background-90"
|
||||||
|
>
|
||||||
|
<h6 className="font-medium text-base">{cycle.name}</h6>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{cycle.start_date && cycle.end_date && (
|
||||||
|
<div className="text-xs text-custom-text-300">
|
||||||
|
{renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cycle.assignee_ids?.length > 0 ? (
|
||||||
|
<AvatarGroup showTooltip={false}>
|
||||||
|
{cycle.assignee_ids?.map((assigneeId) => {
|
||||||
|
const member = getUserDetails(assigneeId);
|
||||||
|
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||||
|
})}
|
||||||
|
</AvatarGroup>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||||
|
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cycle.is_favorite ? (
|
||||||
|
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||||
|
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={handleAddToFavorites}>
|
||||||
|
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workspaceSlug && projectId && (
|
||||||
|
<CycleQuickActions
|
||||||
|
cycleId={cycleId}
|
||||||
|
projectId={projectId.toString()}
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
25
web/components/cycles/active-cycle/upcoming-cycles-list.tsx
Normal file
25
web/components/cycles/active-cycle/upcoming-cycles-list.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
// hooks
|
||||||
|
import { useCycle } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { UpcomingCycleListItem } from "components/cycles";
|
||||||
|
|
||||||
|
export const UpcomingCyclesList = observer(() => {
|
||||||
|
// store hooks
|
||||||
|
const { currentProjectUpcomingCycleIds } = useCycle();
|
||||||
|
|
||||||
|
if (!currentProjectUpcomingCycleIds) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded inline-block">
|
||||||
|
Upcoming cycles
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 divide-y-[0.5px] divide-custom-border-200 border-b-[0.5px] border-custom-border-200">
|
||||||
|
{currentProjectUpcomingCycleIds.map((cycleId) => (
|
||||||
|
<UpcomingCycleListItem key={cycleId} cycleId={cycleId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
55
web/components/cycles/applied-filters/date.tsx
Normal file
55
web/components/cycles/applied-filters/date.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
// helpers
|
||||||
|
import { renderFormattedDate } from "helpers/date-time.helper";
|
||||||
|
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||||
|
// constants
|
||||||
|
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
editable: boolean | undefined;
|
||||||
|
handleRemove: (val: string) => void;
|
||||||
|
values: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppliedDateFilters: React.FC<Props> = observer((props) => {
|
||||||
|
const { editable, handleRemove, values } = props;
|
||||||
|
|
||||||
|
const getDateLabel = (value: string): string => {
|
||||||
|
let dateLabel = "";
|
||||||
|
|
||||||
|
const dateDetails = DATE_FILTER_OPTIONS.find((d) => d.value === value);
|
||||||
|
|
||||||
|
if (dateDetails) dateLabel = dateDetails.name;
|
||||||
|
else {
|
||||||
|
const dateParts = value.split(";");
|
||||||
|
|
||||||
|
if (dateParts.length === 2) {
|
||||||
|
const [date, time] = dateParts;
|
||||||
|
|
||||||
|
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateLabel;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{values.map((date) => (
|
||||||
|
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||||
|
<span className="normal-case">{getDateLabel(date)}</span>
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||||
|
onClick={() => handleRemove(date)}
|
||||||
|
>
|
||||||
|
<X size={10} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
3
web/components/cycles/applied-filters/index.ts
Normal file
3
web/components/cycles/applied-filters/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./date";
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./status";
|
90
web/components/cycles/applied-filters/root.tsx
Normal file
90
web/components/cycles/applied-filters/root.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useUser } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { AppliedDateFilters, AppliedStatusFilters } from "components/cycles";
|
||||||
|
// helpers
|
||||||
|
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||||
|
// types
|
||||||
|
import { TCycleFilters } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { EUserProjectRoles } from "constants/project";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
appliedFilters: TCycleFilters;
|
||||||
|
handleClearAllFilters: () => void;
|
||||||
|
handleRemoveFilter: (key: keyof TCycleFilters, value: string | null) => void;
|
||||||
|
alwaysAllowEditing?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DATE_FILTERS = ["start_date", "end_date"];
|
||||||
|
|
||||||
|
export const CycleAppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||||
|
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
membership: { currentProjectRole },
|
||||||
|
} = useUser();
|
||||||
|
|
||||||
|
if (!appliedFilters) return null;
|
||||||
|
|
||||||
|
if (Object.keys(appliedFilters).length === 0) return null;
|
||||||
|
|
||||||
|
const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
|
||||||
|
{Object.entries(appliedFilters).map(([key, value]) => {
|
||||||
|
const filterKey = key as keyof TCycleFilters;
|
||||||
|
|
||||||
|
if (!value) return;
|
||||||
|
if (Array.isArray(value) && value.length === 0) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={filterKey}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{filterKey === "status" && (
|
||||||
|
<AppliedStatusFilters
|
||||||
|
editable={isEditingAllowed}
|
||||||
|
handleRemove={(val) => handleRemoveFilter("status", val)}
|
||||||
|
values={value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{DATE_FILTERS.includes(filterKey) && (
|
||||||
|
<AppliedDateFilters
|
||||||
|
editable={isEditingAllowed}
|
||||||
|
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
|
||||||
|
values={value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isEditingAllowed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||||
|
onClick={() => handleRemoveFilter(filterKey, null)}
|
||||||
|
>
|
||||||
|
<X size={12} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{isEditingAllowed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearAllFilters}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
<X size={12} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
43
web/components/cycles/applied-filters/status.tsx
Normal file
43
web/components/cycles/applied-filters/status.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { CYCLE_STATUS } from "constants/cycle";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
handleRemove: (val: string) => void;
|
||||||
|
values: string[];
|
||||||
|
editable: boolean | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppliedStatusFilters: React.FC<Props> = observer((props) => {
|
||||||
|
const { handleRemove, values, editable } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{values.map((status) => {
|
||||||
|
const statusDetails = CYCLE_STATUS.find((s) => s.value === status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 rounded p-1 text-xs",
|
||||||
|
statusDetails?.bgColor,
|
||||||
|
statusDetails?.textColor
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusDetails?.title}
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||||
|
onClick={() => handleRemove(status)}
|
||||||
|
>
|
||||||
|
<X size={10} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -1,22 +1,12 @@
|
|||||||
import { FC, MouseEvent, useState } from "react";
|
import { FC, MouseEvent } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// hooks
|
// hooks
|
||||||
// components
|
// components
|
||||||
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
|
import { Info, Star } from "lucide-react";
|
||||||
import {
|
import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast } from "@plane/ui";
|
||||||
Avatar,
|
import { CycleQuickActions } from "components/cycles";
|
||||||
AvatarGroup,
|
|
||||||
CustomMenu,
|
|
||||||
Tooltip,
|
|
||||||
LayersIcon,
|
|
||||||
CycleGroupIcon,
|
|
||||||
TOAST_TYPE,
|
|
||||||
setToast,
|
|
||||||
setPromiseToast,
|
|
||||||
} from "@plane/ui";
|
|
||||||
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
|
||||||
// ui
|
// ui
|
||||||
// icons
|
// icons
|
||||||
// helpers
|
// helpers
|
||||||
@ -24,7 +14,6 @@ import { CYCLE_STATUS } from "constants/cycle";
|
|||||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
|
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
|
||||||
import { copyTextToClipboard } from "helpers/string.helper";
|
|
||||||
// constants
|
// constants
|
||||||
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
|
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
|
||||||
//.types
|
//.types
|
||||||
@ -38,13 +27,10 @@ export interface ICyclesBoardCard {
|
|||||||
|
|
||||||
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||||
const { cycleId, workspaceSlug, projectId } = props;
|
const { cycleId, workspaceSlug, projectId } = props;
|
||||||
// states
|
|
||||||
const [updateModal, setUpdateModal] = useState(false);
|
|
||||||
const [deleteModal, setDeleteModal] = useState(false);
|
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// store
|
// store
|
||||||
const { setTrackElement, captureEvent } = useEventTracker();
|
const { captureEvent } = useEventTracker();
|
||||||
const {
|
const {
|
||||||
membership: { currentProjectRole },
|
membership: { currentProjectRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
@ -56,7 +42,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
|||||||
if (!cycleDetails) return null;
|
if (!cycleDetails) return null;
|
||||||
|
|
||||||
const cycleStatus = cycleDetails.status.toLocaleLowerCase();
|
const cycleStatus = cycleDetails.status.toLocaleLowerCase();
|
||||||
const isCompleted = cycleStatus === "completed";
|
|
||||||
const endDate = new Date(cycleDetails.end_date ?? "");
|
const endDate = new Date(cycleDetails.end_date ?? "");
|
||||||
const startDate = new Date(cycleDetails.start_date ?? "");
|
const startDate = new Date(cycleDetails.start_date ?? "");
|
||||||
const isDateValid = cycleDetails.start_date || cycleDetails.end_date;
|
const isDateValid = cycleDetails.start_date || cycleDetails.end_date;
|
||||||
@ -78,24 +63,10 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
|||||||
? cycleTotalIssues === 0
|
? cycleTotalIssues === 0
|
||||||
? "0 Issue"
|
? "0 Issue"
|
||||||
: cycleTotalIssues === cycleDetails.completed_issues
|
: cycleTotalIssues === cycleDetails.completed_issues
|
||||||
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
|
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
|
||||||
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
|
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
|
||||||
: "0 Issue";
|
: "0 Issue";
|
||||||
|
|
||||||
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
|
||||||
|
|
||||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Link Copied!",
|
|
||||||
message: "Cycle link copied to clipboard.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
@ -152,20 +123,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setTrackElement("Cycles page grid layout");
|
|
||||||
setUpdateModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setTrackElement("Cycles page grid layout");
|
|
||||||
setDeleteModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
|
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
const { query } = router;
|
const { query } = router;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -181,22 +138,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<CycleCreateUpdateModal
|
|
||||||
data={cycleDetails}
|
|
||||||
isOpen={updateModal}
|
|
||||||
handleClose={() => setUpdateModal(false)}
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CycleDeleteModal
|
|
||||||
cycle={cycleDetails}
|
|
||||||
isOpen={deleteModal}
|
|
||||||
handleClose={() => setDeleteModal(false)}
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||||
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@ -288,30 +229,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
|||||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<CustomMenu ellipsis className="z-10">
|
|
||||||
{!isCompleted && isEditingAllowed && (
|
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||||
<>
|
|
||||||
<CustomMenu.MenuItem onClick={handleEditCycle}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
<span>Edit cycle</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
<span>Delete cycle</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<LinkIcon className="h-3 w-3" />
|
|
||||||
<span>Copy cycle link</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</CustomMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
25
web/components/cycles/board/cycles-board-map.tsx
Normal file
25
web/components/cycles/board/cycles-board-map.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// components
|
||||||
|
import { CyclesBoardCard } from "components/cycles";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cycleIds: string[];
|
||||||
|
peekCycle: string | undefined;
|
||||||
|
projectId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CyclesBoardMap: React.FC<Props> = (props) => {
|
||||||
|
const { cycleIds, peekCycle, projectId, workspaceSlug } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full grid grid-cols-1 gap-6 ${
|
||||||
|
peekCycle ? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3" : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
|
||||||
|
} auto-rows-max transition-all`}
|
||||||
|
>
|
||||||
|
{cycleIds.map((cycleId) => (
|
||||||
|
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
3
web/components/cycles/board/index.ts
Normal file
3
web/components/cycles/board/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./cycles-board-card";
|
||||||
|
export * from "./cycles-board-map";
|
||||||
|
export * from "./root";
|
60
web/components/cycles/board/root.tsx
Normal file
60
web/components/cycles/board/root.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Disclosure } from "@headlessui/react";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
// components
|
||||||
|
import { CyclePeekOverview, CyclesBoardMap } from "components/cycles";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
|
export interface ICyclesBoard {
|
||||||
|
completedCycleIds: string[];
|
||||||
|
cycleIds: string[];
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
peekCycle: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
|
||||||
|
const { completedCycleIds, cycleIds, workspaceSlug, projectId, peekCycle } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<div className="flex h-full w-full justify-between">
|
||||||
|
<div className="h-full w-full flex flex-col p-8 space-y-8 vertical-scrollbar scrollbar-lg">
|
||||||
|
<CyclesBoardMap
|
||||||
|
cycleIds={cycleIds}
|
||||||
|
peekCycle={peekCycle}
|
||||||
|
projectId={projectId}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
{completedCycleIds.length !== 0 && (
|
||||||
|
<Disclosure as="div" className="space-y-4">
|
||||||
|
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded flex items-center gap-1">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
Completed cycles ({completedCycleIds.length})
|
||||||
|
<ChevronRight
|
||||||
|
className={cn("h-3 w-3 transition-all", {
|
||||||
|
"rotate-90": open,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Disclosure.Button>
|
||||||
|
<Disclosure.Panel>
|
||||||
|
<CyclesBoardMap
|
||||||
|
cycleIds={completedCycleIds}
|
||||||
|
peekCycle={peekCycle}
|
||||||
|
projectId={projectId}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Disclosure>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -1,47 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
// components
|
|
||||||
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
|
|
||||||
import { EmptyState } from "components/empty-state";
|
|
||||||
// constants
|
|
||||||
import { EMPTY_STATE_DETAILS } from "constants/empty-state";
|
|
||||||
|
|
||||||
export interface ICyclesBoard {
|
|
||||||
cycleIds: string[];
|
|
||||||
filter: string;
|
|
||||||
workspaceSlug: string;
|
|
||||||
projectId: string;
|
|
||||||
peekCycle: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
|
|
||||||
const { cycleIds, filter, workspaceSlug, projectId, peekCycle } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{cycleIds?.length > 0 ? (
|
|
||||||
<div className="h-full w-full">
|
|
||||||
<div className="flex h-full w-full justify-between">
|
|
||||||
<div
|
|
||||||
className={`grid h-full w-full grid-cols-1 gap-6 overflow-y-auto p-8 ${
|
|
||||||
peekCycle
|
|
||||||
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
|
|
||||||
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
|
|
||||||
} auto-rows-max transition-all vertical-scrollbar scrollbar-lg`}
|
|
||||||
>
|
|
||||||
{cycleIds.map((cycleId) => (
|
|
||||||
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<CyclePeekOverview
|
|
||||||
projectId={projectId?.toString() ?? ""}
|
|
||||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState type={`project-cycle-${filter}` as keyof typeof EMPTY_STATE_DETAILS} size="sm" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,57 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
// components
|
|
||||||
import { CyclePeekOverview, CyclesListItem } from "components/cycles";
|
|
||||||
import { EmptyState } from "components/empty-state";
|
|
||||||
// ui
|
|
||||||
import { Loader } from "@plane/ui";
|
|
||||||
// constants
|
|
||||||
import { EMPTY_STATE_DETAILS } from "constants/empty-state";
|
|
||||||
|
|
||||||
export interface ICyclesList {
|
|
||||||
cycleIds: string[];
|
|
||||||
filter: string;
|
|
||||||
workspaceSlug: string;
|
|
||||||
projectId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CyclesList: FC<ICyclesList> = observer((props) => {
|
|
||||||
const { cycleIds, filter, workspaceSlug, projectId } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{cycleIds ? (
|
|
||||||
<>
|
|
||||||
{cycleIds.length > 0 ? (
|
|
||||||
<div className="h-full overflow-y-auto">
|
|
||||||
<div className="flex h-full w-full justify-between">
|
|
||||||
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
|
|
||||||
{cycleIds.map((cycleId) => (
|
|
||||||
<CyclesListItem
|
|
||||||
key={cycleId}
|
|
||||||
cycleId={cycleId}
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<CyclePeekOverview
|
|
||||||
projectId={projectId?.toString() ?? ""}
|
|
||||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState type={`project-cycle-${filter}` as keyof typeof EMPTY_STATE_DETAILS} size="sm" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Loader className="space-y-4">
|
|
||||||
<Loader.Item height="50px" />
|
|
||||||
<Loader.Item height="50px" />
|
|
||||||
<Loader.Item height="50px" />
|
|
||||||
</Loader>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
164
web/components/cycles/cycles-view-header.tsx
Normal file
164
web/components/cycles/cycles-view-header.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
import { ListFilter, Search, X } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useCycleFilter } from "hooks/store";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
|
// components
|
||||||
|
import { CycleFiltersSelection } from "components/cycles";
|
||||||
|
import { FiltersDropdown } from "components/issues";
|
||||||
|
// ui
|
||||||
|
import { Tooltip } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
// types
|
||||||
|
import { TCycleFilters } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "constants/cycle";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CyclesViewHeader: React.FC<Props> = observer((props) => {
|
||||||
|
const { projectId } = props;
|
||||||
|
// states
|
||||||
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
// refs
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// hooks
|
||||||
|
const {
|
||||||
|
currentProjectDisplayFilters,
|
||||||
|
currentProjectFilters,
|
||||||
|
searchQuery,
|
||||||
|
updateDisplayFilters,
|
||||||
|
updateFilters,
|
||||||
|
updateSearchQuery,
|
||||||
|
} = useCycleFilter();
|
||||||
|
// outside click detector hook
|
||||||
|
useOutsideClickDetector(inputRef, () => {
|
||||||
|
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFilters = useCallback(
|
||||||
|
(key: keyof TCycleFilters, value: string | string[]) => {
|
||||||
|
const newValues = currentProjectFilters?.[key] ?? [];
|
||||||
|
|
||||||
|
if (Array.isArray(value))
|
||||||
|
value.forEach((val) => {
|
||||||
|
if (!newValues.includes(val)) newValues.push(val);
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
if (currentProjectFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||||
|
else newValues.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilters(projectId, { [key]: newValues });
|
||||||
|
},
|
||||||
|
[currentProjectFilters, projectId, updateFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
|
||||||
|
else setIsSearchOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-custom-border-200 px-4 sm:px-5 sm:pb-0">
|
||||||
|
<Tab.List as="div" className="flex items-center overflow-x-scroll">
|
||||||
|
{CYCLE_TABS_LIST.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab.key}
|
||||||
|
className={({ selected }) =>
|
||||||
|
`border-b-2 p-4 text-sm font-medium outline-none ${
|
||||||
|
selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
{currentProjectDisplayFilters?.active_tab !== "active" && (
|
||||||
|
<div className="hidden h-full sm:flex items-center gap-3 self-end">
|
||||||
|
{!isSearchOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSearchOpen(true);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
|
||||||
|
{
|
||||||
|
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 focus:outline-none"
|
||||||
|
placeholder="Search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => updateSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
/>
|
||||||
|
{isSearchOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid place-items-center"
|
||||||
|
onClick={() => {
|
||||||
|
updateSearchQuery("");
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
|
||||||
|
<CycleFiltersSelection filters={currentProjectFilters ?? {}} handleFiltersUpdate={handleFilters} />
|
||||||
|
</FiltersDropdown>
|
||||||
|
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
|
||||||
|
{CYCLE_VIEW_LAYOUTS.map((layout) => (
|
||||||
|
<Tooltip key={layout.key} tooltipContent={layout.title}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
|
||||||
|
currentProjectDisplayFilters?.layout == layout.key
|
||||||
|
? "bg-custom-background-100 shadow-custom-shadow-2xs"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
updateDisplayFilters(projectId, {
|
||||||
|
layout: layout.key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<layout.icon
|
||||||
|
strokeWidth={2}
|
||||||
|
className={`h-3.5 w-3.5 ${
|
||||||
|
currentProjectDisplayFilters?.layout == layout.key
|
||||||
|
? "text-custom-text-100"
|
||||||
|
: "text-custom-text-200"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -1,43 +1,35 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
|
import { useCycle, useCycleFilter } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles";
|
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles";
|
||||||
// ui components
|
// ui
|
||||||
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
|
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
|
||||||
import { useCycle } from "hooks/store";
|
// assets
|
||||||
|
import NameFilterImage from "public/empty-state/cycle/name-filter.svg";
|
||||||
|
import AllFiltersImage from "public/empty-state/cycle/all-filters.svg";
|
||||||
// types
|
// types
|
||||||
import { TCycleLayout, TCycleView } from "@plane/types";
|
import { TCycleLayoutOptions } from "@plane/types";
|
||||||
|
|
||||||
export interface ICyclesView {
|
export interface ICyclesView {
|
||||||
filter: TCycleView;
|
layout: TCycleLayoutOptions;
|
||||||
layout: TCycleLayout;
|
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
peekCycle: string | undefined;
|
peekCycle: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CyclesView: FC<ICyclesView> = observer((props) => {
|
export const CyclesView: FC<ICyclesView> = observer((props) => {
|
||||||
const { filter, layout, workspaceSlug, projectId, peekCycle } = props;
|
const { layout, workspaceSlug, projectId, peekCycle } = props;
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader } = useCycle();
|
||||||
currentProjectCompletedCycleIds,
|
const { searchQuery } = useCycleFilter();
|
||||||
currentProjectDraftCycleIds,
|
// derived values
|
||||||
currentProjectUpcomingCycleIds,
|
const filteredCycleIds = getFilteredCycleIds(projectId);
|
||||||
currentProjectCycleIds,
|
const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId);
|
||||||
loader,
|
|
||||||
} = useCycle();
|
|
||||||
|
|
||||||
const cyclesList =
|
if (loader || !filteredCycleIds)
|
||||||
filter === "completed"
|
|
||||||
? currentProjectCompletedCycleIds
|
|
||||||
: filter === "draft"
|
|
||||||
? currentProjectDraftCycleIds
|
|
||||||
: filter === "upcoming"
|
|
||||||
? currentProjectUpcomingCycleIds
|
|
||||||
: currentProjectCycleIds;
|
|
||||||
|
|
||||||
if (loader || !cyclesList)
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{layout === "list" && <CycleModuleListLayout />}
|
{layout === "list" && <CycleModuleListLayout />}
|
||||||
@ -46,23 +38,45 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0)
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full grid place-items-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Image
|
||||||
|
src={searchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
|
||||||
|
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
|
||||||
|
alt="No matching cycles"
|
||||||
|
/>
|
||||||
|
<h5 className="text-xl font-medium mt-7 mb-1">No matching cycles</h5>
|
||||||
|
<p className="text-custom-text-400 text-base">
|
||||||
|
{searchQuery.trim() === ""
|
||||||
|
? "Remove the filters to see all cycles"
|
||||||
|
: "Remove the search criteria to see all cycles"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{layout === "list" && (
|
{layout === "list" && (
|
||||||
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
|
<CyclesList
|
||||||
|
completedCycleIds={filteredCompletedCycleIds ?? []}
|
||||||
|
cycleIds={filteredCycleIds}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{layout === "board" && (
|
{layout === "board" && (
|
||||||
<CyclesBoard
|
<CyclesBoard
|
||||||
cycleIds={cyclesList}
|
completedCycleIds={filteredCompletedCycleIds ?? []}
|
||||||
filter={filter}
|
cycleIds={filteredCycleIds}
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
peekCycle={peekCycle}
|
peekCycle={peekCycle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{layout === "gantt" && <CyclesListGanttChartView cycleIds={filteredCycleIds} workspaceSlug={workspaceSlug} />}
|
||||||
{layout === "gantt" && <CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -103,7 +103,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
|||||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-500/20">
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-500/20">
|
||||||
<AlertTriangle width={16} strokeWidth={2} className="text-red-600" />
|
<AlertTriangle width={16} strokeWidth={2} className="text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl font-medium 2xl:text-2xl">Delete Cycle</div>
|
<div className="text-xl font-medium 2xl:text-2xl">Delete cycle</div>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
<p className="text-sm text-custom-text-200">
|
<p className="text-sm text-custom-text-200">
|
||||||
@ -118,8 +118,8 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="danger" size="sm" tabIndex={1} onClick={formSubmit}>
|
<Button variant="danger" size="sm" tabIndex={1} onClick={formSubmit} loading={loader}>
|
||||||
{loader ? "Deleting..." : "Delete Cycle"}
|
{loader ? "Deleting" : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
63
web/components/cycles/dropdowns/filters/end-date.tsx
Normal file
63
web/components/cycles/dropdowns/filters/end-date.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { DateFilterModal } from "components/core";
|
||||||
|
import { FilterHeader, FilterOption } from "components/issues";
|
||||||
|
// constants
|
||||||
|
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
appliedFilters: string[] | null;
|
||||||
|
handleUpdate: (val: string | string[]) => void;
|
||||||
|
searchQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilterEndDate: React.FC<Props> = observer((props) => {
|
||||||
|
const { appliedFilters, handleUpdate, searchQuery } = props;
|
||||||
|
|
||||||
|
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||||
|
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||||
|
|
||||||
|
const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isDateFilterModalOpen && (
|
||||||
|
<DateFilterModal
|
||||||
|
handleClose={() => setIsDateFilterModalOpen(false)}
|
||||||
|
isOpen={isDateFilterModalOpen}
|
||||||
|
onSelect={(val) => handleUpdate(val)}
|
||||||
|
title="Due date"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FilterHeader
|
||||||
|
title={`Due date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||||
|
isPreviewEnabled={previewEnabled}
|
||||||
|
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||||
|
/>
|
||||||
|
{previewEnabled && (
|
||||||
|
<div>
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{filteredOptions.map((option) => (
|
||||||
|
<FilterOption
|
||||||
|
key={option.value}
|
||||||
|
isChecked={appliedFilters?.includes(option.value) ? true : false}
|
||||||
|
onClick={() => handleUpdate(option.value)}
|
||||||
|
title={option.name}
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
4
web/components/cycles/dropdowns/filters/index.ts
Normal file
4
web/components/cycles/dropdowns/filters/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./end-date";
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./start-date";
|
||||||
|
export * from "./status";
|
69
web/components/cycles/dropdowns/filters/root.tsx
Normal file
69
web/components/cycles/dropdowns/filters/root.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
// components
|
||||||
|
import { FilterEndDate, FilterStartDate, FilterStatus } from "components/cycles";
|
||||||
|
// types
|
||||||
|
import { TCycleFilters, TCycleGroups } from "@plane/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filters: TCycleFilters;
|
||||||
|
handleFiltersUpdate: (key: keyof TCycleFilters, value: string | string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
|
||||||
|
const { filters, handleFiltersUpdate } = props;
|
||||||
|
// states
|
||||||
|
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||||
|
<div className="bg-custom-background-100 p-2.5 pb-0">
|
||||||
|
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
|
||||||
|
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
|
||||||
|
placeholder="Search"
|
||||||
|
value={filtersSearchQuery}
|
||||||
|
onChange={(e) => setFiltersSearchQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{filtersSearchQuery !== "" && (
|
||||||
|
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
|
||||||
|
<X className="text-custom-text-300" size={12} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
|
||||||
|
{/* cycle status */}
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterStatus
|
||||||
|
appliedFilters={(filters.status as TCycleGroups[]) ?? null}
|
||||||
|
handleUpdate={(val) => handleFiltersUpdate("status", val)}
|
||||||
|
searchQuery={filtersSearchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* start date */}
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterStartDate
|
||||||
|
appliedFilters={filters.start_date ?? null}
|
||||||
|
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
|
||||||
|
searchQuery={filtersSearchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* end date */}
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterEndDate
|
||||||
|
appliedFilters={filters.end_date ?? null}
|
||||||
|
handleUpdate={(val) => handleFiltersUpdate("end_date", val)}
|
||||||
|
searchQuery={filtersSearchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
63
web/components/cycles/dropdowns/filters/start-date.tsx
Normal file
63
web/components/cycles/dropdowns/filters/start-date.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { DateFilterModal } from "components/core";
|
||||||
|
import { FilterHeader, FilterOption } from "components/issues";
|
||||||
|
// constants
|
||||||
|
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
appliedFilters: string[] | null;
|
||||||
|
handleUpdate: (val: string | string[]) => void;
|
||||||
|
searchQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilterStartDate: React.FC<Props> = observer((props) => {
|
||||||
|
const { appliedFilters, handleUpdate, searchQuery } = props;
|
||||||
|
|
||||||
|
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||||
|
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||||
|
|
||||||
|
const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isDateFilterModalOpen && (
|
||||||
|
<DateFilterModal
|
||||||
|
handleClose={() => setIsDateFilterModalOpen(false)}
|
||||||
|
isOpen={isDateFilterModalOpen}
|
||||||
|
onSelect={(val) => handleUpdate(val)}
|
||||||
|
title="Start date"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FilterHeader
|
||||||
|
title={`Start date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||||
|
isPreviewEnabled={previewEnabled}
|
||||||
|
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||||
|
/>
|
||||||
|
{previewEnabled && (
|
||||||
|
<div>
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{filteredOptions.map((option) => (
|
||||||
|
<FilterOption
|
||||||
|
key={option.value}
|
||||||
|
isChecked={appliedFilters?.includes(option.value) ? true : false}
|
||||||
|
onClick={() => handleUpdate(option.value)}
|
||||||
|
title={option.name}
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
49
web/components/cycles/dropdowns/filters/status.tsx
Normal file
49
web/components/cycles/dropdowns/filters/status.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// components
|
||||||
|
import { FilterHeader, FilterOption } from "components/issues";
|
||||||
|
// types
|
||||||
|
import { TCycleGroups } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { CYCLE_STATUS } from "constants/cycle";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
appliedFilters: TCycleGroups[] | null;
|
||||||
|
handleUpdate: (val: string) => void;
|
||||||
|
searchQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilterStatus: React.FC<Props> = observer((props) => {
|
||||||
|
const { appliedFilters, handleUpdate, searchQuery } = props;
|
||||||
|
// states
|
||||||
|
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||||
|
|
||||||
|
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||||
|
const filteredOptions = CYCLE_STATUS.filter((p) => p.value.includes(searchQuery.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilterHeader
|
||||||
|
title={`Status of the cycle${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||||
|
isPreviewEnabled={previewEnabled}
|
||||||
|
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||||
|
/>
|
||||||
|
{previewEnabled && (
|
||||||
|
<div>
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((status) => (
|
||||||
|
<FilterOption
|
||||||
|
key={status.value}
|
||||||
|
isChecked={appliedFilters?.includes(status.value) ? true : false}
|
||||||
|
onClick={() => handleUpdate(status.value)}
|
||||||
|
title={status.title}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
1
web/components/cycles/dropdowns/index.ts
Normal file
1
web/components/cycles/dropdowns/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./filters";
|
@ -4,8 +4,7 @@ import { useRouter } from "next/router";
|
|||||||
// hooks
|
// hooks
|
||||||
import { CycleGanttBlock } from "components/cycles";
|
import { CycleGanttBlock } from "components/cycles";
|
||||||
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart";
|
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart";
|
||||||
import { EUserProjectRoles } from "constants/project";
|
import { useCycle } from "hooks/store";
|
||||||
import { useCycle, useUser } from "hooks/store";
|
|
||||||
// components
|
// components
|
||||||
// types
|
// types
|
||||||
import { ICycle } from "@plane/types";
|
import { ICycle } from "@plane/types";
|
||||||
@ -22,9 +21,6 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
|
||||||
membership: { currentProjectRole },
|
|
||||||
} = useUser();
|
|
||||||
const { getCycleById, updateCycleDetails } = useCycle();
|
const { getCycleById, updateCycleDetails } = useCycle();
|
||||||
|
|
||||||
const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
|
const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
|
||||||
@ -52,9 +48,6 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
|||||||
return structuredBlocks;
|
return structuredBlocks;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAllowed =
|
|
||||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full overflow-y-auto">
|
<div className="h-full w-full overflow-y-auto">
|
||||||
<GanttChartRoot
|
<GanttChartRoot
|
||||||
@ -67,7 +60,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
|||||||
enableBlockLeftResize={false}
|
enableBlockLeftResize={false}
|
||||||
enableBlockRightResize={false}
|
enableBlockRightResize={false}
|
||||||
enableBlockMove={false}
|
enableBlockMove={false}
|
||||||
enableReorder={isAllowed}
|
enableReorder={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,17 +1,16 @@
|
|||||||
export * from "./cycles-view";
|
export * from "./active-cycle";
|
||||||
export * from "./active-cycle-details";
|
export * from "./applied-filters";
|
||||||
export * from "./active-cycle-stats";
|
export * from "./board/";
|
||||||
|
export * from "./dropdowns";
|
||||||
export * from "./gantt-chart";
|
export * from "./gantt-chart";
|
||||||
|
export * from "./list";
|
||||||
|
export * from "./cycle-peek-overview";
|
||||||
|
export * from "./cycles-view-header";
|
||||||
export * from "./cycles-view";
|
export * from "./cycles-view";
|
||||||
|
export * from "./delete-modal";
|
||||||
export * from "./form";
|
export * from "./form";
|
||||||
export * from "./modal";
|
export * from "./modal";
|
||||||
|
export * from "./quick-actions";
|
||||||
export * from "./sidebar";
|
export * from "./sidebar";
|
||||||
export * from "./transfer-issues-modal";
|
export * from "./transfer-issues-modal";
|
||||||
export * from "./transfer-issues";
|
export * from "./transfer-issues";
|
||||||
export * from "./cycles-list";
|
|
||||||
export * from "./cycles-list-item";
|
|
||||||
export * from "./cycles-board";
|
|
||||||
export * from "./cycles-board-card";
|
|
||||||
export * from "./delete-modal";
|
|
||||||
export * from "./cycle-peek-overview";
|
|
||||||
export * from "./cycles-list-item";
|
|
||||||
|
@ -1,26 +1,14 @@
|
|||||||
import { FC, MouseEvent, useState } from "react";
|
import { FC, MouseEvent } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// hooks
|
// hooks
|
||||||
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
|
import { Check, Info, Star, User2 } from "lucide-react";
|
||||||
import {
|
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
|
||||||
CustomMenu,
|
import { CycleQuickActions } from "components/cycles";
|
||||||
Tooltip,
|
|
||||||
CircularProgressIndicator,
|
|
||||||
CycleGroupIcon,
|
|
||||||
AvatarGroup,
|
|
||||||
Avatar,
|
|
||||||
TOAST_TYPE,
|
|
||||||
setToast,
|
|
||||||
setPromiseToast,
|
|
||||||
} from "@plane/ui";
|
|
||||||
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
|
||||||
import { CYCLE_STATUS } from "constants/cycle";
|
import { CYCLE_STATUS } from "constants/cycle";
|
||||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
|
||||||
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
|
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
|
||||||
import { copyTextToClipboard } from "helpers/string.helper";
|
|
||||||
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
|
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
// ui
|
// ui
|
||||||
@ -29,6 +17,7 @@ import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
|
|||||||
// constants
|
// constants
|
||||||
// types
|
// types
|
||||||
import { TCycleGroups } from "@plane/types";
|
import { TCycleGroups } from "@plane/types";
|
||||||
|
import { EUserProjectRoles } from "constants/project";
|
||||||
|
|
||||||
type TCyclesListItem = {
|
type TCyclesListItem = {
|
||||||
cycleId: string;
|
cycleId: string;
|
||||||
@ -42,33 +31,16 @@ type TCyclesListItem = {
|
|||||||
|
|
||||||
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||||
const { cycleId, workspaceSlug, projectId } = props;
|
const { cycleId, workspaceSlug, projectId } = props;
|
||||||
// states
|
|
||||||
const [updateModal, setUpdateModal] = useState(false);
|
|
||||||
const [deleteModal, setDeleteModal] = useState(false);
|
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// store hooks
|
// store hooks
|
||||||
const { setTrackElement, captureEvent } = useEventTracker();
|
const { captureEvent } = useEventTracker();
|
||||||
const {
|
const {
|
||||||
membership: { currentProjectRole },
|
membership: { currentProjectRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
|
|
||||||
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
|
||||||
|
|
||||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Link Copied!",
|
|
||||||
message: "Cycle link copied to clipboard.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
@ -125,20 +97,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setTrackElement("Cycles page list layout");
|
|
||||||
setUpdateModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setTrackElement("Cycles page list layout");
|
|
||||||
setDeleteModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
|
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
const { query } = router;
|
const { query } = router;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -161,7 +119,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
|||||||
const endDate = new Date(cycleDetails.end_date ?? "");
|
const endDate = new Date(cycleDetails.end_date ?? "");
|
||||||
const startDate = new Date(cycleDetails.start_date ?? "");
|
const startDate = new Date(cycleDetails.start_date ?? "");
|
||||||
|
|
||||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||||
|
|
||||||
const cycleTotalIssues =
|
const cycleTotalIssues =
|
||||||
cycleDetails.backlog_issues +
|
cycleDetails.backlog_issues +
|
||||||
@ -184,20 +142,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CycleCreateUpdateModal
|
|
||||||
data={cycleDetails}
|
|
||||||
isOpen={updateModal}
|
|
||||||
handleClose={() => setUpdateModal(false)}
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
<CycleDeleteModal
|
|
||||||
cycle={cycleDetails}
|
|
||||||
isOpen={deleteModal}
|
|
||||||
handleClose={() => setDeleteModal(false)}
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||||
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row">
|
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row">
|
||||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||||
@ -246,7 +190,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end ">
|
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end">
|
||||||
<div className="text-xs text-custom-text-300">
|
<div className="text-xs text-custom-text-300">
|
||||||
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
|
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
|
||||||
</div>
|
</div>
|
||||||
@ -256,8 +200,8 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
|||||||
<div className="flex w-10 cursor-default items-center justify-center">
|
<div className="flex w-10 cursor-default items-center justify-center">
|
||||||
{cycleDetails.assignee_ids?.length > 0 ? (
|
{cycleDetails.assignee_ids?.length > 0 ? (
|
||||||
<AvatarGroup showTooltip={false}>
|
<AvatarGroup showTooltip={false}>
|
||||||
{cycleDetails.assignee_ids?.map((assigne_id) => {
|
{cycleDetails.assignee_ids?.map((assignee_id) => {
|
||||||
const member = getUserDetails(assigne_id);
|
const member = getUserDetails(assignee_id);
|
||||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||||
})}
|
})}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
@ -281,30 +225,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CustomMenu ellipsis>
|
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||||
{!isCompleted && isEditingAllowed && (
|
|
||||||
<>
|
|
||||||
<CustomMenu.MenuItem onClick={handleEditCycle}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
<span>Edit cycle</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
<span>Delete cycle</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<LinkIcon className="h-3 w-3" />
|
|
||||||
<span>Copy cycle link</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</CustomMenu>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
20
web/components/cycles/list/cycles-list-map.tsx
Normal file
20
web/components/cycles/list/cycles-list-map.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// components
|
||||||
|
import { CyclesListItem } from "components/cycles";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cycleIds: string[];
|
||||||
|
projectId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CyclesListMap: React.FC<Props> = (props) => {
|
||||||
|
const { cycleIds, projectId, workspaceSlug } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{cycleIds.map((cycleId) => (
|
||||||
|
<CyclesListItem key={cycleId} cycleId={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
3
web/components/cycles/list/index.ts
Normal file
3
web/components/cycles/list/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./cycles-list-item";
|
||||||
|
export * from "./cycles-list-map";
|
||||||
|
export * from "./root";
|
49
web/components/cycles/list/root.tsx
Normal file
49
web/components/cycles/list/root.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Disclosure } from "@headlessui/react";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
// components
|
||||||
|
import { CyclePeekOverview, CyclesListMap } from "components/cycles";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
|
export interface ICyclesList {
|
||||||
|
completedCycleIds: string[];
|
||||||
|
cycleIds: string[];
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||||
|
const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<div className="flex h-full w-full justify-between">
|
||||||
|
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
|
||||||
|
<CyclesListMap cycleIds={cycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||||
|
{completedCycleIds.length !== 0 && (
|
||||||
|
<Disclosure as="div" className="mt-4 space-y-4">
|
||||||
|
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded ml-5 flex items-center gap-1">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
Completed cycles ({completedCycleIds.length})
|
||||||
|
<ChevronRight
|
||||||
|
className={cn("h-3 w-3 transition-all", {
|
||||||
|
"rotate-90": open,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Disclosure.Button>
|
||||||
|
<Disclosure.Panel>
|
||||||
|
<CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Disclosure>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -11,7 +11,7 @@ import { CycleService } from "services/cycle.service";
|
|||||||
// components
|
// components
|
||||||
// ui
|
// ui
|
||||||
// types
|
// types
|
||||||
import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types";
|
import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
|
|
||||||
type CycleModalProps = {
|
type CycleModalProps = {
|
||||||
@ -34,7 +34,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
|||||||
const { workspaceProjectIds } = useProject();
|
const { workspaceProjectIds } = useProject();
|
||||||
const { createCycle, updateCycleDetails } = useCycle();
|
const { createCycle, updateCycleDetails } = useCycle();
|
||||||
|
|
||||||
const { setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active");
|
const { setValue: setCycleTab } = useLocalStorage<TCycleTabOptions>("cycle_tab", "active");
|
||||||
|
|
||||||
const handleCreateCycle = async (payload: Partial<ICycle>) => {
|
const handleCreateCycle = async (payload: Partial<ICycle>) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
112
web/components/cycles/quick-actions.tsx
Normal file
112
web/components/cycles/quick-actions.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useCycle, useEventTracker, useUser } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
||||||
|
// ui
|
||||||
|
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||||
|
// constants
|
||||||
|
import { EUserProjectRoles } from "constants/project";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cycleId: string;
|
||||||
|
projectId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||||
|
const { cycleId, projectId, workspaceSlug } = props;
|
||||||
|
// states
|
||||||
|
const [updateModal, setUpdateModal] = useState(false);
|
||||||
|
const [deleteModal, setDeleteModal] = useState(false);
|
||||||
|
// store hooks
|
||||||
|
const { setTrackElement } = useEventTracker();
|
||||||
|
const {
|
||||||
|
membership: { currentWorkspaceAllProjectsRole },
|
||||||
|
} = useUser();
|
||||||
|
const { getCycleById } = useCycle();
|
||||||
|
// derived values
|
||||||
|
const cycleDetails = getCycleById(cycleId);
|
||||||
|
const isCompleted = cycleDetails?.status.toLowerCase() === "completed";
|
||||||
|
// auth
|
||||||
|
const isEditingAllowed =
|
||||||
|
!!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER;
|
||||||
|
|
||||||
|
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Link Copied!",
|
||||||
|
message: "Cycle link copied to clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setTrackElement("Cycles page list layout");
|
||||||
|
setUpdateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setTrackElement("Cycles page list layout");
|
||||||
|
setDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{cycleDetails && (
|
||||||
|
<div className="fixed">
|
||||||
|
<CycleCreateUpdateModal
|
||||||
|
data={cycleDetails}
|
||||||
|
isOpen={updateModal}
|
||||||
|
handleClose={() => setUpdateModal(false)}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
<CycleDeleteModal
|
||||||
|
cycle={cycleDetails}
|
||||||
|
isOpen={deleteModal}
|
||||||
|
handleClose={() => setDeleteModal(false)}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CustomMenu ellipsis placement="bottom-end">
|
||||||
|
{!isCompleted && isEditingAllowed && (
|
||||||
|
<>
|
||||||
|
<CustomMenu.MenuItem onClick={handleEditCycle}>
|
||||||
|
<span className="flex items-center justify-start gap-2">
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
<span>Edit cycle</span>
|
||||||
|
</span>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
|
||||||
|
<span className="flex items-center justify-start gap-2">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
<span>Delete cycle</span>
|
||||||
|
</span>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||||
|
<span className="flex items-center justify-start gap-2">
|
||||||
|
<LinkIcon className="h-3 w-3" />
|
||||||
|
<span>Copy cycle link</span>
|
||||||
|
</span>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</CustomMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -216,15 +216,15 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
? "0 Issue"
|
? "0 Issue"
|
||||||
: `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}`
|
: `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}`
|
||||||
: cycleDetails.total_issues === 0
|
: cycleDetails.total_issues === 0
|
||||||
? "0 Issue"
|
? "0 Issue"
|
||||||
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
||||||
|
|
||||||
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
|
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
|
||||||
|
|
||||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative">
|
||||||
{cycleDetails && workspaceSlug && projectId && (
|
{cycleDetails && workspaceSlug && projectId && (
|
||||||
<CycleDeleteModal
|
<CycleDeleteModal
|
||||||
cycle={cycleDetails}
|
cycle={cycleDetails}
|
||||||
@ -236,7 +236,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 py-5">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-border-300"
|
className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-border-300"
|
||||||
@ -505,6 +505,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -7,6 +7,7 @@ import { useDashboard } from "hooks/store";
|
|||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
DurationFilterDropdown,
|
DurationFilterDropdown,
|
||||||
|
IssuesErrorState,
|
||||||
TabsList,
|
TabsList,
|
||||||
WidgetIssuesList,
|
WidgetIssuesList,
|
||||||
WidgetLoader,
|
WidgetLoader,
|
||||||
@ -26,10 +27,12 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
// states
|
// states
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
|
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } =
|
||||||
|
useDashboard();
|
||||||
// derived values
|
// derived values
|
||||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
|
const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
||||||
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||||
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||||
@ -73,74 +76,91 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||||
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||||
|
|
||||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
|
||||||
<div className="flex items-center justify-between gap-2 p-6 pl-7">
|
{widgetStatsError ? (
|
||||||
<Link
|
<IssuesErrorState
|
||||||
href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`}
|
isRefreshing={fetching}
|
||||||
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
onClick={() =>
|
||||||
>
|
|
||||||
Assigned to you
|
|
||||||
</Link>
|
|
||||||
<DurationFilterDropdown
|
|
||||||
customDates={selectedCustomDates}
|
|
||||||
value={selectedDurationFilter}
|
|
||||||
onChange={(val, customDates) => {
|
|
||||||
if (val === "custom" && customDates) {
|
|
||||||
handleUpdateFilters({
|
|
||||||
duration: val,
|
|
||||||
custom_dates: customDates,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (val === selectedDurationFilter) return;
|
|
||||||
|
|
||||||
let newTab = selectedTab;
|
|
||||||
// switch to pending tab if target date is changed to none
|
|
||||||
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
|
||||||
// switch to upcoming tab if target date is changed to other than none
|
|
||||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming";
|
|
||||||
|
|
||||||
handleUpdateFilters({
|
handleUpdateFilters({
|
||||||
duration: val,
|
duration: EDurationFilters.NONE,
|
||||||
tab: newTab,
|
tab: "pending",
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
<Tab.Group
|
widgetStats && (
|
||||||
as="div"
|
<>
|
||||||
selectedIndex={selectedTabIndex}
|
<div className="flex items-center justify-between gap-2 p-6 pl-7">
|
||||||
onChange={(i) => {
|
<Link
|
||||||
const newSelectedTab = tabsList[i];
|
href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`}
|
||||||
handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" });
|
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
||||||
}}
|
>
|
||||||
className="h-full flex flex-col"
|
Assigned to you
|
||||||
>
|
</Link>
|
||||||
<div className="px-6">
|
<DurationFilterDropdown
|
||||||
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
customDates={selectedCustomDates}
|
||||||
</div>
|
value={selectedDurationFilter}
|
||||||
<Tab.Panels as="div" className="h-full">
|
onChange={(val, customDates) => {
|
||||||
{tabsList.map((tab) => {
|
if (val === "custom" && customDates) {
|
||||||
if (tab.key !== selectedTab) return null;
|
handleUpdateFilters({
|
||||||
|
duration: val,
|
||||||
|
custom_dates: customDates,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
if (val === selectedDurationFilter) return;
|
||||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
|
||||||
<WidgetIssuesList
|
let newTab = selectedTab;
|
||||||
tab={tab.key}
|
// switch to pending tab if target date is changed to none
|
||||||
type="assigned"
|
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
||||||
workspaceSlug={workspaceSlug}
|
// switch to upcoming tab if target date is changed to other than none
|
||||||
widgetStats={widgetStats}
|
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed")
|
||||||
isLoading={fetching}
|
newTab = "upcoming";
|
||||||
/>
|
|
||||||
</Tab.Panel>
|
handleUpdateFilters({
|
||||||
);
|
duration: val,
|
||||||
})}
|
tab: newTab,
|
||||||
</Tab.Panels>
|
});
|
||||||
</Tab.Group>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Tab.Group
|
||||||
|
as="div"
|
||||||
|
selectedIndex={selectedTabIndex}
|
||||||
|
onChange={(i) => {
|
||||||
|
const newSelectedTab = tabsList[i];
|
||||||
|
handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" });
|
||||||
|
}}
|
||||||
|
className="h-full flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="px-6">
|
||||||
|
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||||
|
</div>
|
||||||
|
<Tab.Panels as="div" className="h-full">
|
||||||
|
{tabsList.map((tab) => {
|
||||||
|
if (tab.key !== selectedTab) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
||||||
|
<WidgetIssuesList
|
||||||
|
tab={tab.key}
|
||||||
|
type="assigned"
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
widgetStats={widgetStats}
|
||||||
|
isLoading={fetching}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -7,6 +7,7 @@ import { useDashboard } from "hooks/store";
|
|||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
DurationFilterDropdown,
|
DurationFilterDropdown,
|
||||||
|
IssuesErrorState,
|
||||||
TabsList,
|
TabsList,
|
||||||
WidgetIssuesList,
|
WidgetIssuesList,
|
||||||
WidgetLoader,
|
WidgetLoader,
|
||||||
@ -26,10 +27,12 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
// states
|
// states
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
|
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } =
|
||||||
|
useDashboard();
|
||||||
// derived values
|
// derived values
|
||||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
|
const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
||||||
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||||
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||||
@ -70,74 +73,91 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||||
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||||
|
|
||||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
|
||||||
<div className="flex items-center justify-between gap-2 p-6 pl-7">
|
{widgetStatsError ? (
|
||||||
<Link
|
<IssuesErrorState
|
||||||
href={`/${workspaceSlug}/workspace-views/created/${filterParams}`}
|
isRefreshing={fetching}
|
||||||
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
onClick={() =>
|
||||||
>
|
|
||||||
Created by you
|
|
||||||
</Link>
|
|
||||||
<DurationFilterDropdown
|
|
||||||
customDates={selectedCustomDates}
|
|
||||||
value={selectedDurationFilter}
|
|
||||||
onChange={(val, customDates) => {
|
|
||||||
if (val === "custom" && customDates) {
|
|
||||||
handleUpdateFilters({
|
|
||||||
duration: val,
|
|
||||||
custom_dates: customDates,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (val === selectedDurationFilter) return;
|
|
||||||
|
|
||||||
let newTab = selectedTab;
|
|
||||||
// switch to pending tab if target date is changed to none
|
|
||||||
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
|
||||||
// switch to upcoming tab if target date is changed to other than none
|
|
||||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming";
|
|
||||||
|
|
||||||
handleUpdateFilters({
|
handleUpdateFilters({
|
||||||
duration: val,
|
duration: EDurationFilters.NONE,
|
||||||
tab: newTab,
|
tab: "pending",
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
<Tab.Group
|
widgetStats && (
|
||||||
as="div"
|
<>
|
||||||
selectedIndex={selectedTabIndex}
|
<div className="flex items-center justify-between gap-2 p-6 pl-7">
|
||||||
onChange={(i) => {
|
<Link
|
||||||
const newSelectedTab = tabsList[i];
|
href={`/${workspaceSlug}/workspace-views/created/${filterParams}`}
|
||||||
handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" });
|
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
||||||
}}
|
>
|
||||||
className="h-full flex flex-col"
|
Created by you
|
||||||
>
|
</Link>
|
||||||
<div className="px-6">
|
<DurationFilterDropdown
|
||||||
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
customDates={selectedCustomDates}
|
||||||
</div>
|
value={selectedDurationFilter}
|
||||||
<Tab.Panels as="div" className="h-full">
|
onChange={(val, customDates) => {
|
||||||
{tabsList.map((tab) => {
|
if (val === "custom" && customDates) {
|
||||||
if (tab.key !== selectedTab) return null;
|
handleUpdateFilters({
|
||||||
|
duration: val,
|
||||||
|
custom_dates: customDates,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
if (val === selectedDurationFilter) return;
|
||||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
|
||||||
<WidgetIssuesList
|
let newTab = selectedTab;
|
||||||
tab={tab.key}
|
// switch to pending tab if target date is changed to none
|
||||||
type="created"
|
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
||||||
workspaceSlug={workspaceSlug}
|
// switch to upcoming tab if target date is changed to other than none
|
||||||
widgetStats={widgetStats}
|
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed")
|
||||||
isLoading={fetching}
|
newTab = "upcoming";
|
||||||
/>
|
|
||||||
</Tab.Panel>
|
handleUpdateFilters({
|
||||||
);
|
duration: val,
|
||||||
})}
|
tab: newTab,
|
||||||
</Tab.Panels>
|
});
|
||||||
</Tab.Group>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Tab.Group
|
||||||
|
as="div"
|
||||||
|
selectedIndex={selectedTabIndex}
|
||||||
|
onChange={(i) => {
|
||||||
|
const newSelectedTab = tabsList[i];
|
||||||
|
handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" });
|
||||||
|
}}
|
||||||
|
className="h-full flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="px-6">
|
||||||
|
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||||
|
</div>
|
||||||
|
<Tab.Panels as="div" className="h-full">
|
||||||
|
{tabsList.map((tab) => {
|
||||||
|
if (tab.key !== selectedTab) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
||||||
|
<WidgetIssuesList
|
||||||
|
tab={tab.key}
|
||||||
|
type="created"
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
widgetStats={widgetStats}
|
||||||
|
isLoading={fetching}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
1
web/components/dashboard/widgets/error-states/index.ts
Normal file
1
web/components/dashboard/widgets/error-states/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./issues";
|
32
web/components/dashboard/widgets/error-states/issues.tsx
Normal file
32
web/components/dashboard/widgets/error-states/issues.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { AlertTriangle, RefreshCcw } from "lucide-react";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isRefreshing: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssuesErrorState: React.FC<Props> = (props) => {
|
||||||
|
const { isRefreshing, onClick } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full grid place-items-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="h-24 w-24 bg-red-500/20 rounded-full grid place-items-center mx-auto">
|
||||||
|
<AlertTriangle className="h-12 w-12 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-7 text-custom-text-300 text-sm font-medium">There was an error in fetching widget details</p>
|
||||||
|
<Button
|
||||||
|
variant="neutral-primary"
|
||||||
|
prependIcon={<RefreshCcw className="h-3 w-3" />}
|
||||||
|
className="mt-2 mx-auto"
|
||||||
|
onClick={onClick}
|
||||||
|
loading={isRefreshing}
|
||||||
|
>
|
||||||
|
{isRefreshing ? "Retrying" : "Retry"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,5 +1,6 @@
|
|||||||
export * from "./dropdowns";
|
export * from "./dropdowns";
|
||||||
export * from "./empty-states";
|
export * from "./empty-states";
|
||||||
|
export * from "./error-states";
|
||||||
export * from "./issue-panels";
|
export * from "./issue-panels";
|
||||||
export * from "./loaders";
|
export * from "./loaders";
|
||||||
export * from "./assigned-issues";
|
export * from "./assigned-issues";
|
||||||
|
@ -37,21 +37,36 @@ export const DefaultCollaboratorsList: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showViewMoreButton = pageCount < totalPages && resultsCount !== 0;
|
||||||
|
const showViewLessButton = pageCount > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
|
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
|
||||||
{collaboratorsPages}
|
{collaboratorsPages}
|
||||||
</div>
|
</div>
|
||||||
{pageCount < totalPages && resultsCount !== 0 && (
|
{(showViewLessButton || showViewMoreButton) && (
|
||||||
<div className="flex items-center justify-center text-xs w-full">
|
<div className="flex items-center justify-center text-xs w-full">
|
||||||
<Button
|
{showViewLessButton && (
|
||||||
variant="link-primary"
|
<Button
|
||||||
size="sm"
|
variant="link-primary"
|
||||||
className="my-3 hover:bg-custom-primary-100/20"
|
size="sm"
|
||||||
onClick={handleLoadMore}
|
className="my-3 hover:bg-custom-primary-100/20"
|
||||||
>
|
onClick={() => setPageCount(1)}
|
||||||
Load more
|
>
|
||||||
</Button>
|
View less
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showViewMoreButton && (
|
||||||
|
<Button
|
||||||
|
variant="link-primary"
|
||||||
|
size="sm"
|
||||||
|
className="my-3 hover:bg-custom-primary-100/20"
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
>
|
||||||
|
View more
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -17,9 +17,9 @@ export const RecentCollaboratorsWidget: React.FC<WidgetProps> = (props) => {
|
|||||||
<div className="w-full rounded-xl border-[0.5px] border-custom-border-200 bg-custom-background-100 duration-300 hover:shadow-custom-shadow-4xl">
|
<div className="w-full rounded-xl border-[0.5px] border-custom-border-200 bg-custom-background-100 duration-300 hover:shadow-custom-shadow-4xl">
|
||||||
<div className="flex items-start justify-between px-7 pt-6">
|
<div className="flex items-start justify-between px-7 pt-6">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-semibold text-custom-text-300">Most active members</h4>
|
<h4 className="text-lg font-semibold text-custom-text-300">Collaborators</h4>
|
||||||
<p className="mt-2 text-xs font-medium text-custom-text-300">
|
<p className="mt-2 text-xs font-medium text-custom-text-300">
|
||||||
Top eight active members in your project by last activity
|
View and find all members you collaborate with across projects
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-72 items-center justify-start gap-2 rounded-md border border-custom-border-200 px-2.5 py-1.5 placeholder:text-custom-text-400">
|
<div className="flex min-w-72 items-center justify-start gap-2 rounded-md border border-custom-border-200 px-2.5 py-1.5 placeholder:text-custom-text-400">
|
||||||
|
@ -48,6 +48,9 @@ export const SearchedCollaboratorsList: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showViewMoreButton = pageCount < totalPages && resultsCount !== 0;
|
||||||
|
const showViewLessButton = pageCount > 1;
|
||||||
|
|
||||||
const emptyStateImage = resolvedTheme === "dark" ? DarkImage : LightImage;
|
const emptyStateImage = resolvedTheme === "dark" ? DarkImage : LightImage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -63,16 +66,28 @@ export const SearchedCollaboratorsList: React.FC<Props> = (props) => {
|
|||||||
<p className="font-medium text-sm">No matching member</p>
|
<p className="font-medium text-sm">No matching member</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pageCount < totalPages && resultsCount !== 0 && (
|
{(showViewLessButton || showViewMoreButton) && (
|
||||||
<div className="flex items-center justify-center text-xs w-full">
|
<div className="flex items-center justify-center text-xs w-full">
|
||||||
<Button
|
{showViewLessButton && (
|
||||||
variant="link-primary"
|
<Button
|
||||||
size="sm"
|
variant="link-primary"
|
||||||
className="my-3 hover:bg-custom-primary-100/20"
|
size="sm"
|
||||||
onClick={handleLoadMore}
|
className="my-3 hover:bg-custom-primary-100/20"
|
||||||
>
|
onClick={() => setPageCount(1)}
|
||||||
Load more
|
>
|
||||||
</Button>
|
View less
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showViewMoreButton && (
|
||||||
|
<Button
|
||||||
|
variant="link-primary"
|
||||||
|
size="sm"
|
||||||
|
className="my-3 hover:bg-custom-primary-100/20"
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
>
|
||||||
|
View more
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -9,12 +9,12 @@ import { useUser } from "hooks/store";
|
|||||||
import { Button, TButtonVariant } from "@plane/ui";
|
import { Button, TButtonVariant } from "@plane/ui";
|
||||||
import { ComicBoxButton } from "./comic-box-button";
|
import { ComicBoxButton } from "./comic-box-button";
|
||||||
// constant
|
// constant
|
||||||
import { EMPTY_STATE_DETAILS, EmptyStateKeys } from "constants/empty-state";
|
import { EMPTY_STATE_DETAILS, EmptyStateType } from "constants/empty-state";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
export type EmptyStateProps = {
|
export type EmptyStateProps = {
|
||||||
type: EmptyStateKeys;
|
type: EmptyStateType;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
layout?: "widget-simple" | "screen-detailed" | "screen-simple";
|
layout?: "widget-simple" | "screen-detailed" | "screen-simple";
|
||||||
additionalPath?: string;
|
additionalPath?: string;
|
||||||
@ -39,6 +39,10 @@ export const EmptyState: React.FC<EmptyStateProps> = (props) => {
|
|||||||
} = useUser();
|
} = useUser();
|
||||||
// theme
|
// theme
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
// if empty state type is not found
|
||||||
|
if (!EMPTY_STATE_DETAILS[type]) return null;
|
||||||
|
|
||||||
// current empty state details
|
// current empty state details
|
||||||
const { key, title, description, path, primaryButton, secondaryButton, accessType, access } =
|
const { key, title, description, path, primaryButton, secondaryButton, accessType, access } =
|
||||||
EMPTY_STATE_DETAILS[type];
|
EMPTY_STATE_DETAILS[type];
|
||||||
|
@ -25,7 +25,7 @@ export const GanttChartHeader: React.FC<Props> = observer((props) => {
|
|||||||
const { currentView } = useGanttChart();
|
const { currentView } = useGanttChart();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2 z-10">
|
<div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2">
|
||||||
<div className="flex items-center gap-2 text-lg font-medium">{title}</div>
|
<div className="flex items-center gap-2 text-lg font-medium">{title}</div>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<div className="ml-auto text-sm font-medium">{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}</div>
|
<div className="ml-auto text-sm font-medium">{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}</div>
|
||||||
|
@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// hooks
|
// hooks
|
||||||
import { ArrowRight, Plus, PanelRight } from "lucide-react";
|
import { ArrowRight, Plus, PanelRight } from "lucide-react";
|
||||||
import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui";
|
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui";
|
||||||
import { ProjectAnalyticsModal } from "components/analytics";
|
import { ProjectAnalyticsModal } from "components/analytics";
|
||||||
import { BreadcrumbLink } from "components/common";
|
import { BreadcrumbLink } from "components/common";
|
||||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||||
@ -142,6 +142,12 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
|||||||
const canUserCreateIssue =
|
const canUserCreateIssue =
|
||||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||||
|
|
||||||
|
const issueCount = cycleDetails
|
||||||
|
? issueFilters?.displayFilters?.sub_issue
|
||||||
|
? cycleDetails.total_issues + cycleDetails?.sub_issues
|
||||||
|
: cycleDetails.total_issues
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProjectAnalyticsModal
|
<ProjectAnalyticsModal
|
||||||
@ -197,15 +203,29 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
|||||||
label={
|
label={
|
||||||
<>
|
<>
|
||||||
<ContrastIcon className="h-3 w-3" />
|
<ContrastIcon className="h-3 w-3" />
|
||||||
<div className=" w-auto max-w-[70px] sm:max-w-[200px] inline-block truncate line-clamp-1 overflow-hidden whitespace-nowrap">
|
<div className="flex items-center gap-2 w-auto max-w-[70px] sm:max-w-[200px] truncate">
|
||||||
{cycleDetails?.name && cycleDetails.name}
|
<p className="truncate">{cycleDetails?.name && cycleDetails.name}</p>
|
||||||
|
{issueCount && issueCount > 0 ? (
|
||||||
|
<Tooltip
|
||||||
|
tooltipContent={`There are ${issueCount} ${
|
||||||
|
issueCount > 1 ? "issues" : "issue"
|
||||||
|
} in this cycle`}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<span className="cursor-default flex items-center text-center justify-center px-2 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||||
|
{issueCount}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
className="ml-1.5 flex-shrink-0"
|
className="ml-1.5 flex-shrink-0 truncate"
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
>
|
>
|
||||||
{currentProjectCycleIds?.map((cycleId) => <CycleDropdownOption key={cycleId} cycleId={cycleId} />)}
|
{currentProjectCycleIds?.map((cycleId) => (
|
||||||
|
<CycleDropdownOption key={cycleId} cycleId={cycleId} />
|
||||||
|
))}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -13,7 +13,7 @@ import { CYCLE_VIEW_LAYOUTS } from "constants/cycle";
|
|||||||
import { EUserProjectRoles } from "constants/project";
|
import { EUserProjectRoles } from "constants/project";
|
||||||
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
|
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
|
||||||
import useLocalStorage from "hooks/use-local-storage";
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
import { TCycleLayout } from "@plane/types";
|
import { TCycleLayoutOptions } from "@plane/types";
|
||||||
import { ProjectLogo } from "components/project";
|
import { ProjectLogo } from "components/project";
|
||||||
|
|
||||||
export const CyclesHeader: FC = observer(() => {
|
export const CyclesHeader: FC = observer(() => {
|
||||||
@ -33,10 +33,10 @@ export const CyclesHeader: FC = observer(() => {
|
|||||||
const canUserCreateCycle =
|
const canUserCreateCycle =
|
||||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||||
|
|
||||||
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list");
|
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayoutOptions>("cycle_layout", "list");
|
||||||
|
|
||||||
const handleCurrentLayout = useCallback(
|
const handleCurrentLayout = useCallback(
|
||||||
(_layout: TCycleLayout) => {
|
(_layout: TCycleLayoutOptions) => {
|
||||||
setCycleLayout(_layout);
|
setCycleLayout(_layout);
|
||||||
},
|
},
|
||||||
[setCycleLayout]
|
[setCycleLayout]
|
||||||
@ -109,7 +109,7 @@ export const CyclesHeader: FC = observer(() => {
|
|||||||
key={layout.key}
|
key={layout.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
// handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
||||||
handleCurrentLayout(layout.key as TCycleLayout);
|
handleCurrentLayout(layout.key as TCycleLayoutOptions);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// hooks
|
// hooks
|
||||||
import { ArrowRight, PanelRight, Plus } from "lucide-react";
|
import { ArrowRight, PanelRight, Plus } from "lucide-react";
|
||||||
import { Breadcrumbs, Button, CustomMenu, DiceIcon } from "@plane/ui";
|
import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui";
|
||||||
import { ProjectAnalyticsModal } from "components/analytics";
|
import { ProjectAnalyticsModal } from "components/analytics";
|
||||||
import { BreadcrumbLink } from "components/common";
|
import { BreadcrumbLink } from "components/common";
|
||||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||||
@ -143,6 +143,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
|||||||
const canUserCreateIssue =
|
const canUserCreateIssue =
|
||||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||||
|
|
||||||
|
const issueCount = moduleDetails
|
||||||
|
? issueFilters?.displayFilters?.sub_issue
|
||||||
|
? moduleDetails.total_issues + moduleDetails.sub_issues
|
||||||
|
: moduleDetails.total_issues
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProjectAnalyticsModal
|
<ProjectAnalyticsModal
|
||||||
@ -198,15 +204,29 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
|||||||
label={
|
label={
|
||||||
<>
|
<>
|
||||||
<DiceIcon className="h-3 w-3" />
|
<DiceIcon className="h-3 w-3" />
|
||||||
<div className="w-auto max-w-[70px] sm:max-w-[200px] inline-block truncate line-clamp-1 overflow-hidden whitespace-nowrap">
|
<div className="flex items-center gap-2 w-auto max-w-[70px] sm:max-w-[200px] truncate">
|
||||||
{moduleDetails?.name && moduleDetails.name}
|
<p className="truncate">{moduleDetails?.name && moduleDetails.name}</p>
|
||||||
|
{issueCount && issueCount > 0 ? (
|
||||||
|
<Tooltip
|
||||||
|
tooltipContent={`There are ${issueCount} ${
|
||||||
|
issueCount > 1 ? "issues" : "issue"
|
||||||
|
} in this module`}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<span className="cursor-default flex items-center text-center justify-center px-2 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||||
|
{issueCount}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
className="ml-1.5 flex-shrink-0"
|
className="ml-1.5 flex-shrink-0"
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
>
|
>
|
||||||
{projectModuleIds?.map((moduleId) => <ModuleDropdownOption key={moduleId} moduleId={moduleId} />)}
|
{projectModuleIds?.map((moduleId) => (
|
||||||
|
<ModuleDropdownOption key={moduleId} moduleId={moduleId} />
|
||||||
|
))}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -5,7 +5,7 @@ import { ArrowLeft } from "lucide-react";
|
|||||||
// hooks
|
// hooks
|
||||||
// constants
|
// constants
|
||||||
// ui
|
// ui
|
||||||
import { Breadcrumbs, LayersIcon } from "@plane/ui";
|
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { BreadcrumbLink } from "components/common";
|
import { BreadcrumbLink } from "components/common";
|
||||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||||
@ -69,6 +69,12 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
|
|||||||
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property);
|
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const issueCount = currentProjectDetails
|
||||||
|
? issueFilters?.displayFilters?.sub_issue
|
||||||
|
? currentProjectDetails.archived_issues + currentProjectDetails.archived_sub_issues
|
||||||
|
: currentProjectDetails.archived_issues
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
<div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||||
@ -82,7 +88,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
|
|||||||
<ArrowLeft fontSize={14} strokeWidth={2} />
|
<ArrowLeft fontSize={14} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2.5">
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.BreadcrumbItem
|
<Breadcrumbs.BreadcrumbItem
|
||||||
type="text"
|
type="text"
|
||||||
@ -111,6 +117,16 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
{issueCount && issueCount > 0 ? (
|
||||||
|
<Tooltip
|
||||||
|
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's archived`}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||||
|
{issueCount}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// hooks
|
// hooks
|
||||||
// components
|
// components
|
||||||
import { Breadcrumbs, LayersIcon } from "@plane/ui";
|
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
|
||||||
import { BreadcrumbLink } from "components/common";
|
import { BreadcrumbLink } from "components/common";
|
||||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
|
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
|
||||||
@ -73,11 +73,18 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
|
|||||||
},
|
},
|
||||||
[workspaceSlug, projectId, updateFilters]
|
[workspaceSlug, projectId, updateFilters]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const issueCount = currentProjectDetails
|
||||||
|
? issueFilters?.displayFilters?.sub_issue
|
||||||
|
? currentProjectDetails.draft_issues + currentProjectDetails.draft_sub_issues
|
||||||
|
: currentProjectDetails.draft_issues
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||||
<SidebarHamburgerToggle />
|
<SidebarHamburgerToggle />
|
||||||
<div>
|
<div className="flex items-center gap-2.5">
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.BreadcrumbItem
|
<Breadcrumbs.BreadcrumbItem
|
||||||
type="text"
|
type="text"
|
||||||
@ -103,6 +110,16 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
{issueCount && issueCount > 0 ? (
|
||||||
|
<Tooltip
|
||||||
|
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's draft`}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||||
|
{issueCount}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react";
|
import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { Breadcrumbs, Button, LayersIcon } from "@plane/ui";
|
import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui";
|
||||||
import { ProjectAnalyticsModal } from "components/analytics";
|
import { ProjectAnalyticsModal } from "components/analytics";
|
||||||
import { BreadcrumbLink } from "components/common";
|
import { BreadcrumbLink } from "components/common";
|
||||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||||
@ -102,6 +102,12 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
|||||||
const canUserCreateIssue =
|
const canUserCreateIssue =
|
||||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||||
|
|
||||||
|
const issueCount = currentProjectDetails
|
||||||
|
? issueFilters?.displayFilters?.sub_issue
|
||||||
|
? currentProjectDetails?.total_issues + currentProjectDetails?.sub_issues
|
||||||
|
: currentProjectDetails?.total_issues
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProjectAnalyticsModal
|
<ProjectAnalyticsModal
|
||||||
@ -113,7 +119,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
|||||||
<div className="flex items-center gap-2 p-4 border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
<div className="flex items-center gap-2 p-4 border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||||
<SidebarHamburgerToggle />
|
<SidebarHamburgerToggle />
|
||||||
<div>
|
<div className="flex items-center gap-2.5">
|
||||||
<Breadcrumbs onBack={() => router.back()}>
|
<Breadcrumbs onBack={() => router.back()}>
|
||||||
<Breadcrumbs.BreadcrumbItem
|
<Breadcrumbs.BreadcrumbItem
|
||||||
type="text"
|
type="text"
|
||||||
@ -145,6 +151,16 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
{issueCount && issueCount > 0 ? (
|
||||||
|
<Tooltip
|
||||||
|
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in this project`}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||||
|
{issueCount}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{currentProjectDetails?.is_deployed && deployUrl && (
|
{currentProjectDetails?.is_deployed && deployUrl && (
|
||||||
<a
|
<a
|
||||||
|
@ -73,7 +73,6 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
|
|||||||
reset,
|
reset,
|
||||||
watch,
|
watch,
|
||||||
getValues,
|
getValues,
|
||||||
setValue,
|
|
||||||
} = useForm({ defaultValues });
|
} = useForm({ defaultValues });
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
@ -32,7 +32,7 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => {
|
|||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
const newFilters: IIssueFilterOptions = {};
|
const newFilters: IIssueFilterOptions = {};
|
||||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||||
newFilters[key as keyof IIssueFilterOptions] = null;
|
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||||
});
|
});
|
||||||
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||||
...newFilters,
|
...newFilters,
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user